Skip to content

Commit 1ffadf4

Browse files
committed
refactor(deck-picker): introduce UiEvent
A common pattern in MVI (and some of Compose) is to encapsulate all one-shot side-effects into a wrapper interface. This is implemented via a Channel, enforcing correct one-shot and buffering behavior This pattern allows: * Removal of 'StateFlow-related' bugs * Removal of 'val' flows * Simple 'Result' classes * Fewer methods handling 'Unit' in the DeckPicker Assisted-by: Claude Opus 4.6
1 parent 2182c3e commit 1ffadf4

5 files changed

Lines changed: 170 additions & 69 deletions

File tree

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

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ import com.ichi2.anki.deckpicker.EmptyCardsResult
114114
import com.ichi2.anki.deckpicker.OptionsMenuState
115115
import com.ichi2.anki.deckpicker.ShortcutData
116116
import com.ichi2.anki.deckpicker.SyncIconState
117+
import com.ichi2.anki.deckpicker.UiEvent
117118
import com.ichi2.anki.dialogs.AsyncDialogFragment
118119
import com.ichi2.anki.dialogs.BackupPromptDialog
119120
import com.ichi2.anki.dialogs.CreateDeckDialog
@@ -833,24 +834,28 @@ open class DeckPicker :
833834
.show()
834835
}
835836

836-
viewModel.deckDeletedNotification.launchCollectionInLifecycleScope(::onDeckDeleted)
837-
viewModel.emptyCardsNotification.launchCollectionInLifecycleScope(::onCardsEmptied)
838-
viewModel.flowOfDeckCountsChanged.launchCollectionInLifecycleScope(::onDeckCountsChanged)
839-
viewModel.flowOfDestination.launchCollectionInLifecycleScope(::onDestinationChanged)
840-
viewModel.flowOfExportDeck.launchCollectionInLifecycleScope(::onExportDeck)
841-
viewModel.flowOfCreateShortcut.launchCollectionInLifecycleScope(::createIcon)
842-
viewModel.flowOfDisableShortcuts.launchCollectionInLifecycleScope(::disableDeckAndChildrenShortcuts)
843-
viewModel.onError.launchCollectionInLifecycleScope(::onError)
844-
viewModel.flowOfPromptUserToUpdateScheduler.launchCollectionInLifecycleScope(::onPromptUserToUpdateScheduler)
845-
viewModel.flowOfUndoUpdated.launchCollectionInLifecycleScope(::onUndoUpdated)
837+
viewModel.uiEvents.launchCollectionInLifecycleScope { event ->
838+
when (event) {
839+
is UiEvent.DeckDeleted -> onDeckDeleted(event.result)
840+
is UiEvent.EmptyCardsDeleted -> onCardsEmptied(event.result)
841+
is UiEvent.Navigate -> onDestinationChanged(event.destination)
842+
is UiEvent.ShowError -> onError(event.message)
843+
is UiEvent.ExportDeck -> onExportDeck(event.deckId)
844+
is UiEvent.CreateShortcut -> createIcon(event.data)
845+
is UiEvent.DisableShortcuts -> disableDeckAndChildrenShortcuts(event.deckIds)
846+
UiEvent.PromptUpdateScheduler -> onPromptUserToUpdateScheduler(Unit)
847+
UiEvent.UndoUpdated -> onUndoUpdated(Unit)
848+
UiEvent.DecksReloaded -> onDecksReloaded(Unit)
849+
UiEvent.DeckCountsChanged -> onDeckCountsChanged(Unit)
850+
}
851+
}
846852
viewModel.flowOfStudiedTodayStats.launchCollectionInLifecycleScope(::onStudiedTodayChanged)
847853
viewModel.flowOfDeckListInInitialState.filterNotNull().launchCollectionInLifecycleScope(::onCollectionStatusChanged)
848854
viewModel.flowOfCardsDue.launchCollectionInLifecycleScope(::onCardsDueChanged)
849855
viewModel.flowOfCollectionHasNoCards.launchCollectionInLifecycleScope(::onStudyOptionsVisibilityChanged)
850856
viewModel.flowOfDeckList.launchCollectionInLifecycleScope(::onDeckListChanged)
851857
viewModel.flowOfFocusedDeck.launchCollectionInLifecycleScope(::onFocusedDeckChanged)
852858
viewModel.flowOfResizingDividerVisible.launchCollectionInLifecycleScope(::onResizingDividerVisibilityChanged)
853-
viewModel.flowOfDecksReloaded.launchCollectionInLifecycleScope(::onDecksReloaded)
854859
viewModel.flowOfStartupResponse.filterNotNull().launchCollectionInLifecycleScope(::onStartupResponse)
855860
}
856861

AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/DeckPickerViewModel.kt

Lines changed: 87 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ import com.ichi2.anki.performBackupInBackground
5555
import com.ichi2.anki.reviewreminders.ScheduleRemindersDestination
5656
import com.ichi2.anki.settings.Prefs
5757
import com.ichi2.anki.syncAuth
58+
import com.ichi2.anki.ui.ChannelUiEventHost
5859
import com.ichi2.anki.utils.Destination
5960
import kotlinx.coroutines.Dispatchers
6061
import kotlinx.coroutines.Job
@@ -128,22 +129,22 @@ class DeckPickerViewModel :
128129
}.stateIn(viewModelScope, SharingStarted.Eagerly, initialValue = FlattenedDeckList.empty)
129130

130131
/**
131-
* @see deleteDeck
132-
* @see DeckDeletionResult
132+
* Single channel of one-shot UI events for [DeckPicker].
133+
*
134+
* Use this for navigation, snackbars, dialogs, error messages, and other
135+
* fire-once events. Do *not* use it for ongoing state — keep that as `StateFlow`.
133136
*/
134-
val deckDeletedNotification = MutableSharedFlow<DeckDeletionResult>(extraBufferCapacity = 1)
135-
val emptyCardsNotification = MutableSharedFlow<EmptyCardsResult>(extraBufferCapacity = 1)
136-
val flowOfDestination = MutableSharedFlow<Destination>(extraBufferCapacity = 1)
137-
override val onError = MutableSharedFlow<String>(extraBufferCapacity = 1)
138-
val flowOfExportDeck = MutableSharedFlow<DeckId>()
139-
val flowOfCreateShortcut = MutableSharedFlow<ShortcutData>()
140-
val flowOfDisableShortcuts = MutableSharedFlow<List<String>>()
137+
private val events = ChannelUiEventHost<UiEvent>()
138+
val uiEvents = events.uiEvents
141139

142140
/**
143-
* A notification that the study counts have changed
141+
* Errors emitted via the standard `launchCatchingIO { }` machinery.
142+
*
143+
* This satisfies [OnErrorListener] (which `launchCatchingIO` depends on) and is
144+
* forwarded into [uiEvents] as [UiEvent.ShowError] in [init].
145+
* Prefer collecting [uiEvents] in the UI rather than this flow directly.
144146
*/
145-
// TODO: most of the recalculation should be moved inside the ViewModel
146-
val flowOfDeckCountsChanged = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
147+
override val onError = MutableSharedFlow<String>(extraBufferCapacity = 1)
147148

148149
var loadDeckCounts: Job? = null
149150
private set
@@ -154,10 +155,6 @@ class DeckPickerViewModel :
154155
*/
155156
private var schedulerUpgradeDialogShownForVersion: Long? = null
156157

157-
val flowOfPromptUserToUpdateScheduler = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
158-
159-
val flowOfUndoUpdated = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
160-
161158
val flowOfCollectionHasNoCards = MutableStateFlow(true)
162159

163160
val flowOfDeckListInInitialState =
@@ -182,16 +179,13 @@ class DeckPickerViewModel :
182179
!(isInInitialState == true || hasNoCards)
183180
}
184181

