Skip to content

Commit 98108de

Browse files
committed
feat: select an arbitrary deck when sharing an image to Image Occlusion
1 parent 04ec728 commit 98108de

File tree

4 files changed

+190
-55
lines changed

4 files changed

+190
-55
lines changed

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2383,6 +2383,8 @@ class NoteEditorFragment :
23832383
note: Note?,
23842384
changeType: FieldChangeType,
23852385
) {
2386+
requireView().findViewById<TextView>(R.id.CardEditorDeckText).isVisible = !currentNotetypeIsImageOcclusion()
2387+
requireView().findViewById<View>(R.id.note_deck_spinner).isVisible = !currentNotetypeIsImageOcclusion()
23862388
editorNote =
23872389
if (note == null || addNote) {
23882390
getColUnsafe.run {

AnkiDroid/src/main/java/com/ichi2/anki/pages/ImageOcclusion.kt

Lines changed: 86 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -21,21 +21,36 @@ import android.net.Uri
2121
import android.os.Bundle
2222
import android.view.View
2323
import android.webkit.WebView
24+
import android.widget.Spinner
2425
import androidx.activity.addCallback
2526
import androidx.core.os.bundleOf
27+
import androidx.fragment.app.viewModels
2628
import androidx.lifecycle.lifecycleScope
2729
import com.google.android.material.appbar.MaterialToolbar
2830
import com.ichi2.anki.CollectionManager.withCol
31+
import com.ichi2.anki.DeckSpinnerSelection
2932
import com.ichi2.anki.R
3033
import com.ichi2.anki.SingleFragmentActivity
3134
import com.ichi2.anki.common.annotations.NeedsTest
35+
import com.ichi2.anki.dialogs.DeckSelectionDialog
3236
import com.ichi2.anki.dialogs.DiscardChangesDialog
37+
import com.ichi2.anki.launchCatchingTask
3338
import com.ichi2.anki.libanki.DeckId
39+
import com.ichi2.anki.libanki.DeckNameId
40+
import com.ichi2.anki.model.SelectableDeck
41+
import com.ichi2.anki.pages.viewmodel.ImageOcclusionArgs
42+
import com.ichi2.anki.pages.viewmodel.ImageOcclusionViewModel
43+
import com.ichi2.anki.requireAnkiActivity
3444
import kotlinx.coroutines.launch
35-
import org.json.JSONObject
3645
import timber.log.Timber
3746

38-
class ImageOcclusion : PageFragment(R.layout.image_occlusion) {
47+
class ImageOcclusion :
48+
PageFragment(R.layout.image_occlusion),
49+
DeckSelectionDialog.DeckSelectionListener {
50+
private val viewModel: ImageOcclusionViewModel by viewModels()
51+
private lateinit var deckSpinnerSelection: DeckSpinnerSelection
52+
private lateinit var spinner: Spinner
53+
3954
override fun onViewCreated(
4055
view: View,
4156
savedInstanceState: Bundle?,
@@ -49,33 +64,34 @@ class ImageOcclusion : PageFragment(R.layout.image_occlusion) {
4964
}
5065
}
5166

67+
spinner = view.findViewById(R.id.deck_selector)
68+
deckSpinnerSelection =
69+
DeckSpinnerSelection(
70+
requireAnkiActivity(),
71+
spinner,
72+
showAllDecks = false,
73+
alwaysShowDefault = false,
74+
showFilteredDecks = false,
75+
)
76+
77+
requireAnkiActivity().launchCatchingTask {
78+
deckSpinnerSelection.initializeStatsBarDeckSpinner()
79+
val selectedDeck = withCol { decks.getLegacy(decks.selected()) }
80+
if (selectedDeck == null) return@launchCatchingTask
81+
select(selectedDeck.id)
82+
}
83+
5284
@NeedsTest("#17393 verify that the added image occlusion cards are put in the correct deck")
5385
view.findViewById<MaterialToolbar>(R.id.toolbar).setOnMenuItemClickListener {
54-
val editorWorkingDeckId = requireArguments().getLong(ARG_KEY_EDITOR_DECK_ID)
5586
if (it.itemId == R.id.action_save) {
5687
Timber.i("save item selected")
57-
// TODO desktop code doesn't allow a deck change from the reviewer, if we would do
58-
// the same then NoteEditor could simply set the deck as selected and this hack
59-
// could be removed
60-
// because NoteEditor doesn't update the selected deck in Collection.decks when
61-
// there's a deck change and keeps its own deckId reference, we need to use that
62-
// deck id reference as the target deck in this fragment(backend code simply uses
63-
// the current selected deck it sees as the target deck for adding)
64-
lifecycleScope.launch {
65-
val previousDeckId =
66-
withCol {
67-
val current = backend.getCurrentDeck().id
68-
backend.setCurrentDeck(editorWorkingDeckId)
69-
current
70-
}
71-
webView.evaluateJavascript("anki.imageOcclusion.save()") {
72-
// reset to the previous deck that the backend "saw" as selected, this
73-
// avoids other screens unexpectedly having their working decks modified(
74-
// most important being the Reviewer where the user would find itself
75-
// studying another deck after editing a note with changing the deck)
76-
lifecycleScope.launch {
77-
withCol { backend.setCurrentDeck(previousDeckId) }
78-
}
88+
webView.evaluateJavascript("anki.imageOcclusion.save()") {
89+
// reset to the previous deck that the backend "saw" as selected, this
90+
// avoids other screens unexpectedly having their working decks modified(
91+
// most important being the Reviewer where the user would find itself
92+
// studying another deck after editing a note with changing the deck)
93+
viewLifecycleOwner.lifecycleScope.launch {
94+
viewModel.onSaveOperationCompleted()
7995
}
8096
}
8197
}
@@ -90,31 +106,45 @@ class ImageOcclusion : PageFragment(R.layout.image_occlusion) {
90106
url: String?,
91107
) {
92108
super.onPageFinished(view, url)
93-
94-
val kind = requireArguments().getString(ARG_KEY_KIND)
95-
val noteOrNotetypeId = requireArguments().getLong(ARG_KEY_ID)
96-
val imagePath = requireArguments().getString(ARG_KEY_PATH)
97-
98-
val options = JSONObject()
99-
options.put("kind", kind)
100-
if (kind == "add") {
101-
options.put("imagePath", imagePath)
102-
options.put("notetypeId", noteOrNotetypeId)
103-
} else {
104-
options.put("noteId", noteOrNotetypeId)
109+
viewModel.webViewOptions.let { options ->
110+
view?.evaluateJavascript("globalThis.anki.imageOcclusion.mode = $options") {
111+
super.onPageFinished(view, url)
112+
}
105113
}
114+
}
115+
}
106116

107-
view?.evaluateJavascript("globalThis.anki.imageOcclusion.mode = $options") {
108-
super.onPageFinished(view, url)
109-
}
117+
override fun onDeckSelected(deck: SelectableDeck?) {
118+
if (deck == null) return
119+
require(deck is SelectableDeck.Deck)
120+
121+
val deckDidChange = viewModel.handleDeckSelection(deck.deckId)
122+
if (deckDidChange) {
123+
viewLifecycleOwner.lifecycleScope.launch {
124+
select(deck.deckId)
125+
deckSpinnerSelection.selectDeckById(viewModel.selectedDeckId, true)
110126
}
111127
}
128+
}
129+
130+
private val decksAdapterSequence
131+
get() =
132+
sequence {
133+
for (i in 0 until spinner.adapter.count) {
134+
yield(spinner.adapter.getItem(i) as DeckNameId)
135+
}
136+
}
137+
138+
/**
139+
* Given the [deckId] look in the decks adapter for its position and select it if found.
140+
*/
141+
private fun select(deckId: DeckId) {
142+
val itemToSelect = decksAdapterSequence.withIndex().firstOrNull { it.value.id == deckId } ?: return
143+
spinner.setSelection(itemToSelect.index)
144+
}
112145

113146
companion object {
114-
private const val ARG_KEY_KIND = "kind"
115-
private const val ARG_KEY_ID = "id"
116-
private const val ARG_KEY_PATH = "imagePath"
117-
private const val ARG_KEY_EDITOR_DECK_ID = "arg_key_editor_deck_id"
147+
const val IO_ARGS_KEY = "IMAGE_OCCLUSION_ARGS"
118148

119149
/**
120150
* @param editorWorkingDeckId the current deck id that [com.ichi2.anki.NoteEditorFragment] is using
@@ -126,20 +156,22 @@ class ImageOcclusion : PageFragment(R.layout.image_occlusion) {
126156
imagePath: String?,
127157
editorWorkingDeckId: DeckId,
128158
): Intent {
129-
val suffix =
130-
if (kind == "edit") {
131-
noteOrNotetypeId
132-
} else {
133-
Uri.encode(imagePath)
134-
}
159+
val suffix = if (kind == "edit") noteOrNotetypeId else Uri.encode(imagePath)
160+
161+
val args =
162+
ImageOcclusionArgs(
163+
kind = kind,
164+
id = noteOrNotetypeId,
165+
imagePath = imagePath,
166+
editorDeckId = editorWorkingDeckId,
167+
)
168+
135169
val arguments =
136170
bundleOf(
137-
ARG_KEY_KIND to kind,
138-
ARG_KEY_ID to noteOrNotetypeId,
139-
ARG_KEY_PATH to imagePath,
171+
IO_ARGS_KEY to args,
140172
PATH_ARG_KEY to "image-occlusion/$suffix",
141-
ARG_KEY_EDITOR_DECK_ID to editorWorkingDeckId,
142173
)
174+
143175
return SingleFragmentActivity.getIntent(context, ImageOcclusion::class, arguments)
144176
}
145177
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/*
2+
* Copyright (c) 2025 Ashish Yadav <[email protected]>
3+
*
4+
* This program is free software; you can redistribute it and/or modify it under
5+
* the terms of the GNU General Public License as published by the Free Software
6+
* Foundation; either version 3 of the License, or (at your option) any later
7+
* version.
8+
*
9+
* This program is distributed in the hope that it will be useful, but WITHOUT ANY
10+
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
11+
* FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
12+
* details.
13+
*
14+
* You should have received a copy of the GNU General Public License along with
15+
* this program. If not, see <http://www.gnu.org/licenses/>.
16+
*/
17+
18+
package com.ichi2.anki.pages.viewmodel
19+
20+
import android.os.Parcelable
21+
import androidx.lifecycle.SavedStateHandle
22+
import androidx.lifecycle.ViewModel
23+
import androidx.lifecycle.viewModelScope
24+
import com.ichi2.anki.CollectionManager
25+
import com.ichi2.anki.libanki.DeckId
26+
import com.ichi2.anki.pages.ImageOcclusion
27+
import kotlinx.coroutines.launch
28+
import kotlinx.parcelize.Parcelize
29+
import org.json.JSONObject
30+
31+
@Parcelize
32+
data class ImageOcclusionArgs(
33+
val kind: String,
34+
val id: Long,
35+
val imagePath: String?,
36+
val editorDeckId: Long,
37+
) : Parcelable
38+
39+
/**
40+
* ViewModel for the Image Occlusion fragment.
41+
*/
42+
class ImageOcclusionViewModel(
43+
savedStateHandle: SavedStateHandle,
44+
) : ViewModel() {
45+
var selectedDeckId: Long
46+
47+
/**
48+
* The ID of the deck that was originally selected when the editor was opened.
49+
* This is used to restore the deck after saving a note to prevent unexpected deck changes.
50+
*/
51+
val oldDeckId: Long
52+
53+
/**
54+
* A [JSONObject] containing options for initializing the WebView. This includes
55+
* the type of operation ("add" or "edit"), and relevant IDs and paths.
56+
*/
57+
val webViewOptions: JSONObject
58+
59+
init {
60+
val args: ImageOcclusionArgs = checkNotNull(savedStateHandle[ImageOcclusion.IO_ARGS_KEY])
61+
62+
selectedDeckId = args.editorDeckId
63+
oldDeckId = args.editorDeckId
64+
65+
webViewOptions =
66+
JSONObject().apply {
67+
put("kind", args.kind)
68+
if (args.kind == "add") {
69+
put("imagePath", args.imagePath)
70+
put("notetypeId", args.id)
71+
} else {
72+
put("noteId", args.id)
73+
}
74+
}
75+
}
76+
77+
/**
78+
* Handles the selection of a new deck.
79+
*
80+
* @param deckId The [DeckId] object representing the selected deck. Can be null if no deck is selected.
81+
*/
82+
fun handleDeckSelection(deckId: DeckId): Boolean {
83+
if (deckId == selectedDeckId) return false
84+
selectedDeckId = deckId
85+
return true
86+
}
87+
88+
fun onSaveOperationCompleted() {
89+
if (oldDeckId == selectedDeckId) return
90+
viewModelScope.launch {
91+
CollectionManager.withCol { backend.setCurrentDeck(oldDeckId) }
92+
}
93+
}
94+
}

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,15 @@
3535
android:layout_height="wrap_content"
3636
app:navigationContentDescription="@string/abc_action_bar_up_description"
3737
app:navigationIcon="?attr/homeAsUpIndicator"
38-
app:menu="@menu/image_occlusion"/>
38+
app:menu="@menu/image_occlusion">
3939

40+
<Spinner
41+
android:id="@+id/deck_selector"
42+
android:layout_gravity="center_vertical"
43+
android:layout_width="wrap_content"
44+
android:layout_height="wrap_content"
45+
android:dropDownWidth="wrap_content"/>
46+
</com.google.android.material.appbar.MaterialToolbar>
4047
</com.google.android.material.appbar.AppBarLayout>
4148

4249
<WebView

0 commit comments

Comments
 (0)