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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import com.ichi2.anki.libanki.DeckId
import com.ichi2.anki.libanki.Note
import com.ichi2.anki.libanki.NotetypeJson
import com.ichi2.anki.observability.undoableOp
import com.ichi2.anki.progress.HasProgress
import com.ichi2.anki.progress.ProgressManager
import com.ichi2.anki.utils.ext.getAllClozeTextFields
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.MutableSharedFlow
Expand All @@ -36,9 +38,12 @@ import kotlin.math.max
*/
class InstantEditorViewModel :
ViewModel(),
OnErrorListener {
OnErrorListener,
HasProgress<InstantEditorProgress> {
override val onError = MutableSharedFlow<String>()

override val progressManager = ProgressManager<InstantEditorProgress>()

/** Errors or Warnings related to the edit fields that might occur when trying to save note */
val instantEditorError = MutableSharedFlow<String?>()

Expand Down Expand Up @@ -123,20 +128,21 @@ class InstantEditorViewModel :
* @param skipClozeCheck Indicates whether to skip the cloze field check.
* @return A [SaveNoteResult] indicating the outcome of the operation.
*/
suspend fun checkAndSaveNote(skipClozeCheck: Boolean = false): SaveNoteResult {
if (skipClozeCheck) {
return saveNote()
}
suspend fun checkAndSaveNote(skipClozeCheck: Boolean = false): SaveNoteResult =
progressManager.withProgress(InstantEditorProgress.SAVING_NOTE) {
if (skipClozeCheck) {
return@withProgress saveNote()
}

val note = editorNote
val result = checkNoteFieldsResponse(note)
if (result is NoteFieldsCheckResult.Failure) {
return SaveNoteResult.Warning(result.localizedMessage)
val note = editorNote
val result = checkNoteFieldsResponse(note)
if (result is NoteFieldsCheckResult.Failure) {
return@withProgress SaveNoteResult.Warning(result.localizedMessage)
}
Timber.d("Note fields check successful, saving note")
instantEditorError.emit(null)
saveNote()
}
Timber.d("Note fields check successful, saving note")
instantEditorError.emit(null)
return saveNote()
}

/** Adds the note to the collection.
* @return If the operation is successful, returns [SaveNoteResult.Success],
Expand Down Expand Up @@ -409,6 +415,10 @@ class InstantEditorViewModel :
}
}

enum class InstantEditorProgress {
SAVING_NOTE,
}

/**
* Represents the result of saving a note operation.
* Has three possible outcomes: `Success`, `Failure`, and `Warning`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import android.widget.EditText
import androidx.activity.OnBackPressedCallback
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog
import androidx.lifecycle.flowWithLifecycle
import androidx.lifecycle.lifecycleScope
Expand All @@ -43,6 +44,8 @@ import com.ichi2.anki.dialogs.startDeckSelection
import com.ichi2.anki.launchCatchingTask
import com.ichi2.anki.libanki.NotetypeJson
import com.ichi2.anki.model.SelectableDeck
import com.ichi2.anki.progress.ViewModelProgress
import com.ichi2.anki.progress.observeProgress
import com.ichi2.anki.servicelayer.NoteService
import com.ichi2.anki.startup.ensureStorageIsReady
import com.ichi2.anki.withProgress
Expand Down Expand Up @@ -122,6 +125,7 @@ class InstantNoteEditorActivity : AnkiActivity(R.layout.activity_instant_note_ed
)

setupErrorListeners()
observeProgress(viewModel) { progress -> getString(progress.messageRes) }
prepareEditorDialog()

registerDeckSelectedHandler(action = ::onDeckSelected)
Expand Down Expand Up @@ -377,11 +381,24 @@ class InstantNoteEditorActivity : AnkiActivity(R.layout.activity_instant_note_ed
* retrieves the field content, and saves the note
*/
private fun checkAndSave() {
if (isSaveIgnored()) return
extractFieldValues()

saveNoteWithProgress(skipClozeCheck = false)
}

private fun isSaveIgnored(): Boolean {
if (isFinishing) {
Timber.i("Ignoring save, the editor is closing")
return true
}
if (viewModel.progressManager.progress.value is ViewModelProgress.Active) {
Timber.i("Ignoring save while a note is saving")
return true
}
return false
}

private fun handleSaveNoteResult(result: SaveNoteResult) {
when (result) {
is SaveNoteResult.Failure -> {
Expand Down Expand Up @@ -513,19 +530,15 @@ class InstantNoteEditorActivity : AnkiActivity(R.layout.activity_instant_note_ed
AlertDialog.Builder(this).show {
message(text = errorMessage)
positiveButton(text = TR.actionsSave()) {
saveNoteWithProgress(skipClozeCheck = true)
if (!isSaveIgnored()) saveNoteWithProgress(skipClozeCheck = true)
}
negativeButton(CommonString.dialog_cancel)
}
}

private fun saveNoteWithProgress(skipClozeCheck: Boolean) {
lifecycleScope.launch {
val result =
withProgress(resources.getString(CommonString.saving_facts)) {
viewModel.checkAndSaveNote(skipClozeCheck = skipClozeCheck)
}
handleSaveNoteResult(result)
handleSaveNoteResult(viewModel.checkAndSaveNote(skipClozeCheck = skipClozeCheck))
}
}

Expand Down Expand Up @@ -652,3 +665,10 @@ class InstantNoteEditorActivity : AnkiActivity(R.layout.activity_instant_note_ed
SHOW_EDITOR_DIALOG,
}
}

@get:StringRes
private val InstantEditorProgress.messageRes: Int
get() =
when (this) {
InstantEditorProgress.SAVING_NOTE -> CommonString.saving_facts
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// SPDX-License-Identifier: GPL-3.0-or-later

package com.ichi2.anki.instanteditor

import android.content.DialogInterface
import android.content.Intent
import android.os.Handler
import android.os.Looper
import android.widget.EditText
import androidx.appcompat.app.AlertDialog
import androidx.lifecycle.ViewModelProvider
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.matcher.RootMatchers.isDialog
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.ichi2.anki.CollectionManager
import com.ichi2.anki.R
import com.ichi2.anki.RobolectricTest
import com.ichi2.anki.instantnoteeditor.InstantEditorViewModel
import com.ichi2.anki.instantnoteeditor.InstantNoteEditorActivity
import com.ichi2.testutils.withCollectionAccessOn
import kotlinx.coroutines.android.asCoroutineDispatcher
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Shadows.shadowOf
import org.robolectric.shadows.ShadowDialog

@RunWith(AndroidJUnit4::class)
class InstantEditorNoClozeSnapshotReviewTest : RobolectricTest() {
@Test
fun `confirmation saves the fields that produced the no-cloze warning`() =
runTest {
val originalText = "Hello world"
val intent =
Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, originalText)
}
val activity =
withCollectionAccessOn(Handler(Looper.getMainLooper()).asCoroutineDispatcher()) {
startRegularActivity<InstantNoteEditorActivity>(intent)
}
onView(withId(R.id.switch_edit_mode_button)).inRoot(isDialog()).perform(click())
val editor = ShadowDialog.getLatestDialog() as AlertDialog
val viewModel = ViewModelProvider(activity)[InstantEditorViewModel::class.java]
val notesBefore = col.noteCount()

withCollectionAccessOn(StandardTestDispatcher(testScheduler)) {
onView(withId(R.id.action_save_note)).inRoot(isDialog()).perform(click())
// This window remains interactive while validation waits in the collection queue.
editor.findViewById<EditText>(R.id.edit_field_text)!!.setText("")
}
advanceUntilIdle()
val warning = ShadowDialog.getLatestDialog() as AlertDialog
assertTrue("no-cloze warning must be shown", warning !== editor && warning.isShowing)
assertEquals(
CollectionManager.TR.addingYouHaveAClozeDeletionNote(),
warning.findViewById<android.widget.TextView>(android.R.id.message)!!.text.toString(),
)

warning.getButton(DialogInterface.BUTTON_POSITIVE).performClick()
shadowOf(Looper.getMainLooper()).idle()
advanceUntilIdle()

assertTrue("confirmed note is saved", activity.isFinishing)
assertEquals("one note added", notesBefore + 1, col.noteCount())
assertEquals(
"confirmation must not insert unvalidated replacement fields",
originalText,
col.getNote(viewModel.editorNote.id).fields[0],
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,20 @@
package com.ichi2.anki.instanteditor

import androidx.test.ext.junit.runners.AndroidJUnit4
import app.cash.turbine.test
import com.ichi2.anki.CollectionManager
import com.ichi2.anki.RobolectricTest
import com.ichi2.anki.instantnoteeditor.InstantEditorProgress
import com.ichi2.anki.instantnoteeditor.InstantEditorViewModel
import com.ichi2.anki.instantnoteeditor.InstantNoteEditorActivity
import com.ichi2.anki.instantnoteeditor.SaveNoteResult
import com.ichi2.anki.libanki.testutils.AnkiTest
import com.ichi2.anki.progress.ViewModelProgress
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import kotlin.test.assertIs

@RunWith(AndroidJUnit4::class)
class InstantEditorViewModelTest : RobolectricTest() {
Expand Down Expand Up @@ -103,6 +107,21 @@ class InstantEditorViewModelTest : RobolectricTest() {
assertEquals(CollectionManager.TR.addingTheFirstFieldIsEmpty(), saveNoteResult(result))
}

@Test
fun `saving a note reports progress`() =
runViewModelTest {
editorNote.setField(0, "{{c1::Hello}}")

progressManager.progress.test {
assertIs<ViewModelProgress.Idle>(awaitItem())
checkAndSaveNote()
val active = assertIs<ViewModelProgress.Active<InstantEditorProgress>>(awaitItem())
assertEquals(InstantEditorProgress.SAVING_NOTE, active.message)
assertIs<ViewModelProgress.Idle>(awaitItem())
expectNoEvents()
}
}

@Test
fun testCheckAndSaveNote_NullEditorNote_ReturnsFailure() =
runViewModelTest {
Expand Down
Loading
Loading