185-
// HACK: dismiss a legacy progress bar
186-
// TODO: Replace with better progress handling for first load/corrupt collections
187-
// This MutableSharedFlow has replay=1 due to a race condition between its collector being started
188-
// and a possible early emission that occurs when the user is on a metered network and a dialog has to show up
189-
// to ask the user if they want to trigger a sync. Normally, the spinning progress indicator is
190-
// dismissed via an emission to this flow after the sync is completed, but if the metered network
191-
// warning dialog appears, we should immediately refresh the UI in case the user decides not to sync.
192-
// Otherwise, the progress indicator remains indefinitely. This replay=1 ensures that the collector will
193-
// receive the dismissal event even if it starts after the emission.
194-
val flowOfDecksReloaded = MutableSharedFlow<Unit>(extraBufferCapacity = 1, replay = 1)
182+
init {
183+
// Bridge `OnErrorListener.onError` (driven by `launchCatchingIO { }`) into the
184+
// unified side-effects channel so the UI only needs one collector.
185+
viewModelScope.launch {
186+
onError.collect { events.emit(UiEvent.ShowError(it)) }
187+
}
188+
}
195189

196190
/**
197191
* Deletes the provided deck, child decks. and all cards inside.
@@ -209,8 +203,10 @@ class DeckPickerViewModel :
209203
// to match and avoid unnecessary scrolls in `renderPage()`.
210204
focusedDeck = Consts.DEFAULT_DECK_ID
211205

212-
deckDeletedNotification.emit(
213-
DeckDeletionResult(deckName = deckName, cardsDeleted = changes.count),
206+
events.emit(
207+
UiEvent.DeckDeleted(
208+
DeckDeletionResult(deckName = deckName, cardsDeleted = changes.count),
209+
),
214210
)
215211
}
216212

@@ -249,7 +245,7 @@ class DeckPickerViewModel :
249245
}
250246
}
251247
val result = undoableOp { removeCardsAndOrphanedNotes(toDelete) }
252-
emptyCardsNotification.emit(EmptyCardsResult(cardsDeleted = result.count))
248+
events.emit(UiEvent.EmptyCardsDeleted(EmptyCardsResult(cardsDeleted = result.count)))
253249
}
254250

255251
// TODO: move withProgress to the ViewModel, so we don't return 'Job'
@@ -258,7 +254,7 @@ class DeckPickerViewModel :
258254
Timber.i("empty filtered deck %s", deckId)
259255
withCol { decks.select(deckId) }
260256
undoableOp { sched.emptyFilteredDeck(decks.selected()) }
261-
flowOfDeckCountsChanged.emit(Unit)
257+
events.emit(UiEvent.DeckCountsChanged)
262258
}
263259

264260
/**
@@ -272,13 +268,13 @@ class DeckPickerViewModel :
272268
decks.select(deckId)
273269
sched.rebuildFilteredDeck(decks.selected())
274270
}
275-
flowOfDeckCountsChanged.emit(Unit)
271+
events.emit(UiEvent.DeckCountsChanged)
276272
}
277273

278274
fun browseCards(deckId: DeckId) =
279275
launchCatchingIO {
280276
withCol { decks.select(deckId) }
281-
flowOfDestination.emit(BrowserDestination.ToDeck(deckId))
277+
events.emit(UiEvent.Navigate(BrowserDestination.ToDeck(deckId)))
282278
}
283279

284280
fun addNote(
@@ -288,13 +284,13 @@ class DeckPickerViewModel :
288284
if (deckId != null && setAsCurrent) {
289285
withCol { decks.select(deckId) }
290286
}
291-
flowOfDestination.emit(NoteEditorLauncher.AddNote(deckId))
287+
events.emit(UiEvent.Navigate(NoteEditorLauncher.AddNote(deckId)))
292288
}
293289

294290
/**
295291
* Opens the Manage Note Types screen.
296292
*/
297-
fun openManageNoteTypes() = launchCatchingIO { flowOfDestination.emit(ManageNoteTypesDestination()) }
293+
fun openManageNoteTypes() = launchCatchingIO { events.emit(UiEvent.Navigate(ManageNoteTypesDestination())) }
298294

