Skip to content

Commit c78b61a

Browse files
committed
fix: back press trigger the discard dialog
1 parent 7dc302e commit c78b61a

3 files changed

Lines changed: 133 additions & 10 deletions

File tree

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

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,10 @@ import android.graphics.Color
2424
import android.net.Uri
2525
import android.os.Bundle
2626
import android.view.View
27+
import androidx.activity.OnBackPressedCallback
2728
import androidx.core.graphics.createBitmap
2829
import androidx.fragment.app.Fragment
30+
import androidx.lifecycle.lifecycleScope
2931
import com.ichi2.anki.common.time.TimeManager
3032
import com.ichi2.anki.common.time.getTimestamp
3133
import com.ichi2.anki.compat.CompatHelper
@@ -35,6 +37,8 @@ import com.ichi2.anki.ui.windows.reviewer.whiteboard.WhiteboardFragment
3537
import com.ichi2.anki.ui.windows.reviewer.whiteboard.WhiteboardView
3638
import com.ichi2.themes.Themes
3739
import dev.androidbroadcast.vbpd.viewBinding
40+
import kotlinx.coroutines.flow.launchIn
41+
import kotlinx.coroutines.flow.onEach
3842

3943
class DrawingFragment : Fragment(R.layout.fragment_drawing) {
4044
private val binding by viewBinding(FragmentDrawingBinding::bind)
@@ -48,15 +52,7 @@ class DrawingFragment : Fragment(R.layout.fragment_drawing) {
4852
super.onViewCreated(view, savedInstanceState)
4953
binding.toolbar.apply {
5054
setNavigationOnClickListener {
51-
// avoid showing the discard changes dialog only if the user hasn't drawn anything,
52-
// even if is is erased or undone, since they may want to undo/redo something.
53-
if (whiteboardFragment?.isEmpty() == true) {
54-
requireActivity().onBackPressedDispatcher.onBackPressed()
55-
} else {
56-
DiscardChangesDialog.showDialog(requireContext()) {
57-
requireActivity().onBackPressedDispatcher.onBackPressed()
58-
}
59-
}
55+
requireActivity().onBackPressedDispatcher.onBackPressed()
6056
}
6157
setOnMenuItemClickListener { item ->
6258
when (item.itemId) {
@@ -66,6 +62,34 @@ class DrawingFragment : Fragment(R.layout.fragment_drawing) {
6662
true
6763
}
6864
}
65+
// Defer setup until after parent and child `onViewCreated` methods complete.
66+
// This is required to:
67+
// Guarantee LIFO priority over the child's snackbar callback (parent/child execution order varies by OS).
68+
// Ensure the child's `doubleBackCallback` is fully initialized before we modify it.
69+
view.post {
70+
if (!isAdded) return@post
71+
val whiteboard = whiteboardFragment ?: return@post
72+
73+
// Suppress the reviewer's "go back again to exit" snackbar it's not the right UX in the drawing flow.
74+
whiteboard.doubleBackCallback?.isEnabled = false
75+
76+
// Intercept back navigation only when there's content to discard. When empty, the callback stays disabled so the press falls through
77+
// to the activity finishing and predictive back shows its system exit animation.
78+
val backCallback =
79+
object : OnBackPressedCallback(enabled = false) {
80+
override fun handleOnBackPressed() {
81+
DiscardChangesDialog.showDialog(requireContext()) {
82+
isEnabled = false
83+
requireActivity().onBackPressedDispatcher.onBackPressed()
84+
}
85+
}
86+
}
87+
requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner, backCallback)
88+
89+
whiteboard.isEmptyFlow
90+
.onEach { isEmpty -> backCallback.isEnabled = !isEmpty }
91+
.launchIn(viewLifecycleOwner.lifecycleScope)
92+
}
6993
}
7094

7195
private fun onSaveDrawing() {

AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardFragment.kt

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ import com.ichi2.utils.dp
5656
import com.ichi2.utils.increaseHorizontalPaddingOfMenuIcons
5757
import com.ichi2.utils.toRGBAHex
5858
import dev.androidbroadcast.vbpd.viewBinding
59+
import kotlinx.coroutines.flow.Flow
5960
import kotlinx.coroutines.flow.combine
6061
import kotlinx.coroutines.flow.launchIn
6162
import kotlinx.coroutines.flow.onEach
@@ -75,7 +76,14 @@ class WhiteboardFragment :
7576

7677
val binding by viewBinding(FragmentWhiteboardBinding::bind)
7778
private lateinit var bindingMap: BindingMap<ReviewerBinding, WhiteboardAction>
78-
private var doubleBackCallback: OnBackPressedCallback? = null
79+
80+
/**
81+
* "Go back again to exit" back-press callback. Exposed so hosts that have their
82+
* own back navigation (e.g. [com.ichi2.anki.DrawingFragment] with a discard dialog)
83+
* can disable it via `doubleBackCallback?.isEnabled = false`.
84+
*/
85+
var doubleBackCallback: OnBackPressedCallback? = null
86+
private set
7987

8088
private var eraserPopup: PopupWindow? = null
8189
private var brushConfigPopup: PopupWindow? = null
@@ -476,4 +484,15 @@ class WhiteboardFragment :
476484
* @return whether the whiteboard is completely empty, including the undo and redo stacks.
477485
*/
478486
fun isEmpty(): Boolean = !viewModel.canUndo.value && !viewModel.canRedo.value
487+
488+
/**
489+
* Emits `true` when the whiteboard is empty (cannot undo or redo) and `false` otherwise.
490+
* Useful for hosts that need to react to content changes, e.g. to toggle a back-press
491+
* callback's `isEnabled`.
492+
*/
493+
val isEmptyFlow: Flow<Boolean>
494+
get() =
495+
combine(viewModel.canUndo, viewModel.canRedo) { canUndo, canRedo ->
496+
!canUndo && !canRedo
497+
}
479498
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
/*
2+
* Copyright (c) 2026 Ashish Yadav <mailtoashish693@gmail.com>
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 FOR A
11+
* PARTICULAR PURPOSE. See the GNU General Public License for more details.
12+
*
13+
* You should have received a copy of the GNU General Public License along with
14+
* this program. If not, see <http://www.gnu.org/licenses/>.
15+
*/
16+
package com.ichi2.anki
17+
18+
import android.graphics.Path
19+
import androidx.appcompat.app.AlertDialog
20+
import androidx.test.ext.junit.runners.AndroidJUnit4
21+
import com.ichi2.anki.ui.windows.reviewer.whiteboard.WhiteboardFragment
22+
import org.hamcrest.MatcherAssert.assertThat
23+
import org.hamcrest.Matchers.equalTo
24+
import org.junit.Test
25+
import org.junit.runner.RunWith
26+
import org.robolectric.shadows.ShadowDialog
27+
import kotlin.test.assertNotNull
28+
import kotlin.test.assertNull
29+
30+
/** Tests for [DrawingFragment] */
31+
@RunWith(AndroidJUnit4::class)
32+
class DrawingFragmentTest : RobolectricTest() {
33+
@Test
34+
fun `back press finishes the activity when the whiteboard is empty`() {
35+
val (activity, _) = launchDrawingActivity()
36+
37+
activity.onBackPressedDispatcher.onBackPressed()
38+
advanceRobolectricLooper()
39+
40+
assertNull(ShadowDialog.getLatestDialog(), "no discard dialog should be shown for an empty whiteboard")
41+
assertThat("activity finishes immediately", activity.isFinishing, equalTo(true))
42+
}
43+
44+
@Test
45+
fun `back press shows the discard dialog when the whiteboard has content`() {
46+
val (activity, whiteboard) = launchDrawingActivity()
47+
whiteboard.binding.whiteboardView.onNewPath
48+
?.invoke(samplePath())
49+
advanceRobolectricLooper()
50+
51+
activity.onBackPressedDispatcher.onBackPressed()
52+
advanceRobolectricLooper()
53+
54+
val dialog =
55+
assertNotNull(
56+
ShadowDialog.getLatestDialog() as? AlertDialog,
57+
"discard dialog should be shown",
58+
)
59+
assertThat("activity is not finishing while dialog is open", activity.isFinishing, equalTo(false))
60+
61+
dialog.getButton(AlertDialog.BUTTON_POSITIVE).performClick()
62+
advanceRobolectricLooper()
63+
assertThat("activity finishes after discard is confirmed", activity.isFinishing, equalTo(true))
64+
}
65+
66+
private fun launchDrawingActivity(): Pair<SingleFragmentActivity, WhiteboardFragment> {
67+
val activity = startRegularActivity<SingleFragmentActivity>(DrawingFragment.getIntent(targetContext))
68+
val drawingFragment =
69+
activity.supportFragmentManager.findFragmentByTag(SingleFragmentActivity.FRAGMENT_TAG) as DrawingFragment
70+
val whiteboardFragment =
71+
drawingFragment.childFragmentManager.findFragmentById(R.id.fragment_container) as WhiteboardFragment
72+
return activity to whiteboardFragment
73+
}
74+
75+
private fun samplePath(): Path =
76+
Path().apply {
77+
moveTo(0f, 0f)
78+
lineTo(10f, 10f)
79+
}
80+
}

0 commit comments

Comments
 (0)