diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/CardBrowser.kt b/AnkiDroid/src/main/java/com/ichi2/anki/CardBrowser.kt index 7a7cbf0d0fd7..3ebf48f87349 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/CardBrowser.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/CardBrowser.kt @@ -723,7 +723,10 @@ open class CardBrowser : // restore drawer click listener and icon restoreDrawerIcon() menuInflater.inflate(R.menu.card_browser, menu) - setFlagTitles(menu) + menu.findItem(R.id.action_search_by_flag).subMenu?.let { + subMenu -> + setupFlags(subMenu, Mode.SINGLE_SELECT) + } saveSearchItem = menu.findItem(R.id.action_save_search) saveSearchItem?.isVisible = false // the searchview's query always starts empty. mySearchesItem = menu.findItem(R.id.action_list_my_searches) @@ -777,7 +780,10 @@ open class CardBrowser : } else { // multi-select mode menuInflater.inflate(R.menu.card_browser_multiselect, menu) - setMultiSelectFlagTitles(menu) + menu.findItem(R.id.action_flag).subMenu?.let { + subMenu -> + setupFlags(subMenu, Mode.MULTI_SELECT) + } showBackIcon() increaseHorizontalPaddingOfOverflowMenuIcons(menu) } @@ -795,6 +801,28 @@ open class CardBrowser : return super.onCreateOptionsMenu(menu) } + /** + * Representing different selection modes. + */ + enum class Mode(val value: Int) { + SINGLE_SELECT(1000), + MULTI_SELECT(1001) + } + + private fun setupFlags(subMenu: SubMenu, mode: Mode) { + lifecycleScope.launch { + val groupId = when (mode) { + Mode.SINGLE_SELECT -> mode.value + Mode.MULTI_SELECT -> mode.value + } + + for ((flag, displayName) in Flag.queryDisplayNames()) { + subMenu.add(groupId, flag.ordinal, Menu.NONE, displayName) + .setIcon(flag.drawableRes) + } + } + } + override fun onNavigationPressed() { if (viewModel.isInMultiSelectMode) { viewModel.endMultiSelectMode() @@ -803,28 +831,6 @@ open class CardBrowser : } } - private fun setFlagTitles(menu: Menu) { - menu.findItem(R.id.action_select_flag_zero).title = Flag.NONE.displayName() - menu.findItem(R.id.action_select_flag_one).title = Flag.RED.displayName() - menu.findItem(R.id.action_select_flag_two).title = Flag.ORANGE.displayName() - menu.findItem(R.id.action_select_flag_three).title = Flag.GREEN.displayName() - menu.findItem(R.id.action_select_flag_four).title = Flag.BLUE.displayName() - menu.findItem(R.id.action_select_flag_five).title = Flag.PINK.displayName() - menu.findItem(R.id.action_select_flag_six).title = Flag.TURQUOISE.displayName() - menu.findItem(R.id.action_select_flag_seven).title = Flag.PURPLE.displayName() - } - - private fun setMultiSelectFlagTitles(menu: Menu) { - menu.findItem(R.id.action_flag_zero).title = Flag.NONE.displayName() - menu.findItem(R.id.action_flag_one).title = Flag.RED.displayName() - menu.findItem(R.id.action_flag_two).title = Flag.ORANGE.displayName() - menu.findItem(R.id.action_flag_three).title = Flag.GREEN.displayName() - menu.findItem(R.id.action_flag_four).title = Flag.BLUE.displayName() - menu.findItem(R.id.action_flag_five).title = Flag.PINK.displayName() - menu.findItem(R.id.action_flag_six).title = Flag.TURQUOISE.displayName() - menu.findItem(R.id.action_flag_seven).title = Flag.PURPLE.displayName() - } - private fun updatePreviewMenuItem() { previewItem?.isVisible = viewModel.rowCount > 0 } @@ -931,6 +937,15 @@ open class CardBrowser : undoSnackbar != null && undoSnackbar!!.isShown -> undoSnackbar!!.dismiss() } + Flag.entries.find { it.ordinal == item.itemId }?.let { flag -> + when (item.groupId) { + Mode.SINGLE_SELECT.value -> filterByFlag(flag) + Mode.MULTI_SELECT.value -> updateFlagForSelectedRows(flag) + else -> return@let + } + return true + } + when (item.itemId) { android.R.id.home -> { viewModel.endMultiSelectMode() @@ -993,70 +1008,6 @@ open class CardBrowser : showFilterByTagsDialog() return true } - R.id.action_flag_zero -> { - updateFlagForSelectedRows(Flag.NONE) - return true - } - R.id.action_flag_one -> { - updateFlagForSelectedRows(Flag.RED) - return true - } - R.id.action_flag_two -> { - updateFlagForSelectedRows(Flag.ORANGE) - return true - } - R.id.action_flag_three -> { - updateFlagForSelectedRows(Flag.GREEN) - return true - } - R.id.action_flag_four -> { - updateFlagForSelectedRows(Flag.BLUE) - return true - } - R.id.action_flag_five -> { - updateFlagForSelectedRows(Flag.PINK) - return true - } - R.id.action_flag_six -> { - updateFlagForSelectedRows(Flag.TURQUOISE) - return true - } - R.id.action_flag_seven -> { - updateFlagForSelectedRows(Flag.PURPLE) - return true - } - R.id.action_select_flag_zero -> { - filterByFlag(Flag.NONE) - return true - } - R.id.action_select_flag_one -> { - filterByFlag(Flag.RED) - return true - } - R.id.action_select_flag_two -> { - filterByFlag(Flag.ORANGE) - return true - } - R.id.action_select_flag_three -> { - filterByFlag(Flag.GREEN) - return true - } - R.id.action_select_flag_four -> { - filterByFlag(Flag.BLUE) - return true - } - R.id.action_select_flag_five -> { - filterByFlag(Flag.PINK) - return true - } - R.id.action_select_flag_six -> { - filterByFlag(Flag.TURQUOISE) - return true - } - R.id.action_select_flag_seven -> { - filterByFlag(Flag.PURPLE) - return true - } R.id.action_delete_card -> { deleteSelectedNotes() return true diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/Flag.kt b/AnkiDroid/src/main/java/com/ichi2/anki/Flag.kt index 22dac034004d..503baf3ee4a3 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/Flag.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/Flag.kt @@ -18,9 +18,12 @@ package com.ichi2.anki import androidx.annotation.ColorRes import androidx.annotation.DrawableRes import com.ichi2.anki.CollectionManager.TR +import com.ichi2.anki.CollectionManager.withCol +import com.ichi2.anki.utils.ext.getStringOrNull import com.ichi2.libanki.Card import com.ichi2.libanki.CardId import com.ichi2.libanki.Collection +import org.json.JSONObject enum class Flag(val code: Int, @DrawableRes val drawableRes: Int, @ColorRes val browserColorRes: Int?) { NONE(0, R.drawable.ic_flag_transparent, null), @@ -32,7 +35,18 @@ enum class Flag(val code: Int, @DrawableRes val drawableRes: Int, @ColorRes val TURQUOISE(6, R.drawable.ic_flag_turquoise, R.color.flag_turquoise), PURPLE(7, R.drawable.ic_flag_purple, R.color.flag_purple); - fun displayName(): String = when (this) { + /** + * Retrieves the name associated with the flag. This may be user-defined + * + * @see queryDisplayNames - more efficient + */ + private fun displayName(labels: FlagLabels): String { + // NONE may not be renamed + if (this == NONE) return defaultDisplayName() + return labels.getLabel(this) ?: defaultDisplayName() + } + + private fun defaultDisplayName(): String = when (this) { NONE -> TR.browsingNoFlag() RED -> TR.actionsFlagRed() ORANGE -> TR.actionsFlagOrange() @@ -47,7 +61,36 @@ enum class Flag(val code: Int, @DrawableRes val drawableRes: Int, @ColorRes val fun fromCode(code: Int): Flag { return entries.first { it.code == code } } + + /** + * @return A mapping from each [Flag] to its display name (optionally user-defined) + */ + suspend fun queryDisplayNames(): Map { + // load user-defined flag labels from the collection + val labels = FlagLabels.loadFromColConfig() + // either map to user-provided name, or translated name + return Flag.entries.associateWith { it.displayName(labels) } + } } } + +/** + * User-defined labels for flags. Stored in the collection optionally as `{ "1": "Redd" }` + * [Flag.NONE] does not have a label + */ +@JvmInline +private value class FlagLabels(val value: JSONObject) { + /** + * @return the user-defined label for the provided flag, or null if undefined + * This is not supported for [Flag.NONE] and is validated outside this method + */ + fun getLabel(flag: Flag): String? = value.getStringOrNull(flag.code.toString()) + + companion object { + suspend fun loadFromColConfig() = + FlagLabels(withCol { config.getObject("flagLabels", JSONObject()) }) + } +} + fun Collection.setUserFlag(flag: Flag, cids: List) = this.setUserFlag(flag.code, cids) fun Card.setUserFlag(flag: Flag) = this.setUserFlag(flag.code) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/Reviewer.kt b/AnkiDroid/src/main/java/com/ichi2/anki/Reviewer.kt index 771a4983a2c6..8719b2e979c3 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/Reviewer.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/Reviewer.kt @@ -40,12 +40,12 @@ import androidx.appcompat.view.menu.MenuBuilder import androidx.appcompat.widget.Toolbar import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat +import androidx.lifecycle.lifecycleScope import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat import anki.frontend.SetSchedulingStatesRequest import com.google.android.material.color.MaterialColors import com.google.android.material.snackbar.Snackbar import com.ichi2.anim.ActivityTransitionAnimation.getInverseTransition -import com.ichi2.anki.CollectionManager.TR import com.ichi2.anki.CollectionManager.withCol import com.ichi2.anki.Whiteboard.Companion.createInstance import com.ichi2.anki.Whiteboard.OnPaintColorChangeListener @@ -89,6 +89,7 @@ import com.ichi2.utils.HandlerUtils.getDefaultLooper import com.ichi2.utils.Permissions.canRecordAudio import com.ichi2.utils.ViewGroupUtils.setRenderWorkaround import com.ichi2.widget.WidgetStatus.updateInBackground +import kotlinx.coroutines.launch import timber.log.Timber import java.io.File @@ -169,6 +170,8 @@ open class Reviewer : FlashCardViewerResultCallback() ) + private val flagItemIds = mutableSetOf() + override fun onCreate(savedInstanceState: Bundle?) { if (showedActivityFailedScreen(savedInstanceState)) { return @@ -353,6 +356,13 @@ open class Reviewer : if (drawerToggle.onOptionsItemSelected(item)) { return true } + + Flag.entries.find { it.ordinal == item.itemId }?.let { flag -> + Timber.i("Reviewer:: onOptionItemSelected Flag - ${flag.name} clicked") + onFlag(currentCard, flag) + return true + } + when (item.itemId) { android.R.id.home -> { Timber.i("Reviewer:: Home button pressed") @@ -452,38 +462,6 @@ open class Reviewer : Timber.i("Reviewer:: Add note button pressed") addNote() } - R.id.action_flag_zero -> { - Timber.i("Reviewer:: No flag") - onFlag(currentCard, Flag.NONE) - } - R.id.action_flag_one -> { - Timber.i("Reviewer:: Flag one") - onFlag(currentCard, Flag.RED) - } - R.id.action_flag_two -> { - Timber.i("Reviewer:: Flag two") - onFlag(currentCard, Flag.ORANGE) - } - R.id.action_flag_three -> { - Timber.i("Reviewer:: Flag three") - onFlag(currentCard, Flag.GREEN) - } - R.id.action_flag_four -> { - Timber.i("Reviewer:: Flag four") - onFlag(currentCard, Flag.BLUE) - } - R.id.action_flag_five -> { - Timber.i("Reviewer:: Flag five") - onFlag(currentCard, Flag.PINK) - } - R.id.action_flag_six -> { - Timber.i("Reviewer:: Flag six") - onFlag(currentCard, Flag.TURQUOISE) - } - R.id.action_flag_seven -> { - Timber.i("Reviewer:: Flag seven") - onFlag(currentCard, Flag.PURPLE) - } R.id.action_card_info -> { Timber.i("Card Viewer:: Card Info") openCardInfo() @@ -692,7 +670,7 @@ open class Reviewer : Timber.d("onCreateOptionsMenu()") // NOTE: This is called every time a new question is shown via invalidate options menu menuInflater.inflate(R.menu.reviewer, menu) - setFlagTitles(menu) + menu.findItem(R.id.action_flag).subMenu?.let { subMenu -> setupFlags(subMenu) } displayIcons(menu) actionButtons.setCustomButtonsStatus(menu) val alpha = Themes.ALPHA_ICON_ENABLED_LIGHT @@ -844,20 +822,19 @@ open class Reviewer : onboarding.onCreate() increaseHorizontalPaddingOfOverflowMenuIcons(menu) - tintOverflowMenuIcons(menu, skipIf = { isFlagResource(it.itemId) }) + tintOverflowMenuIcons(menu, skipIf = { isFlagItem(it) }) return super.onCreateOptionsMenu(menu) } - private fun setFlagTitles(menu: Menu) { - menu.findItem(R.id.action_flag_zero).title = Flag.NONE.displayName() - menu.findItem(R.id.action_flag_one).title = Flag.RED.displayName() - menu.findItem(R.id.action_flag_two).title = Flag.ORANGE.displayName() - menu.findItem(R.id.action_flag_three).title = Flag.GREEN.displayName() - menu.findItem(R.id.action_flag_four).title = Flag.BLUE.displayName() - menu.findItem(R.id.action_flag_five).title = Flag.PINK.displayName() - menu.findItem(R.id.action_flag_six).title = Flag.TURQUOISE.displayName() - menu.findItem(R.id.action_flag_seven).title = Flag.PURPLE.displayName() + private fun setupFlags(subMenu: SubMenu) { + lifecycleScope.launch { + for ((flag, displayName) in Flag.queryDisplayNames()) { + val menuItem = subMenu.add(Menu.NONE, flag.ordinal, Menu.NONE, displayName) + .setIcon(flag.drawableRes) + flagItemIds.add(menuItem.itemId) + } + } } @SuppressLint("RestrictedApi") @@ -873,8 +850,8 @@ open class Reviewer : } } - private fun isFlagResource(itemId: Int): Boolean { - return itemId == R.id.action_flag_seven || itemId == R.id.action_flag_six || itemId == R.id.action_flag_five || itemId == R.id.action_flag_four || itemId == R.id.action_flag_three || itemId == R.id.action_flag_two || itemId == R.id.action_flag_one + private fun isFlagItem(menuItem: MenuItem): Boolean { + return flagItemIds.contains(menuItem.itemId) } override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/previewer/PreviewerFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/PreviewerFragment.kt index a1bf9534ea2d..b5567e279e06 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/previewer/PreviewerFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/PreviewerFragment.kt @@ -50,6 +50,7 @@ import com.ichi2.annotations.NeedsTest import com.ichi2.utils.performClickIfEnabled import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch +import timber.log.Timber class PreviewerFragment : CardViewerFragment(R.layout.previewer), @@ -97,7 +98,7 @@ class PreviewerFragment : } /* ************************************* Menu items ************************************* */ val menu = view.findViewById(R.id.toolbar).menu - setFlagTitles(menu) + setupFlagMenu(menu) lifecycleScope.launch { viewModel.backSideOnly @@ -123,6 +124,7 @@ class PreviewerFragment : } } + // handle selection of a new flag lifecycleScope.launch { viewModel.flagCode .flowWithLifecycle(lifecycle) @@ -188,34 +190,31 @@ class PreviewerFragment : } } + private fun setupFlagMenu(menu: Menu) { + val submenu = menu.findItem(R.id.action_flag).subMenu + lifecycleScope.launch { + for ((flag, name) in Flag.queryDisplayNames()) { + submenu?.add(Menu.NONE, flag.ordinal, Menu.NONE, name) + ?.setIcon(flag.drawableRes) + } + } + } + override fun onMenuItemClick(item: MenuItem): Boolean { + Flag.entries.find { it.ordinal == item.itemId }?.let { flag -> + Timber.i("PreviewerFragment:: onMenuItemClick Flag - ${flag.name} clicked") + viewModel.setFlag(flag) + return true + } + when (item.itemId) { R.id.action_edit -> editCard() R.id.action_mark -> viewModel.toggleMark() R.id.action_back_side_only -> viewModel.toggleBackSideOnly() - R.id.action_flag_zero -> viewModel.setFlag(Flag.NONE) - R.id.action_flag_one -> viewModel.setFlag(Flag.RED) - R.id.action_flag_two -> viewModel.setFlag(Flag.ORANGE) - R.id.action_flag_three -> viewModel.setFlag(Flag.GREEN) - R.id.action_flag_four -> viewModel.setFlag(Flag.BLUE) - R.id.action_flag_five -> viewModel.setFlag(Flag.PINK) - R.id.action_flag_six -> viewModel.setFlag(Flag.TURQUOISE) - R.id.action_flag_seven -> viewModel.setFlag(Flag.PURPLE) } return true } - private fun setFlagTitles(menu: Menu) { - menu.findItem(R.id.action_flag_zero).title = Flag.NONE.displayName() - menu.findItem(R.id.action_flag_one).title = Flag.RED.displayName() - menu.findItem(R.id.action_flag_two).title = Flag.ORANGE.displayName() - menu.findItem(R.id.action_flag_three).title = Flag.GREEN.displayName() - menu.findItem(R.id.action_flag_four).title = Flag.BLUE.displayName() - menu.findItem(R.id.action_flag_five).title = Flag.PINK.displayName() - menu.findItem(R.id.action_flag_six).title = Flag.TURQUOISE.displayName() - menu.findItem(R.id.action_flag_seven).title = Flag.PURPLE.displayName() - } - private fun setBackSideOnlyButtonIcon(menu: Menu, isBackSideOnly: Boolean) { menu.findItem(R.id.action_back_side_only).apply { if (isBackSideOnly) { diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/JSONObject.kt b/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/JSONObject.kt new file mode 100644 index 000000000000..561f3c1cf10a --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/JSONObject.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2024 Ashish Yadav + * + * 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.utils.ext + +import org.json.JSONObject + +fun JSONObject.getStringOrNull(key: String): String? { + if (!has(key)) return null + return try { + getString(key) + } catch (_: Exception) { + null + } +} diff --git a/AnkiDroid/src/main/java/com/ichi2/libanki/Config.kt b/AnkiDroid/src/main/java/com/ichi2/libanki/Config.kt index ae110942988b..4181ec535bb3 100644 --- a/AnkiDroid/src/main/java/com/ichi2/libanki/Config.kt +++ b/AnkiDroid/src/main/java/com/ichi2/libanki/Config.kt @@ -25,6 +25,7 @@ import kotlinx.serialization.json.Json import net.ankiweb.rsdroid.Backend import net.ankiweb.rsdroid.exceptions.BackendNotFoundException import org.json.JSONArray +import org.json.JSONException import org.json.JSONObject class Config(val backend: Backend) { @@ -69,4 +70,15 @@ class Config(val backend: Backend) { null } } + + @NotInLibAnki + fun getObject(key: String, default: JSONObject): JSONObject { + return try { + JSONObject(backend.getConfigJson(key).toStringUtf8()) + } catch (ex: BackendNotFoundException) { + default + } catch (ex: JSONException) { + default + } + } } diff --git a/AnkiDroid/src/main/res/menu/card_browser.xml b/AnkiDroid/src/main/res/menu/card_browser.xml index 2481fbcaf0e8..750f60a2993c 100644 --- a/AnkiDroid/src/main/res/menu/card_browser.xml +++ b/AnkiDroid/src/main/res/menu/card_browser.xml @@ -39,37 +39,7 @@ android:id="@+id/action_search_by_flag" android:title="@string/card_browser_search_by_flag"> - - - - - - - - + diff --git a/AnkiDroid/src/main/res/menu/card_browser_multiselect.xml b/AnkiDroid/src/main/res/menu/card_browser_multiselect.xml index 64deb37a7150..7d7248a6575d 100644 --- a/AnkiDroid/src/main/res/menu/card_browser_multiselect.xml +++ b/AnkiDroid/src/main/res/menu/card_browser_multiselect.xml @@ -25,37 +25,7 @@ android:title="@string/menu_flag_card" android:icon="@drawable/ic_flag_transparent"> - - - - - - - - + diff --git a/AnkiDroid/src/main/res/menu/previewer.xml b/AnkiDroid/src/main/res/menu/previewer.xml index 1c53b71283d6..4794fcb22eed 100644 --- a/AnkiDroid/src/main/res/menu/previewer.xml +++ b/AnkiDroid/src/main/res/menu/previewer.xml @@ -28,37 +28,7 @@ android:icon="@drawable/ic_flag_transparent" app:showAsAction="always" > - - - - - - - - + - - - - - - - - +