Skip to content

Commit fa28bdb

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

File tree

3 files changed

+181
-49
lines changed

3 files changed

+181
-49
lines changed

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

Lines changed: 76 additions & 48 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,32 +106,44 @@ class ImageOcclusion : PageFragment(R.layout.image_occlusion) {
90106
url: String?,
91107
) {
92108
super.onPageFinished(view, url)
109+
viewModel.webViewOptions.let { options ->
110+
view?.evaluateJavascript("globalThis.anki.imageOcclusion.mode = $options") {
111+
super.onPageFinished(view, url)
112+
}
113+
}
114+
}
115+
}
93116

94-
val kind = requireArguments().getString(ARG_KEY_KIND)
95-
val noteOrNotetypeId = requireArguments().getLong(ARG_KEY_ID)
96-
val imagePath = requireArguments().getString(ARG_KEY_PATH)
117+
override fun onDeckSelected(deck: SelectableDeck?) {
118+
if (deck == null) return
119+
require(deck is SelectableDeck.Deck)
97120

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)
105-
}
121+
val deckDidChange = viewModel.handleDeckSelection(deck.deckId)
122+
if (deckDidChange) {
123+
viewLifecycleOwner.lifecycleScope.launch {
124+
select(deck.deckId)
125+
deckSpinnerSelection.selectDeckById(viewModel.selectedDeckId, true)
126+
}
127+
}
128+
}
106129

107-
view?.evaluateJavascript("globalThis.anki.imageOcclusion.mode = $options") {
108-
super.onPageFinished(view, url)
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)
109135
}
110136
}
111-
}
112137

113-
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"
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+
}
118145

146+
companion object {
119147
/**
120148
* @param editorWorkingDeckId the current deck id that [com.ichi2.anki.NoteEditorFragment] is using
121149
*/
@@ -134,11 +162,11 @@ class ImageOcclusion : PageFragment(R.layout.image_occlusion) {
134162
}
135163
val arguments =
136164
bundleOf(
137-
ARG_KEY_KIND to kind,
138-
ARG_KEY_ID to noteOrNotetypeId,
139-
ARG_KEY_PATH to imagePath,
165+
ImageOcclusionArgs.KEY_KIND to kind,
166+
ImageOcclusionArgs.KEY_ID to noteOrNotetypeId,
167+
ImageOcclusionArgs.KEY_PATH to imagePath,
140168
PATH_ARG_KEY to "image-occlusion/$suffix",
141-
ARG_KEY_EDITOR_DECK_ID to editorWorkingDeckId,
169+
ImageOcclusionArgs.KEY_EDITOR_DECK_ID to editorWorkingDeckId,
142170
)
143171
return SingleFragmentActivity.getIntent(context, ImageOcclusion::class, arguments)
144172
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
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 androidx.lifecycle.SavedStateHandle
21+
import androidx.lifecycle.ViewModel
22+
import androidx.lifecycle.viewModelScope
23+
import com.ichi2.anki.CollectionManager
24+
import com.ichi2.anki.libanki.DeckId
25+
import com.ichi2.anki.pages.viewmodel.ImageOcclusionArgs.KEY_EDITOR_DECK_ID
26+
import com.ichi2.anki.pages.viewmodel.ImageOcclusionArgs.KEY_ID
27+
import com.ichi2.anki.pages.viewmodel.ImageOcclusionArgs.KEY_KIND
28+
import com.ichi2.anki.pages.viewmodel.ImageOcclusionArgs.KEY_PATH
29+
import kotlinx.coroutines.launch
30+
import org.json.JSONObject
31+
32+
/** Argument keys used for passing data to the [com.ichi2.anki.pages.ImageOcclusion] fragment.*/
33+
object ImageOcclusionArgs {
34+
const val KEY_KIND = "kind"
35+
const val KEY_ID = "id"
36+
const val KEY_PATH = "imagePath"
37+
const val KEY_EDITOR_DECK_ID = "arg_key_editor_deck_id"
38+
}
39+
40+
/**
41+
* ViewModel for the Image Occlusion fragment.
42+
*/
43+
class ImageOcclusionViewModel(
44+
savedStateHandle: SavedStateHandle,
45+
) : ViewModel() {
46+
var selectedDeckId: Long
47+
48+
/**
49+
* The ID of the deck that was originally selected when the editor was opened.
50+
* This is used to restore the deck after saving a note to prevent unexpected deck changes.
51+
*/
52+
val oldDeckID: Long
53+
54+
/**
55+
* A [JSONObject] containing options for initializing the WebView. This includes
56+
* the type of operation ("add" or "edit"), and relevant IDs and paths.
57+
*/
58+
val webViewOptions: JSONObject
59+
60+
init {
61+
val deckId: Long = checkNotNull(savedStateHandle[KEY_EDITOR_DECK_ID])
62+
val kind: String = checkNotNull(savedStateHandle[KEY_KIND])
63+
val noteOrNotetypeId: Long = checkNotNull(savedStateHandle[KEY_ID])
64+
val imagePath: String? = savedStateHandle[KEY_PATH]
65+
66+
selectedDeckId = deckId
67+
oldDeckID = deckId
68+
69+
webViewOptions =
70+
JSONObject().apply {
71+
put("kind", kind)
72+
if (kind == "add") {
73+
put("imagePath", imagePath)
74+
put("notetypeId", noteOrNotetypeId)
75+
} else {
76+
put("noteId", noteOrNotetypeId)
77+
}
78+
}
79+
}
80+
81+
/**
82+
* Handles the selection of a new deck.
83+
*
84+
* @param deckId The [DeckId] object representing the selected deck. Can be null if no deck is selected.
85+
*/
86+
fun handleDeckSelection(deckId: DeckId): Boolean {
87+
if (deckId == selectedDeckId) return false
88+
selectedDeckId = deckId
89+
return true
90+
}
91+
92+
fun onSaveOperationCompleted() {
93+
viewModelScope.launch {
94+
CollectionManager.withCol { backend.setCurrentDeck(oldDeckID) }
95+
}
96+
}
97+
}

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)