@@ -55,6 +55,7 @@ import com.ichi2.anki.performBackupInBackground
5555import com.ichi2.anki.reviewreminders.ScheduleRemindersDestination
5656import com.ichi2.anki.settings.Prefs
5757import com.ichi2.anki.syncAuth
58+ import com.ichi2.anki.ui.ChannelUiEventHost
5859import com.ichi2.anki.utils.Destination
5960import kotlinx.coroutines.Dispatchers
6061import 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 */
661702data class OptionsMenuState (
662703 val searchIcon : Boolean ,
0 commit comments