299295
/**
300296
* Opens study options for the provided deck
@@ -308,7 +304,7 @@ class DeckPickerViewModel :
308304
) = launchCatchingIO {
309305
// open cram options if filtered deck, otherwise open regular options
310306
val filtered = isFiltered ?: withCol { decks.isFiltered(deckId) }
311-
flowOfDestination.emit(DeckOptionsDestination(deckId = deckId, isFiltered = filtered))
307+
events.emit(UiEvent.Navigate(DeckOptionsDestination(deckId = deckId, isFiltered = filtered)))
312308
}
313309

314310
fun unburyDeck(deckId: DeckId) =
@@ -318,7 +314,7 @@ class DeckPickerViewModel :
318314

319315
fun scheduleReviewReminders(deckId: DeckId) =
320316
viewModelScope.launch {
321-
flowOfDestination.emit(ScheduleRemindersDestination(deckId))
317+
events.emit(UiEvent.Navigate(ScheduleRemindersDestination(deckId)))
322318
}
323319

324320
/**
@@ -371,17 +367,17 @@ class DeckPickerViewModel :
371367

372368
if (currentSchedulerVersion == 1L && schedulerUpgradeDialogShownForVersion != 1L) {
373369
schedulerUpgradeDialogShownForVersion = 1L
374-
flowOfPromptUserToUpdateScheduler.emit(Unit)
370+
events.emit(UiEvent.PromptUpdateScheduler)
375371
} else {
376372
schedulerUpgradeDialogShownForVersion = currentSchedulerVersion
377373
}
378374

379375
// TODO: This is in the wrong place
380376
// current deck may have changed
381377
focusedDeck = withCol { decks.current().id }
382-
flowOfUndoUpdated.emit(Unit)
378+
events.emit(UiEvent.UndoUpdated)
383379

384-
flowOfDecksReloaded.emit(Unit)
380+
events.emit(UiEvent.DecksReloaded)
385381
}
386382
this.loadDeckCounts = loadDeckCounts
387383
return loadDeckCounts
@@ -414,7 +410,7 @@ class DeckPickerViewModel :
414410
*/
415411
fun exportDeck(deckId: DeckId) =
416412
launchCatchingIO {
417-
flowOfExportDeck.emit(deckId)
413+
events.emit(UiEvent.ExportDeck(deckId))
418414
}
419415

