From ba698ded0cc2e5fc18d037ae69289398f89b147f Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Wed, 2 Jul 2025 03:38:47 +0100 Subject: [PATCH 01/14] improvement(libAnki): expose deck 'descriptionAsMarkdown' I'm not sure if we'll want to implement the setter, but the getter is useful https://redirect.github.com/ankitects/anki/commit/ded626f0b9685355e9856c352daf21c09382999e Issue 18749 --- .../main/java/com/ichi2/anki/libanki/Deck.kt | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/libanki/src/main/java/com/ichi2/anki/libanki/Deck.kt b/libanki/src/main/java/com/ichi2/anki/libanki/Deck.kt index f8d5cf0bb791..204d2e6e3122 100644 --- a/libanki/src/main/java/com/ichi2/anki/libanki/Deck.kt +++ b/libanki/src/main/java/com/ichi2/anki/libanki/Deck.kt @@ -69,4 +69,24 @@ class Deck : JSONObject { set(value) { put("conf", value) } + + /** + * Treats [description] as markdown, cleaning HTML input and stripping images. + * + * If disabled, the description is only shown on the deck overview. + * If enabled, it is also shown on the congratulations screen. + * + * Markdown will appear as text on Anki 2.1.40 and below. + * + * Anki names this feature 'md': markdown description + * + * @see anki.backend.GeneratedBackend.renderMarkdown + * @see anki.i18n.GeneratedTranslations.deckConfigDescriptionNewHandling + * @see anki.i18n.GeneratedTranslations.deckConfigDescriptionNewHandlingHint + */ + var descriptionAsMarkdown: Boolean + get() = optBoolean("md", false) + set(value) { + put("md", value) + } } From 63e4e87f9cf1795ee318863b12f28f479368f030 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Wed, 2 Jul 2025 16:05:16 +0100 Subject: [PATCH 02/14] refactor(libanki): make 'description' a property of 'Deck' --- .../java/com/ichi2/anki/StudyOptionsFragment.kt | 1 - .../anki/dialogs/EditDeckDescriptionDialog.kt | 3 +-- .../ichi2/anki/provider/CardContentProvider.kt | 1 - .../main/java/com/ichi2/anki/utils/ext/Deck.kt | 6 ------ .../src/main/java/com/ichi2/anki/libanki/Deck.kt | 15 +++++++++++++-- 5 files changed, 14 insertions(+), 12 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/StudyOptionsFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/StudyOptionsFragment.kt index 4d2bd6bf1b1d..a5905f54dd75 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/StudyOptionsFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/StudyOptionsFragment.kt @@ -50,7 +50,6 @@ import com.ichi2.anki.reviewreminders.ScheduleReminders import com.ichi2.anki.settings.Prefs import com.ichi2.anki.snackbar.showSnackbar import com.ichi2.anki.ui.internationalization.toSentenceCase -import com.ichi2.anki.utils.ext.description import com.ichi2.anki.utils.ext.showDialogFragment import com.ichi2.utils.HtmlUtils.convertNewlinesToHtml import kotlinx.coroutines.Job diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialog.kt index fad6d2fd0a94..68441f6cbb33 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialog.kt @@ -29,14 +29,13 @@ import com.ichi2.anki.R import com.ichi2.anki.StudyOptionsFragment import com.ichi2.anki.launchCatchingTask import com.ichi2.anki.libanki.DeckId -import com.ichi2.anki.utils.ext.description import com.ichi2.anki.utils.ext.update import com.ichi2.themes.Themes import com.ichi2.utils.AndroidUiUtils.setFocusAndOpenKeyboard import timber.log.Timber /** - * Allows a user to edit the [deck description][description] + * Allows a user to edit the [deck description][com.ichi2.anki.libanki.Deck.description] * * This is visible on [StudyOptionsFragment] */ diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/provider/CardContentProvider.kt b/AnkiDroid/src/main/java/com/ichi2/anki/provider/CardContentProvider.kt index 7c16d908238c..86d0cf6bcee0 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/provider/CardContentProvider.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/provider/CardContentProvider.kt @@ -57,7 +57,6 @@ import com.ichi2.anki.libanki.exception.ConfirmModSchemaException import com.ichi2.anki.libanki.exception.EmptyMediaException import com.ichi2.anki.libanki.sched.DeckNode import com.ichi2.anki.libanki.sched.Ease -import com.ichi2.anki.utils.ext.description import com.ichi2.utils.FileUtil import com.ichi2.utils.FileUtil.internalizeUri import com.ichi2.utils.Permissions.arePermissionsDefinedInManifest diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/Deck.kt b/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/Deck.kt index 70b21666dcc5..b5323ec972db 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/Deck.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/Deck.kt @@ -20,12 +20,6 @@ import com.ichi2.anki.libanki.Deck import com.ichi2.anki.libanki.DeckId import com.ichi2.anki.libanki.Decks -var Deck.description: String - get() = optString("desc", "") - set(value) { - put("desc", value) - } - fun Decks.update( did: DeckId, block: Deck.() -> Unit, diff --git a/libanki/src/main/java/com/ichi2/anki/libanki/Deck.kt b/libanki/src/main/java/com/ichi2/anki/libanki/Deck.kt index 204d2e6e3122..b40b4ced1733 100644 --- a/libanki/src/main/java/com/ichi2/anki/libanki/Deck.kt +++ b/libanki/src/main/java/com/ichi2/anki/libanki/Deck.kt @@ -71,14 +71,25 @@ class Deck : JSONObject { } /** - * Treats [description] as markdown, cleaning HTML input and stripping images. + * The description, shown on the deck overview and optionally the congratulations screen. + * + * May be HTML or Markdown, depending on [descriptionAsMarkdown]. + */ + var description: String + get() = optString("desc", "") + set(value) { + put("desc", value) + } + + /** + * Treats [description] as Markdown, cleaning HTML input and stripping images. * * If disabled, the description is only shown on the deck overview. * If enabled, it is also shown on the congratulations screen. * * Markdown will appear as text on Anki 2.1.40 and below. * - * Anki names this feature 'md': markdown description + * Anki names this feature 'md': Markdown description * * @see anki.backend.GeneratedBackend.renderMarkdown * @see anki.i18n.GeneratedTranslations.deckConfigDescriptionNewHandling From f82b27ebe30fa30b90e62dabaa1663ebdb6a8d41 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Wed, 2 Jul 2025 16:24:39 +0100 Subject: [PATCH 03/14] feat(study-options): render description as markdown https://redirect.github.com/ankitects/anki/blob/da907053460e2b78c31199f97bbea3cf3600f0c2/pylib/anki/collection.py#L1171 Issue 18749 --- .../java/com/ichi2/anki/StudyOptionsFragment.kt | 8 +++++++- .../java/com/ichi2/anki/libanki/Collection.kt | 15 +++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/StudyOptionsFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/StudyOptionsFragment.kt index a5905f54dd75..66114aa4d9a8 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/StudyOptionsFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/StudyOptionsFragment.kt @@ -607,11 +607,17 @@ class StudyOptionsFragment : } // Set deck description + @Language("HTML") val desc: String = if (isDynamic) { resources.getString(R.string.dyn_deck_desc) } else { - col.decks.current().description + val deck = col.decks.current() + if (deck.descriptionAsMarkdown) { + col.renderMarkdown(deck.description, sanitize = true) + } else { + deck.description + } } if (desc.isNotEmpty()) { textDeckDescription.text = formatDescription(desc) diff --git a/libanki/src/main/java/com/ichi2/anki/libanki/Collection.kt b/libanki/src/main/java/com/ichi2/anki/libanki/Collection.kt index 58906c567ddf..0efb78844fc8 100644 --- a/libanki/src/main/java/com/ichi2/anki/libanki/Collection.kt +++ b/libanki/src/main/java/com/ichi2/anki/libanki/Collection.kt @@ -67,6 +67,7 @@ import com.ichi2.anki.libanki.utils.NotInLibAnki import net.ankiweb.rsdroid.Backend import net.ankiweb.rsdroid.RustCleanup import net.ankiweb.rsdroid.exceptions.BackendInvalidInputException +import org.intellij.lang.annotations.Language import timber.log.Timber import java.io.File @@ -1148,6 +1149,20 @@ class Collection( fun evaluateParamsLegacyRaw(input: ByteArray): ByteArray = backend.evaluateParamsLegacyRaw(input = input) + /** + * Converts Markdown ([text]) to HTML + * + * @param text Markdown to format as HTML + * @param sanitize whether to sanitize the HTML using + * [ammonia](https://docs.rs/ammonia/latest/ammonia/). `img` tags are also stripped + */ + @Language("HTML") + @LibAnkiAlias("render_markdown") + fun renderMarkdown( + text: String, + sanitize: Boolean, + ): String = backend.renderMarkdown(markdown = text, sanitize = sanitize) + fun compareAnswer( expected: String, provided: String, From 6cd360de0e970b134902eb412743fb09a19a6875 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Wed, 2 Jul 2025 03:44:54 +0100 Subject: [PATCH 04/14] refactor(deck-description): onViewCreated --- .../anki/dialogs/EditDeckDescriptionDialog.kt | 60 ++++++++++--------- 1 file changed, 31 insertions(+), 29 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialog.kt index 68441f6cbb33..a9952bb14aa0 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialog.kt @@ -17,7 +17,6 @@ package com.ichi2.anki.dialogs import android.os.Bundle -import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.core.os.bundleOf @@ -30,7 +29,6 @@ import com.ichi2.anki.StudyOptionsFragment import com.ichi2.anki.launchCatchingTask import com.ichi2.anki.libanki.DeckId import com.ichi2.anki.utils.ext.update -import com.ichi2.themes.Themes import com.ichi2.utils.AndroidUiUtils.setFocusAndOpenKeyboard import timber.log.Timber @@ -39,7 +37,7 @@ import timber.log.Timber * * This is visible on [StudyOptionsFragment] */ -class EditDeckDescriptionDialog : DialogFragment() { +class EditDeckDescriptionDialog : DialogFragment(R.layout.dialog_deck_description) { private val deckId: DeckId get() = requireArguments().getLong(ARG_DECK_ID) @@ -51,36 +49,40 @@ class EditDeckDescriptionDialog : DialogFragment() { deckDescriptionInput.setText(value) } - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, + override fun onViewCreated( + view: View, savedInstanceState: Bundle?, - ): View { - Themes.setTheme(requireContext()) - return inflater.inflate(R.layout.dialog_deck_description, null).apply { - deckDescriptionInput = this.findViewById(R.id.deck_description_input) - launchCatchingTask { - currentDescription = getDescription() - } - findViewById(R.id.topAppBar) - .apply { - setNavigationOnClickListener { - onBack() - } + ) { + super.onViewCreated(view, savedInstanceState) + deckDescriptionInput = view.findViewById(R.id.deck_description_input) + + // load initial state + launchCatchingTask { + currentDescription = getDescription() + } - setOnMenuItemClickListener { menuItem -> - if (menuItem.itemId == R.id.action_save) { - saveAndExit() - true - } else { - false - } + // setup App Bar + view + .findViewById(R.id.topAppBar) + .apply { + setNavigationOnClickListener { + onBack() + } + + setOnMenuItemClickListener { menuItem -> + if (menuItem.itemId == R.id.action_save) { + saveAndExit() + true + } else { + false } - }.also { toolbar -> - launchCatchingTask { toolbar.title = withCol { decks.getLegacy(deckId)!!.name } } } - setFocusAndOpenKeyboard(deckDescriptionInput) { deckDescriptionInput.setSelection(deckDescriptionInput.text!!.length) } - } + }.also { toolbar -> + launchCatchingTask { toolbar.title = withCol { decks.getLegacy(deckId)!!.name } } + } + + // setup input controls + setFocusAndOpenKeyboard(deckDescriptionInput) { deckDescriptionInput.setSelection(deckDescriptionInput.text!!.length) } } override fun onStart() { From 27c9e516f9d9a9610f561cbdf2a3943bb885d1f4 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Wed, 2 Jul 2025 03:56:15 +0100 Subject: [PATCH 05/14] improvement(deck-description): UI design * fix margins to be standard 24dp * add icon for save * add content description for 'close' Fixes Issue 18528 --- .../main/res/layout/dialog_deck_description.xml | 15 ++++++++++----- .../src/main/res/menu/menu_deck_description.xml | 1 + 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/AnkiDroid/src/main/res/layout/dialog_deck_description.xml b/AnkiDroid/src/main/res/layout/dialog_deck_description.xml index 82c14ce43ad3..d4f4277c5541 100644 --- a/AnkiDroid/src/main/res/layout/dialog_deck_description.xml +++ b/AnkiDroid/src/main/res/layout/dialog_deck_description.xml @@ -19,7 +19,9 @@ xmlns:tools="http://schemas.android.com/tools" android:orientation="vertical" android:layout_width="match_parent" - android:layout_height="wrap_content"> + android:layout_height="match_parent" + android:layout_marginHorizontal="8dp" + > diff --git a/AnkiDroid/src/main/res/menu/menu_deck_description.xml b/AnkiDroid/src/main/res/menu/menu_deck_description.xml index e83aa95a2a4d..122f0ffc5c93 100644 --- a/AnkiDroid/src/main/res/menu/menu_deck_description.xml +++ b/AnkiDroid/src/main/res/menu/menu_deck_description.xml @@ -20,6 +20,7 @@ From c612c1a2ef90c197f5410807579ced1e5a7808fa Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Wed, 2 Jul 2025 16:40:01 +0100 Subject: [PATCH 06/14] fix(deck-description): don't cancel on touch outside/back * touch outside -> no-op --- .../com/ichi2/anki/dialogs/EditDeckDescriptionDialog.kt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialog.kt index a9952bb14aa0..b4f44ccc1a1a 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialog.kt @@ -16,6 +16,7 @@ package com.ichi2.anki.dialogs +import android.app.Dialog import android.os.Bundle import android.view.View import android.view.ViewGroup @@ -49,6 +50,11 @@ class EditDeckDescriptionDialog : DialogFragment(R.layout.dialog_deck_descriptio deckDescriptionInput.setText(value) } + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = + super.onCreateDialog(savedInstanceState).also { + it.setCanceledOnTouchOutside(false) + } + override fun onViewCreated( view: View, savedInstanceState: Bundle?, From f9390de7ab9dc1ba21eb1bbdbbfac18162ba7bc7 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sun, 13 Jul 2025 20:57:34 +0100 Subject: [PATCH 07/14] refactor(deck-description): introduce viewmodel * fixes saved state restoration --- .../anki/dialogs/EditDeckDescriptionDialog.kt | 125 +++++++++--------- .../EditDeckDescriptionDialogViewModel.kt | 99 ++++++++++++++ .../EditDeckDescriptionDialogViewModelTest.kt | 124 +++++++++++++++++ 3 files changed, 282 insertions(+), 66 deletions(-) create mode 100644 AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialogViewModel.kt create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialogViewModelTest.kt diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialog.kt index b4f44ccc1a1a..14e9c6c02c57 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialog.kt @@ -21,17 +21,17 @@ import android.os.Bundle import android.view.View import android.view.ViewGroup import androidx.core.os.bundleOf +import androidx.core.widget.doOnTextChanged import androidx.fragment.app.DialogFragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope import com.google.android.material.appbar.MaterialToolbar import com.google.android.material.textfield.TextInputEditText -import com.ichi2.anki.CollectionManager.withCol import com.ichi2.anki.R import com.ichi2.anki.StudyOptionsFragment -import com.ichi2.anki.launchCatchingTask import com.ichi2.anki.libanki.DeckId -import com.ichi2.anki.utils.ext.update import com.ichi2.utils.AndroidUiUtils.setFocusAndOpenKeyboard -import timber.log.Timber +import kotlinx.coroutines.launch /** * Allows a user to edit the [deck description][com.ichi2.anki.libanki.Deck.description] @@ -39,16 +39,11 @@ import timber.log.Timber * This is visible on [StudyOptionsFragment] */ class EditDeckDescriptionDialog : DialogFragment(R.layout.dialog_deck_description) { - private val deckId: DeckId - get() = requireArguments().getLong(ARG_DECK_ID) + private val viewModel: EditDeckDescriptionDialogViewModel by viewModels() private lateinit var deckDescriptionInput: TextInputEditText - private var currentDescription - get() = deckDescriptionInput.text.toString() - set(value) { - deckDescriptionInput.setText(value) - } + private lateinit var toolbar: MaterialToolbar override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = super.onCreateDialog(savedInstanceState).also { @@ -60,86 +55,84 @@ class EditDeckDescriptionDialog : DialogFragment(R.layout.dialog_deck_descriptio savedInstanceState: Bundle?, ) { super.onViewCreated(view, savedInstanceState) - deckDescriptionInput = view.findViewById(R.id.deck_description_input) - - // load initial state - launchCatchingTask { - currentDescription = getDescription() - } + deckDescriptionInput = + view.findViewById(R.id.deck_description_input).apply { + doOnTextChanged { text, _, _, _ -> + viewModel.description = text?.toString() ?: "" + } + } // setup App Bar - view - .findViewById(R.id.topAppBar) - .apply { - setNavigationOnClickListener { - onBack() - } + toolbar = + view + .findViewById(R.id.topAppBar) + .apply { + setNavigationOnClickListener { + viewModel.onBackRequested() + } - setOnMenuItemClickListener { menuItem -> - if (menuItem.itemId == R.id.action_save) { - saveAndExit() - true - } else { - false + setOnMenuItemClickListener { menuItem -> + when (menuItem.itemId) { + R.id.action_save -> { + viewModel.saveAndExit() + true + } + else -> false + } } } - }.also { toolbar -> - launchCatchingTask { toolbar.title = withCol { decks.getLegacy(deckId)!!.name } } - } - // setup input controls - setFocusAndOpenKeyboard(deckDescriptionInput) { deckDescriptionInput.setSelection(deckDescriptionInput.text!!.length) } - } - - override fun onStart() { - super.onStart() - - dialog!!.window!!.setLayout( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ) + setupFlows() } - private fun saveAndExit() = - launchCatchingTask { - setDescription(currentDescription) - Timber.i("closing deck description dialog") - dismiss() + private fun setupFlows() { + lifecycleScope.launch { + viewModel.flowOfDismissDialog.collect { dismiss -> + if (dismiss) { + dismiss() + } + } } - private fun onBack() = - launchCatchingTask { - fun closeWithoutSaving() { - Timber.i("Closing dialog without saving") - dismiss() + lifecycleScope.launch { + viewModel.flowOfDescription.collect { desc -> + if (desc == deckDescriptionInput.text.toString()) return@collect + deckDescriptionInput.setText(desc) } + } - if (getDescription() == currentDescription) { - closeWithoutSaving() - return@launchCatchingTask + lifecycleScope.launch { + viewModel.flowOfInitCompleted.collect { + if (!it) return@collect + toolbar.title = viewModel.windowTitle + setFocusAndOpenKeyboard(deckDescriptionInput) { deckDescriptionInput.setSelection(deckDescriptionInput.text!!.length) } } + } - Timber.i("asking if user should discard changes") - DiscardChangesDialog.showDialog(requireContext()) { - closeWithoutSaving() + lifecycleScope.launch { + viewModel.flowOfShowDiscardChanges.collect { + DiscardChangesDialog.showDialog(requireContext()) { + viewModel.closeWithoutSaving() + } } } + } - private suspend fun getDescription() = withCol { decks.getLegacy(deckId)!!.description } + override fun onStart() { + super.onStart() - private suspend fun setDescription(value: String) { - Timber.i("updating deck description") - withCol { decks.update(deckId) { description = value } } + dialog!!.window!!.setLayout( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ) } companion object { - private const val ARG_DECK_ID = "deckId" - fun newInstance(deckId: DeckId): EditDeckDescriptionDialog = EditDeckDescriptionDialog().apply { arguments = bundleOf( - ARG_DECK_ID to deckId, + EditDeckDescriptionDialogViewModel.ARG_DECK_ID to deckId, ) } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialogViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialogViewModel.kt new file mode 100644 index 000000000000..d0f0210dee83 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialogViewModel.kt @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2025 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.dialogs + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.ichi2.anki.CollectionManager.withCol +import com.ichi2.anki.libanki.DeckId +import com.ichi2.anki.utils.ext.update +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import timber.log.Timber + +class EditDeckDescriptionDialogViewModel( + private val stateHandle: SavedStateHandle, +) : ViewModel() { + val deckId: DeckId + get() = requireNotNull(stateHandle.get(ARG_DECK_ID)) + + lateinit var windowTitle: String + + val flowOfDescription = MutableStateFlow(stateHandle.get(STATE_DESCRIPTION) ?: "") + + var description + get() = flowOfDescription.value + set(value) { + stateHandle[STATE_DESCRIPTION] = value + flowOfDescription.value = value + } + + // TODO: make this a unit + val flowOfDismissDialog = MutableStateFlow(false) + + val flowOfShowDiscardChanges = MutableSharedFlow() + + val flowOfInitCompleted = MutableStateFlow(false) + + init { + viewModelScope.launch { + windowTitle = withCol { decks.getLegacy(deckId)!!.name } + if (description.isEmpty()) { + description = getDescription() + } + flowOfInitCompleted.emit(true) + } + } + + fun onBackRequested() = + viewModelScope.launch { + if (getDescription() == description) { + closeWithoutSaving() + return@launch + } + + Timber.i("asking if user should discard changes") + flowOfShowDiscardChanges.emit(Unit) + } + + fun closeWithoutSaving() = + viewModelScope.launch { + Timber.i("Closing dialog without saving") + flowOfDismissDialog.emit(true) + } + + fun saveAndExit() = + viewModelScope.launch { + setDescription(description) + Timber.i("closing deck description dialog") + flowOfDismissDialog.emit(true) + } + + private suspend fun getDescription() = withCol { decks.getLegacy(deckId)!!.description } + + private suspend fun setDescription(value: String) { + Timber.i("updating deck description") + withCol { decks.update(deckId) { description = value } } + } + + companion object { + const val ARG_DECK_ID = "deckId" + const val STATE_DESCRIPTION = "description" + } +} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialogViewModelTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialogViewModelTest.kt new file mode 100644 index 000000000000..949a000dc209 --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialogViewModelTest.kt @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2025 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.dialogs + +import androidx.lifecycle.SavedStateHandle +import androidx.test.ext.junit.runners.AndroidJUnit4 +import app.cash.turbine.test +import com.ichi2.anki.dialogs.EditDeckDescriptionDialogViewModel.Companion.ARG_DECK_ID +import com.ichi2.anki.dialogs.EditDeckDescriptionDialogViewModel.Companion.STATE_DESCRIPTION +import com.ichi2.anki.libanki.Consts.DEFAULT_DECK_ID +import com.ichi2.anki.libanki.testutils.AnkiTest +import com.ichi2.testutils.JvmTest +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.equalTo +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Tests [EditDeckDescriptionDialogViewModel] + */ +@RunWith(AndroidJUnit4::class) +class EditDeckDescriptionDialogViewModelTest : JvmTest() { + @Test + fun `title is set`() = + runViewModelTest { + assertThat(windowTitle, equalTo("Default")) + } + + @Test + fun `default description is empty`() = + runViewModelTest { + assertThat(description, equalTo("")) + } + + @Test + fun `description is updated in database`() = + runViewModelTest { + description = "foo" + assertThat("description not updated before save", defaultDeck.description, equalTo("")) + + saveAndExit() + + assertThat("description updated after save", defaultDeck.description, equalTo("foo")) + } + + @Test + fun `dialog dismissed if no changes`() = + runViewModelTest { + flowOfDismissDialog.test { + onBackRequested() + assertThat("dialog should be dismissed", expectMostRecentItem(), equalTo(true)) + } + } + + @Test + fun `dialog not immediately dismissed if going back with changes`() = + runViewModelTest { + flowOfDismissDialog.test { + description = "foo" + onBackRequested() + assertThat("dialog should not be dismissed", expectMostRecentItem(), equalTo(false)) + } + } + + @Test + fun `'discard changes' shown if going back with changes`() = + runViewModelTest { + flowOfShowDiscardChanges.test { + description = "foo" + onBackRequested() + assertThat("discard should be shown", expectMostRecentItem(), equalTo(Unit)) + } + } + + @Test + fun `'close without saving' closes even if changes are made`() = + runViewModelTest { + flowOfDismissDialog.test { + description = "foo" + closeWithoutSaving() + assertThat("dialog should be dismissed", expectMostRecentItem(), equalTo(true)) + } + } + + @Test + fun `test state restoration`() = + runViewModelTest(updatedDescription = "foo") { + assertThat("database is unchanged", defaultDeck.description, equalTo("")) + assertThat("dialog state is maintained", description, equalTo("foo")) + } + + val AnkiTest.defaultDeck + get() = col.decks.getLegacy(DEFAULT_DECK_ID)!! + + private fun runViewModelTest( + updatedDescription: String? = null, + testBody: suspend EditDeckDescriptionDialogViewModel.() -> Unit, + ) = runTest { + val viewModel = + EditDeckDescriptionDialogViewModel( + savedStateHandleOf( + ARG_DECK_ID to DEFAULT_DECK_ID, + STATE_DESCRIPTION to updatedDescription, + ), + ) + testBody(viewModel) + } +} + +fun savedStateHandleOf(vararg pairs: Pair): SavedStateHandle = SavedStateHandle(mapOf(*pairs)) From 4d47695f8d27d987f89fa2980d89082a61c16825 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Wed, 2 Jul 2025 16:11:50 +0100 Subject: [PATCH 08/14] feat(deck-description): allow setting 'Format as Markdown' Note that this option has unintended consequences. * `` tags, are now stripped, although they work with the option disabled * It does not work on old versions of Anki https://redirect.github.com/ankitects/anki/commit/ded626f0b9685355e9856c352daf21c09382999e Issue 18749 --- .../anki/dialogs/EditDeckDescriptionDialog.kt | 32 +++++++++ .../EditDeckDescriptionDialogViewModel.kt | 71 +++++++++++++++++-- .../res/layout/dialog_deck_description.xml | 31 +++++++- AnkiDroid/src/main/res/values/02-strings.xml | 2 + .../EditDeckDescriptionDialogViewModelTest.kt | 50 ++++++++++++- 5 files changed, 175 insertions(+), 11 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialog.kt index 14e9c6c02c57..331547b7fd12 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialog.kt @@ -20,17 +20,22 @@ import android.app.Dialog import android.os.Bundle import android.view.View import android.view.ViewGroup +import android.widget.CheckBox +import android.widget.ImageButton import androidx.core.os.bundleOf import androidx.core.widget.doOnTextChanged import androidx.fragment.app.DialogFragment import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope import com.google.android.material.appbar.MaterialToolbar +import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.textfield.TextInputEditText +import com.ichi2.anki.CollectionManager.TR import com.ichi2.anki.R import com.ichi2.anki.StudyOptionsFragment import com.ichi2.anki.libanki.DeckId import com.ichi2.utils.AndroidUiUtils.setFocusAndOpenKeyboard +import com.ichi2.utils.show import kotlinx.coroutines.launch /** @@ -43,6 +48,8 @@ class EditDeckDescriptionDialog : DialogFragment(R.layout.dialog_deck_descriptio private lateinit var deckDescriptionInput: TextInputEditText + private lateinit var formatAsMarkdownInput: CheckBox + private lateinit var toolbar: MaterialToolbar override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = @@ -62,6 +69,25 @@ class EditDeckDescriptionDialog : DialogFragment(R.layout.dialog_deck_descriptio } } + formatAsMarkdownInput = + view.findViewById(R.id.format_as_markdown).apply { + setOnCheckedChangeListener { _, value -> viewModel.formatAsMarkdown = value } + } + + // setup 'Format as Markdown' help + view.findViewById(R.id.markdown_formatting_help).apply { + contentDescription = + getString(R.string.help_button_content_description, getString(R.string.format_deck_description_as_markdown)) + setOnClickListener { + MaterialAlertDialogBuilder(requireContext()).show { + setTitle(formatAsMarkdownInput.text) + setIcon(R.drawable.ic_help_black_24dp) + // FIXME: the upstream string unexpectedly contains newlines + setMessage(TR.deckConfigDescriptionNewHandlingHint().replace("\n", " ").replace(" ", " ")) + } + } + } + // setup App Bar toolbar = view @@ -101,6 +127,12 @@ class EditDeckDescriptionDialog : DialogFragment(R.layout.dialog_deck_descriptio } } + lifecycleScope.launch { + viewModel.flowOfFormatAsMarkdown.collect { + formatAsMarkdownInput.isChecked = it + } + } + lifecycleScope.launch { viewModel.flowOfInitCompleted.collect { if (!it) return@collect diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialogViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialogViewModel.kt index d0f0210dee83..a6ccb423983a 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialogViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialogViewModel.kt @@ -16,6 +16,7 @@ package com.ichi2.anki.dialogs +import androidx.annotation.VisibleForTesting import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -33,17 +34,42 @@ class EditDeckDescriptionDialogViewModel( val deckId: DeckId get() = requireNotNull(stateHandle.get(ARG_DECK_ID)) + @VisibleForTesting + var userHasMadeChanges: Boolean + get() = stateHandle.get(STATE_USER_MADE_CHANGES) ?: false + set(value) { + stateHandle[STATE_USER_MADE_CHANGES] = value + } + lateinit var windowTitle: String val flowOfDescription = MutableStateFlow(stateHandle.get(STATE_DESCRIPTION) ?: "") + val flowOfFormatAsMarkdown = MutableStateFlow(stateHandle.get(STATE_FORMAT_AS_MARKDOWN) ?: false) + var description get() = flowOfDescription.value set(value) { + userHasMadeChanges = true stateHandle[STATE_DESCRIPTION] = value flowOfDescription.value = value } + var formatAsMarkdown: Boolean + get() = flowOfFormatAsMarkdown.value + set(value) { + userHasMadeChanges = true + stateHandle[STATE_FORMAT_AS_MARKDOWN] = value + flowOfFormatAsMarkdown.value = value + } + + private val dialogState: DeckDescriptionState + get() = + DeckDescriptionState( + description = this.description, + formatAsMarkdown = this.formatAsMarkdown, + ) + // TODO: make this a unit val flowOfDismissDialog = MutableStateFlow(false) @@ -54,8 +80,11 @@ class EditDeckDescriptionDialogViewModel( init { viewModelScope.launch { windowTitle = withCol { decks.getLegacy(deckId)!!.name } - if (description.isEmpty()) { - description = getDescription() + if (!userHasMadeChanges) { + val state = queryDescriptionState() + description = state.description + formatAsMarkdown = state.formatAsMarkdown + userHasMadeChanges = false } flowOfInitCompleted.emit(true) } @@ -63,7 +92,7 @@ class EditDeckDescriptionDialogViewModel( fun onBackRequested() = viewModelScope.launch { - if (getDescription() == description) { + if (queryDescriptionState() == dialogState) { closeWithoutSaving() return@launch } @@ -80,20 +109,48 @@ class EditDeckDescriptionDialogViewModel( fun saveAndExit() = viewModelScope.launch { - setDescription(description) + save() Timber.i("closing deck description dialog") flowOfDismissDialog.emit(true) } - private suspend fun getDescription() = withCol { decks.getLegacy(deckId)!!.description } + private suspend fun queryDescriptionState() = + withCol { + decks.getLegacy(deckId)!!.let { + DeckDescriptionState( + description = it.description, + formatAsMarkdown = it.descriptionAsMarkdown, + ) + } + } - private suspend fun setDescription(value: String) { + private suspend fun save() { Timber.i("updating deck description") - withCol { decks.update(deckId) { description = value } } + withCol { + decks.update(deckId) { + this.description = dialogState.description + this.descriptionAsMarkdown = dialogState.formatAsMarkdown + } + } } + /** + * State for [EditDeckDescriptionDialog]. + * + * Simplifies detecting user changes + * + * @param description see [com.ichi2.anki.libanki.Deck.description] + * @param formatAsMarkdown see [com.ichi2.anki.libanki.Deck.markdownDescription] + */ + private data class DeckDescriptionState( + val description: String, + val formatAsMarkdown: Boolean, + ) + companion object { const val ARG_DECK_ID = "deckId" const val STATE_DESCRIPTION = "description" + const val STATE_FORMAT_AS_MARKDOWN = "format_as_markdown" + const val STATE_USER_MADE_CHANGES = "user_made_changes" } } diff --git a/AnkiDroid/src/main/res/layout/dialog_deck_description.xml b/AnkiDroid/src/main/res/layout/dialog_deck_description.xml index d4f4277c5541..bdc869405605 100644 --- a/AnkiDroid/src/main/res/layout/dialog_deck_description.xml +++ b/AnkiDroid/src/main/res/layout/dialog_deck_description.xml @@ -28,9 +28,9 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="?attr/actionBarSize" + android:layout_marginEnd="2dp" app:layout_scrollFlags="enterAlways" app:menu="@menu/menu_deck_description" - android:layout_marginEnd="2dp" app:navigationIcon="@drawable/close_icon" app:navigationContentDescription="@string/close" tools:title="Deck name" /> @@ -39,9 +39,10 @@ + + + + + + + diff --git a/AnkiDroid/src/main/res/values/02-strings.xml b/AnkiDroid/src/main/res/values/02-strings.xml index 0a868f715218..4d91d055bf30 100644 --- a/AnkiDroid/src/main/res/values/02-strings.xml +++ b/AnkiDroid/src/main/res/values/02-strings.xml @@ -377,6 +377,8 @@ opening the system text to speech settings fails">Failed to open text to speech Please log in to download more decks Description + Format as Markdown + Open help for ā€˜%s’ Failed to copy diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialogViewModelTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialogViewModelTest.kt index 949a000dc209..7b0291d1c223 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialogViewModelTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialogViewModelTest.kt @@ -21,6 +21,8 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import app.cash.turbine.test import com.ichi2.anki.dialogs.EditDeckDescriptionDialogViewModel.Companion.ARG_DECK_ID import com.ichi2.anki.dialogs.EditDeckDescriptionDialogViewModel.Companion.STATE_DESCRIPTION +import com.ichi2.anki.dialogs.EditDeckDescriptionDialogViewModel.Companion.STATE_FORMAT_AS_MARKDOWN +import com.ichi2.anki.dialogs.EditDeckDescriptionDialogViewModel.Companion.STATE_USER_MADE_CHANGES import com.ichi2.anki.libanki.Consts.DEFAULT_DECK_ID import com.ichi2.anki.libanki.testutils.AnkiTest import com.ichi2.testutils.JvmTest @@ -57,6 +59,17 @@ class EditDeckDescriptionDialogViewModelTest : JvmTest() { assertThat("description updated after save", defaultDeck.description, equalTo("foo")) } + @Test + fun `format as markdown is updated in database`() = + runViewModelTest { + formatAsMarkdown = true + assertThat("format as markdown not updated before save", defaultDeck.descriptionAsMarkdown, equalTo(false)) + + saveAndExit() + + assertThat("format as markdown updated after save", defaultDeck.descriptionAsMarkdown, equalTo(true)) + } + @Test fun `dialog dismissed if no changes`() = runViewModelTest { @@ -96,11 +109,43 @@ class EditDeckDescriptionDialogViewModelTest : JvmTest() { } } + @Test + fun `'format as markdown' also triggers changes`() = + runViewModelTest { + flowOfShowDiscardChanges.test { + formatAsMarkdown = true + onBackRequested() + + expectMostRecentItem() + } + } + @Test fun `test state restoration`() = - runViewModelTest(updatedDescription = "foo") { + runViewModelTest(updatedDescription = "foo", updatedFormatAsMarkdown = true) { assertThat("database is unchanged", defaultDeck.description, equalTo("")) assertThat("dialog state is maintained", description, equalTo("foo")) + assertThat("dialog state is maintained", formatAsMarkdown, equalTo(true)) + } + + @Test + fun `no changes initially`() = + runViewModelTest { + assertThat(userHasMadeChanges, equalTo(false)) + } + + @Test + fun `description is a change`() = + runViewModelTest { + description = "foo" + assertThat(userHasMadeChanges, equalTo(true)) + } + + @Test + fun `format as markdown is a change`() = + runViewModelTest { + formatAsMarkdown = false + assertThat(userHasMadeChanges, equalTo(true)) } val AnkiTest.defaultDeck @@ -108,6 +153,7 @@ class EditDeckDescriptionDialogViewModelTest : JvmTest() { private fun runViewModelTest( updatedDescription: String? = null, + updatedFormatAsMarkdown: Boolean? = null, testBody: suspend EditDeckDescriptionDialogViewModel.() -> Unit, ) = runTest { val viewModel = @@ -115,6 +161,8 @@ class EditDeckDescriptionDialogViewModelTest : JvmTest() { savedStateHandleOf( ARG_DECK_ID to DEFAULT_DECK_ID, STATE_DESCRIPTION to updatedDescription, + STATE_FORMAT_AS_MARKDOWN to updatedFormatAsMarkdown, + STATE_USER_MADE_CHANGES to (updatedDescription != null || updatedFormatAsMarkdown != null), ), ) testBody(viewModel) From 60d5d3de56eb278cd33ff3333c8c337977721f40 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Wed, 2 Jul 2025 17:41:34 +0100 Subject: [PATCH 09/14] improvement(deck-description): disable 'save' if no changes --- .../anki/dialogs/EditDeckDescriptionDialog.kt | 10 ++++++++ .../EditDeckDescriptionDialogViewModel.kt | 23 +++++++++++++++---- .../EditDeckDescriptionDialogViewModelTest.kt | 12 ++++++++++ 3 files changed, 41 insertions(+), 4 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialog.kt index 331547b7fd12..273ee62f61cc 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialog.kt @@ -18,6 +18,7 @@ package com.ichi2.anki.dialogs import android.app.Dialog import android.os.Bundle +import android.view.MenuItem import android.view.View import android.view.ViewGroup import android.widget.CheckBox @@ -52,6 +53,9 @@ class EditDeckDescriptionDialog : DialogFragment(R.layout.dialog_deck_descriptio private lateinit var toolbar: MaterialToolbar + private val saveMenuItem: MenuItem + get() = toolbar.menu.findItem(R.id.action_save) + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = super.onCreateDialog(savedInstanceState).also { it.setCanceledOnTouchOutside(false) @@ -148,6 +152,12 @@ class EditDeckDescriptionDialog : DialogFragment(R.layout.dialog_deck_descriptio } } } + + lifecycleScope.launch { + viewModel.flowOfHasChanges.collect { + saveMenuItem.isEnabled = it + } + } } override fun onStart() { diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialogViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialogViewModel.kt index a6ccb423983a..77fef4807ca6 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialogViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialogViewModel.kt @@ -23,8 +23,10 @@ import androidx.lifecycle.viewModelScope import com.ichi2.anki.CollectionManager.withCol import com.ichi2.anki.libanki.DeckId import com.ichi2.anki.utils.ext.update +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combineTransform import kotlinx.coroutines.launch import timber.log.Timber @@ -41,6 +43,8 @@ class EditDeckDescriptionDialogViewModel( stateHandle[STATE_USER_MADE_CHANGES] = value } + private lateinit var initialDialogState: DeckDescriptionState + lateinit var windowTitle: String val flowOfDescription = MutableStateFlow(stateHandle.get(STATE_DESCRIPTION) ?: "") @@ -77,13 +81,18 @@ class EditDeckDescriptionDialogViewModel( val flowOfInitCompleted = MutableStateFlow(false) + val flowOfHasChanges: Flow = + combineTransform(flowOfDescription, flowOfFormatAsMarkdown, flowOfInitCompleted) { + emit(hasChanges()) + } + init { viewModelScope.launch { windowTitle = withCol { decks.getLegacy(deckId)!!.name } + initialDialogState = queryDescriptionState() if (!userHasMadeChanges) { - val state = queryDescriptionState() - description = state.description - formatAsMarkdown = state.formatAsMarkdown + description = initialDialogState.description + formatAsMarkdown = initialDialogState.formatAsMarkdown userHasMadeChanges = false } flowOfInitCompleted.emit(true) @@ -92,7 +101,7 @@ class EditDeckDescriptionDialogViewModel( fun onBackRequested() = viewModelScope.launch { - if (queryDescriptionState() == dialogState) { + if (!hasChanges()) { closeWithoutSaving() return@launch } @@ -134,6 +143,12 @@ class EditDeckDescriptionDialogViewModel( } } + private fun hasChanges(): Boolean { + // this can be triggered via the back dispatcher + if (!::initialDialogState.isInitialized) return false + return initialDialogState != dialogState + } + /** * State for [EditDeckDescriptionDialog]. * diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialogViewModelTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialogViewModelTest.kt index 7b0291d1c223..0a5e26cd43b0 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialogViewModelTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialogViewModelTest.kt @@ -148,6 +148,18 @@ class EditDeckDescriptionDialogViewModelTest : JvmTest() { assertThat(userHasMadeChanges, equalTo(true)) } + @Test + fun `saved changes update`() = + runViewModelTest { + flowOfHasChanges.test { + assertThat("initial state", expectMostRecentItem(), equalTo(false)) + description = "foo" + assertThat("has changes after change", expectMostRecentItem(), equalTo(true)) + description = "" + assertThat("no changes if all reverted", expectMostRecentItem(), equalTo(false)) + } + } + val AnkiTest.defaultDeck get() = col.decks.getLegacy(DEFAULT_DECK_ID)!! From 4a366974e47c13e64db0fbe20ff7611f60f53934 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Wed, 2 Jul 2025 17:32:58 +0100 Subject: [PATCH 10/14] improvement(deck-description): 'discard changes?' on back press --- .../anki/dialogs/EditDeckDescriptionDialog.kt | 31 +++++++++++++++++-- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialog.kt index 273ee62f61cc..62cded7a2e1b 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialog.kt @@ -23,6 +23,7 @@ import android.view.View import android.view.ViewGroup import android.widget.CheckBox import android.widget.ImageButton +import androidx.activity.OnBackPressedCallback import androidx.core.os.bundleOf import androidx.core.widget.doOnTextChanged import androidx.fragment.app.DialogFragment @@ -32,12 +33,14 @@ import com.google.android.material.appbar.MaterialToolbar import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.textfield.TextInputEditText import com.ichi2.anki.CollectionManager.TR +import com.ichi2.anki.CrashReportService import com.ichi2.anki.R import com.ichi2.anki.StudyOptionsFragment import com.ichi2.anki.libanki.DeckId import com.ichi2.utils.AndroidUiUtils.setFocusAndOpenKeyboard import com.ichi2.utils.show import kotlinx.coroutines.launch +import timber.log.Timber /** * Allows a user to edit the [deck description][com.ichi2.anki.libanki.Deck.description] @@ -56,9 +59,25 @@ class EditDeckDescriptionDialog : DialogFragment(R.layout.dialog_deck_descriptio private val saveMenuItem: MenuItem get() = toolbar.menu.findItem(R.id.action_save) + private val onUnsavedChangesBackCallback = + object : OnBackPressedCallback(enabled = false) { + override fun handleOnBackPressed() { + showDiscardChangesDialog() + } + } + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = super.onCreateDialog(savedInstanceState).also { it.setCanceledOnTouchOutside(false) + it.setCancelable(false) + try { + (it as androidx.activity.ComponentDialog) + .onBackPressedDispatcher + .addCallback(this, onUnsavedChangesBackCallback) + } catch (e: Exception) { + Timber.w(e, "EditDeckDescription::backPressed") + CrashReportService.sendExceptionReport(e, "EditDeckDescription", "backPressed", onlyIfSilent = true) + } } override fun onViewCreated( @@ -147,19 +166,25 @@ class EditDeckDescriptionDialog : DialogFragment(R.layout.dialog_deck_descriptio lifecycleScope.launch { viewModel.flowOfShowDiscardChanges.collect { - DiscardChangesDialog.showDialog(requireContext()) { - viewModel.closeWithoutSaving() - } + showDiscardChangesDialog() } } lifecycleScope.launch { viewModel.flowOfHasChanges.collect { saveMenuItem.isEnabled = it + onUnsavedChangesBackCallback.isEnabled = it } } } + fun showDiscardChangesDialog() { + Timber.i("asking if user should discard changes") + DiscardChangesDialog.showDialog(requireContext()) { + viewModel.closeWithoutSaving() + } + } + override fun onStart() { super.onStart() From cf2a9763820db49b73cfaeb93fd8f392aca3a8b7 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Wed, 2 Jul 2025 17:58:48 +0100 Subject: [PATCH 11/14] improvement(deck-description): show snackbar on save --- .../anki/dialogs/EditDeckDescriptionDialog.kt | 17 +++++++++++++---- .../EditDeckDescriptionDialogViewModel.kt | 14 ++++++++++---- AnkiDroid/src/main/res/values/02-strings.xml | 1 + .../EditDeckDescriptionDialogViewModelTest.kt | 18 +++++++++++++++--- 4 files changed, 39 insertions(+), 11 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialog.kt index 62cded7a2e1b..69d72965f1bd 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialog.kt @@ -36,9 +36,12 @@ import com.ichi2.anki.CollectionManager.TR import com.ichi2.anki.CrashReportService import com.ichi2.anki.R import com.ichi2.anki.StudyOptionsFragment +import com.ichi2.anki.dialogs.EditDeckDescriptionDialogViewModel.DismissType import com.ichi2.anki.libanki.DeckId +import com.ichi2.anki.snackbar.showSnackbar import com.ichi2.utils.AndroidUiUtils.setFocusAndOpenKeyboard import com.ichi2.utils.show +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.launch import timber.log.Timber @@ -136,11 +139,17 @@ class EditDeckDescriptionDialog : DialogFragment(R.layout.dialog_deck_descriptio private fun setupFlows() { lifecycleScope.launch { - viewModel.flowOfDismissDialog.collect { dismiss -> - if (dismiss) { - dismiss() + viewModel.flowOfDismissDialog + .filterNotNull() + .collect { dismissType -> + when (dismissType) { + DismissType.ClosedWithoutSaving -> dismiss() + DismissType.Saved -> { + dismiss() + showSnackbar(R.string.deck_description_saved) + } + } } - } } lifecycleScope.launch { diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialogViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialogViewModel.kt index 77fef4807ca6..438bfaed7b2b 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialogViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialogViewModel.kt @@ -74,8 +74,7 @@ class EditDeckDescriptionDialogViewModel( formatAsMarkdown = this.formatAsMarkdown, ) - // TODO: make this a unit - val flowOfDismissDialog = MutableStateFlow(false) + val flowOfDismissDialog = MutableStateFlow(null) val flowOfShowDiscardChanges = MutableSharedFlow() @@ -113,14 +112,14 @@ class EditDeckDescriptionDialogViewModel( fun closeWithoutSaving() = viewModelScope.launch { Timber.i("Closing dialog without saving") - flowOfDismissDialog.emit(true) + flowOfDismissDialog.emit(DismissType.ClosedWithoutSaving) } fun saveAndExit() = viewModelScope.launch { save() Timber.i("closing deck description dialog") - flowOfDismissDialog.emit(true) + flowOfDismissDialog.emit(DismissType.Saved) } private suspend fun queryDescriptionState() = @@ -162,6 +161,13 @@ class EditDeckDescriptionDialogViewModel( val formatAsMarkdown: Boolean, ) + /** How the Dialog was dismissed */ + sealed class DismissType { + data object Saved : DismissType() + + data object ClosedWithoutSaving : DismissType() + } + companion object { const val ARG_DECK_ID = "deckId" const val STATE_DESCRIPTION = "description" diff --git a/AnkiDroid/src/main/res/values/02-strings.xml b/AnkiDroid/src/main/res/values/02-strings.xml index 4d91d055bf30..97b76953ea5c 100644 --- a/AnkiDroid/src/main/res/values/02-strings.xml +++ b/AnkiDroid/src/main/res/values/02-strings.xml @@ -377,6 +377,7 @@ opening the system text to speech settings fails">Failed to open text to speech Please log in to download more decks Description + Description updated Format as Markdown Open help for ā€˜%s’ diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialogViewModelTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialogViewModelTest.kt index 0a5e26cd43b0..479e57d66f4c 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialogViewModelTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialogViewModelTest.kt @@ -23,6 +23,8 @@ import com.ichi2.anki.dialogs.EditDeckDescriptionDialogViewModel.Companion.ARG_D import com.ichi2.anki.dialogs.EditDeckDescriptionDialogViewModel.Companion.STATE_DESCRIPTION import com.ichi2.anki.dialogs.EditDeckDescriptionDialogViewModel.Companion.STATE_FORMAT_AS_MARKDOWN import com.ichi2.anki.dialogs.EditDeckDescriptionDialogViewModel.Companion.STATE_USER_MADE_CHANGES +import com.ichi2.anki.dialogs.EditDeckDescriptionDialogViewModel.DismissType.ClosedWithoutSaving +import com.ichi2.anki.dialogs.EditDeckDescriptionDialogViewModel.DismissType.Saved import com.ichi2.anki.libanki.Consts.DEFAULT_DECK_ID import com.ichi2.anki.libanki.testutils.AnkiTest import com.ichi2.testutils.JvmTest @@ -75,7 +77,7 @@ class EditDeckDescriptionDialogViewModelTest : JvmTest() { runViewModelTest { flowOfDismissDialog.test { onBackRequested() - assertThat("dialog should be dismissed", expectMostRecentItem(), equalTo(true)) + assertThat("dialog should be dismissed", expectMostRecentItem(), equalTo(ClosedWithoutSaving)) } } @@ -85,7 +87,7 @@ class EditDeckDescriptionDialogViewModelTest : JvmTest() { flowOfDismissDialog.test { description = "foo" onBackRequested() - assertThat("dialog should not be dismissed", expectMostRecentItem(), equalTo(false)) + assertThat("dialog should not be dismissed", expectMostRecentItem(), equalTo(null)) } } @@ -105,7 +107,17 @@ class EditDeckDescriptionDialogViewModelTest : JvmTest() { flowOfDismissDialog.test { description = "foo" closeWithoutSaving() - assertThat("dialog should be dismissed", expectMostRecentItem(), equalTo(true)) + assertThat("dialog should be dismissed", expectMostRecentItem(), equalTo(ClosedWithoutSaving)) + } + } + + @Test + fun `dialog is dismissed as saved`() = + runViewModelTest { + flowOfDismissDialog.test { + description = "foo" + saveAndExit() + assertThat("dialog should be dismissed", expectMostRecentItem(), equalTo(Saved)) } } From 435845a2ac722fbe86c50b961dfbd1313db33831 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Thu, 3 Jul 2025 21:29:51 +0100 Subject: [PATCH 12/14] improvement(deck-description): use material theme Adds rounded corners Issue 18528 --- .../anki/dialogs/EditDeckDescriptionDialog.kt | 86 +++++++++---------- 1 file changed, 40 insertions(+), 46 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialog.kt index 69d72965f1bd..46687ca5b1e1 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialog.kt @@ -33,7 +33,6 @@ import com.google.android.material.appbar.MaterialToolbar import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.textfield.TextInputEditText import com.ichi2.anki.CollectionManager.TR -import com.ichi2.anki.CrashReportService import com.ichi2.anki.R import com.ichi2.anki.StudyOptionsFragment import com.ichi2.anki.dialogs.EditDeckDescriptionDialogViewModel.DismissType @@ -50,14 +49,19 @@ import timber.log.Timber * * This is visible on [StudyOptionsFragment] */ -class EditDeckDescriptionDialog : DialogFragment(R.layout.dialog_deck_description) { +class EditDeckDescriptionDialog : DialogFragment() { private val viewModel: EditDeckDescriptionDialogViewModel by viewModels() - private lateinit var deckDescriptionInput: TextInputEditText + private lateinit var dialogView: View - private lateinit var formatAsMarkdownInput: CheckBox + private val deckDescriptionInput: TextInputEditText + get() = dialogView.findViewById(R.id.deck_description_input) - private lateinit var toolbar: MaterialToolbar + private val formatAsMarkdownInput: CheckBox + get() = dialogView.findViewById(R.id.format_as_markdown) + + private val toolbar: MaterialToolbar + get() = dialogView.findViewById(R.id.topAppBar) private val saveMenuItem: MenuItem get() = toolbar.menu.findItem(R.id.action_save) @@ -69,36 +73,29 @@ class EditDeckDescriptionDialog : DialogFragment(R.layout.dialog_deck_descriptio } } - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = - super.onCreateDialog(savedInstanceState).also { - it.setCanceledOnTouchOutside(false) - it.setCancelable(false) - try { - (it as androidx.activity.ComponentDialog) - .onBackPressedDispatcher - .addCallback(this, onUnsavedChangesBackCallback) - } catch (e: Exception) { - Timber.w(e, "EditDeckDescription::backPressed") - CrashReportService.sendExceptionReport(e, "EditDeckDescription", "backPressed", onlyIfSilent = true) + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + this.dialogView = layoutInflater.inflate(R.layout.dialog_deck_description, null) + return MaterialAlertDialogBuilder(requireContext()) + .show { + setView(dialogView) + }.apply { + setupDialogView(dialogView) + setCanceledOnTouchOutside(false) + setCancelable(false) + onBackPressedDispatcher.addCallback(this, onUnsavedChangesBackCallback) } - } + } - override fun onViewCreated( - view: View, - savedInstanceState: Bundle?, - ) { - super.onViewCreated(view, savedInstanceState) - deckDescriptionInput = - view.findViewById(R.id.deck_description_input).apply { - doOnTextChanged { text, _, _, _ -> - viewModel.description = text?.toString() ?: "" - } + private fun setupDialogView(view: View) { + deckDescriptionInput.apply { + doOnTextChanged { text, _, _, _ -> + viewModel.description = text?.toString() ?: "" } + } - formatAsMarkdownInput = - view.findViewById(R.id.format_as_markdown).apply { - setOnCheckedChangeListener { _, value -> viewModel.formatAsMarkdown = value } - } + formatAsMarkdownInput.apply { + setOnCheckedChangeListener { _, value -> viewModel.formatAsMarkdown = value } + } // setup 'Format as Markdown' help view.findViewById(R.id.markdown_formatting_help).apply { @@ -115,24 +112,21 @@ class EditDeckDescriptionDialog : DialogFragment(R.layout.dialog_deck_descriptio } // setup App Bar - toolbar = - view - .findViewById(R.id.topAppBar) - .apply { - setNavigationOnClickListener { - viewModel.onBackRequested() - } + toolbar.apply { + setNavigationOnClickListener { + viewModel.onBackRequested() + } - setOnMenuItemClickListener { menuItem -> - when (menuItem.itemId) { - R.id.action_save -> { - viewModel.saveAndExit() - true - } - else -> false - } + setOnMenuItemClickListener { menuItem -> + when (menuItem.itemId) { + R.id.action_save -> { + viewModel.saveAndExit() + true } + else -> false } + } + } setupFlows() } From cfd2a4ee522db13379648ae6a8b6abbf0684db59 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sun, 13 Jul 2025 22:42:23 +0100 Subject: [PATCH 13/14] improvement(deck-description): use 'Save/Close' buttons Requested in review, instead of icons Issue 18749 --- .../anki/dialogs/EditDeckDescriptionDialog.kt | 40 ++++++++----------- .../res/layout/dialog_deck_description.xml | 11 +++-- .../main/res/menu/menu_deck_description.xml | 27 ------------- 3 files changed, 21 insertions(+), 57 deletions(-) delete mode 100644 AnkiDroid/src/main/res/menu/menu_deck_description.xml diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialog.kt index 46687ca5b1e1..01d7c9d5130f 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialog.kt @@ -18,12 +18,12 @@ package com.ichi2.anki.dialogs import android.app.Dialog import android.os.Bundle -import android.view.MenuItem import android.view.View import android.view.ViewGroup import android.widget.CheckBox import android.widget.ImageButton import androidx.activity.OnBackPressedCallback +import androidx.appcompat.app.AlertDialog import androidx.core.os.bundleOf import androidx.core.widget.doOnTextChanged import androidx.fragment.app.DialogFragment @@ -39,6 +39,9 @@ import com.ichi2.anki.dialogs.EditDeckDescriptionDialogViewModel.DismissType import com.ichi2.anki.libanki.DeckId import com.ichi2.anki.snackbar.showSnackbar import com.ichi2.utils.AndroidUiUtils.setFocusAndOpenKeyboard +import com.ichi2.utils.create +import com.ichi2.utils.negativeButton +import com.ichi2.utils.positiveButton import com.ichi2.utils.show import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.launch @@ -52,6 +55,7 @@ import timber.log.Timber class EditDeckDescriptionDialog : DialogFragment() { private val viewModel: EditDeckDescriptionDialogViewModel by viewModels() + private lateinit var alertDialog: AlertDialog private lateinit var dialogView: View private val deckDescriptionInput: TextInputEditText @@ -63,9 +67,6 @@ class EditDeckDescriptionDialog : DialogFragment() { private val toolbar: MaterialToolbar get() = dialogView.findViewById(R.id.topAppBar) - private val saveMenuItem: MenuItem - get() = toolbar.menu.findItem(R.id.action_save) - private val onUnsavedChangesBackCallback = object : OnBackPressedCallback(enabled = false) { override fun handleOnBackPressed() { @@ -76,13 +77,21 @@ class EditDeckDescriptionDialog : DialogFragment() { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { this.dialogView = layoutInflater.inflate(R.layout.dialog_deck_description, null) return MaterialAlertDialogBuilder(requireContext()) - .show { + .create { setView(dialogView) + positiveButton(R.string.save) + negativeButton(R.string.close) }.apply { - setupDialogView(dialogView) + alertDialog = this + setOnShowListener { + positiveButton.setOnClickListener { viewModel.saveAndExit() } + negativeButton.setOnClickListener { viewModel.onBackRequested() } + } setCanceledOnTouchOutside(false) setCancelable(false) onBackPressedDispatcher.addCallback(this, onUnsavedChangesBackCallback) + show() + setupDialogView(dialogView) } } @@ -111,23 +120,6 @@ class EditDeckDescriptionDialog : DialogFragment() { } } - // setup App Bar - toolbar.apply { - setNavigationOnClickListener { - viewModel.onBackRequested() - } - - setOnMenuItemClickListener { menuItem -> - when (menuItem.itemId) { - R.id.action_save -> { - viewModel.saveAndExit() - true - } - else -> false - } - } - } - setupFlows() } @@ -175,7 +167,7 @@ class EditDeckDescriptionDialog : DialogFragment() { lifecycleScope.launch { viewModel.flowOfHasChanges.collect { - saveMenuItem.isEnabled = it + alertDialog.positiveButton.isEnabled = it onUnsavedChangesBackCallback.isEnabled = it } } diff --git a/AnkiDroid/src/main/res/layout/dialog_deck_description.xml b/AnkiDroid/src/main/res/layout/dialog_deck_description.xml index bdc869405605..099de0c8c531 100644 --- a/AnkiDroid/src/main/res/layout/dialog_deck_description.xml +++ b/AnkiDroid/src/main/res/layout/dialog_deck_description.xml @@ -19,7 +19,7 @@ xmlns:tools="http://schemas.android.com/tools" android:orientation="vertical" android:layout_width="match_parent" - android:layout_height="match_parent" + android:layout_height="wrap_content" android:layout_marginHorizontal="8dp" > @@ -29,9 +29,8 @@ android:layout_height="wrap_content" android:minHeight="?attr/actionBarSize" android:layout_marginEnd="2dp" + android:layout_marginStart="8dp" app:layout_scrollFlags="enterAlways" - app:menu="@menu/menu_deck_description" - app:navigationIcon="@drawable/close_icon" app:navigationContentDescription="@string/close" tools:title="Deck name" /> @@ -41,7 +40,8 @@ android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" - android:layout_marginHorizontal="16dp" + android:layout_marginStart="16dp" + android:layout_marginEnd="22dp" android:layout_marginBottom="8dp" app:layout_behavior="@string/appbar_scrolling_view_behavior"> @@ -65,8 +65,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginStart="16dp" - android:layout_marginEnd="2dp" - android:layout_marginBottom="24dp"> + android:layout_marginEnd="10dp"> - - - - - - \ No newline at end of file From e312461008f3839bd552ad39ab221470c507286a Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sun, 13 Jul 2025 23:10:08 +0100 Subject: [PATCH 14/14] design(deck-description): remove full width Didn't look good on tablets --- .../ichi2/anki/dialogs/EditDeckDescriptionDialog.kt | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialog.kt index 01d7c9d5130f..dbc8c911e1cc 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialog.kt @@ -19,7 +19,6 @@ package com.ichi2.anki.dialogs import android.app.Dialog import android.os.Bundle import android.view.View -import android.view.ViewGroup import android.widget.CheckBox import android.widget.ImageButton import androidx.activity.OnBackPressedCallback @@ -180,15 +179,6 @@ class EditDeckDescriptionDialog : DialogFragment() { } } - override fun onStart() { - super.onStart() - - dialog!!.window!!.setLayout( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ) - } - companion object { fun newInstance(deckId: DeckId): EditDeckDescriptionDialog = EditDeckDescriptionDialog().apply {