420416
/**
@@ -449,11 +445,13 @@ class DeckPickerViewModel :
449445
fullName,
450446
)
451447
}
452-
flowOfCreateShortcut.emit(
453-
ShortcutData(
454-
deckId = deckId,
455-
shortLabel = shortLabel,
456-
longLabel = longLabel,
448+
events.emit(
449+
UiEvent.CreateShortcut(
450+
ShortcutData(
451+
deckId = deckId,
452+
shortLabel = shortLabel,
453+
longLabel = longLabel,
454+
),
457455
),
458456
)
459457
}
@@ -462,7 +460,7 @@ class DeckPickerViewModel :
462460
fun disableDeckAndChildrenShortcuts(deckId: DeckId) =
463461
launchCatchingIO {
464462
val deckTreeDids = dueTree?.find(deckId)?.map { it.did.toString() } ?: emptyList()
465-
flowOfDisableShortcuts.emit(deckTreeDids)
463+
events.emit(UiEvent.DisableShortcuts(deckTreeDids))
466464
}
467465

468466
sealed class StartupResponse {
@@ -657,6 +655,49 @@ enum class SyncIconState {
657655
NotLoggedIn,
658656
}
659657

658+
/**
659+
* One-shot UI events emitted by [DeckPickerViewModel].
660+
*
661+
* @see com.ichi2.anki.ui.UiEventHost
662+
*/
663+
sealed interface UiEvent {
664+
data class DeckDeleted(
665+
val result: DeckDeletionResult,
666+
) : UiEvent
667+
668+
data class EmptyCardsDeleted(
669+
val result: EmptyCardsResult,
670+
) : UiEvent
671+
672+
data class Navigate(
673+
val destination: Destination,
674+
) : UiEvent
675+
676+
data class ShowError(
677+
val message: String,
678+
) : UiEvent
679+
680+
data class ExportDeck(
681+
val deckId: DeckId,
682+
) : UiEvent
683+
684+
data class CreateShortcut(
685+
val data: ShortcutData,
686+
) : UiEvent
687+
688+
data class DisableShortcuts(
689+
val deckIds: List<String>,
690+
) : UiEvent
691+
692+
data object PromptUpdateScheduler : UiEvent
693+
694+
data object UndoUpdated : UiEvent
695+
696+
data object DecksReloaded : UiEvent
697+
698+
data object DeckCountsChanged : UiEvent
699+
}
700+
660701
/** Menu state data for the options menu */
661702
data class OptionsMenuState(
662703
val searchIcon: Boolean,
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/*
2+
* Copyright (c) 2026 David Allison <davidallisongithub@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.ui
17+
18+
import kotlinx.coroutines.channels.Channel
19+
import kotlinx.coroutines.flow.Flow
20+
import kotlinx.coroutines.flow.receiveAsFlow
21+
22+
/**
23+
* One-shot UI events from a ViewModel to its UI, using a single hot Flow.
24+
*
25+
* Each event must be delivered EXACTLY once, buffered while the UI is in the background,
26+
* and never replayed on re-subscription.
27+
*
28+
* This is the wrong choice for ongoing state (use `StateFlow` instead).
29+
*/
30+
interface UiEventHost<T : Any> {
31+
val uiEvents: Flow<T>
32+
33+
suspend fun emit(event: T)
34+
35+
fun tryEmit(event: T): Boolean
36+
}
37+
38+
/**
39+
* Implements [UiEventHost] using a [Channel]
40+
*
41+
* @see UiEventHost
42+
*/
43+
class ChannelUiEventHost<T : Any>(
44+
capacity: Int = Channel.BUFFERED,
45+
) : UiEventHost<T> {
46+
private val channel = Channel<T>(capacity)
47+
override val uiEvents: Flow<T> = channel.receiveAsFlow()
48+
49+
override suspend fun emit(event: T) = channel.send(event)
50+
51+
override fun tryEmit(event: T): Boolean = channel.trySend(event).isSuccess
52+
}

AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import app.cash.turbine.test
2222
import com.ichi2.anki.common.time.TimeManager
2323
import com.ichi2.anki.common.utils.annotation.KotlinCleanup
2424
import com.ichi2.anki.deckpicker.DeckPickerViewModel
25+
import com.ichi2.anki.deckpicker.UiEvent
2526
import com.ichi2.anki.dialogs.DatabaseErrorDialog
2627
import com.ichi2.anki.dialogs.DatabaseErrorDialog.DatabaseErrorDialogType
2728
import com.ichi2.anki.dialogs.DeckPickerContextMenu
@@ -46,6 +47,7 @@ import com.ichi2.testutils.grantWritePermissions
4647
import com.ichi2.testutils.revokeWritePermissions
4748
import com.ichi2.testutils.withDeniedPermissions
4849
import com.ichi2.testutils.withWritePermissions
50+
import kotlinx.coroutines.flow.filterIsInstance
4951
import org.hamcrest.MatcherAssert.assertThat
5052
import org.hamcrest.Matchers.containsInAnyOrder
5153
import org.hamcrest.Matchers.containsString
@@ -428,9 +430,9 @@ class DeckPickerTest : RobolectricTest() {
428430
deckId: DeckId,
429431
): Intent {
430432
var result: Destination? = null
431-
viewModel.flowOfDestination.test(1.seconds) {
433+
viewModel.uiEvents.filterIsInstance<UiEvent.Navigate>().test(1.seconds) {
432434
supportFragmentManager.selectContextMenuOption(option, deckId)
433-
result = awaitItem()
435+
result = awaitItem().destination
434436
}
435437
return result!!.toIntent(this)
436438
}

0 commit comments

Comments
 (0)