@@ -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
@@ -129,22 +130,22 @@ class DeckPickerViewModel :
129130 }.stateIn(viewModelScope, SharingStarted .Eagerly , initialValue = FlattenedDeckList .empty)
130131
131132 /* *
132- * @see deleteDeck
133- * @see DeckDeletionResult
133+ * Single channel of one-shot UI events for [DeckPicker].
134+ *
135+ * Use this for navigation, snackbars, dialogs, error messages, and other
136+ * fire-once events. Do *not* use it for ongoing state — keep that as `StateFlow`.
134137 */
135- val deckDeletedNotification = MutableSharedFlow <DeckDeletionResult >(extraBufferCapacity = 1 )
136- val emptyCardsNotification = MutableSharedFlow <EmptyCardsResult >(extraBufferCapacity = 1 )
137- val flowOfDestination = MutableSharedFlow <Destination >(extraBufferCapacity = 1 )
138- override val onError = MutableSharedFlow <String >(extraBufferCapacity = 1 )
139- val flowOfExportDeck = MutableSharedFlow <DeckId >()
140- val flowOfCreateShortcut = MutableSharedFlow <ShortcutData >()
141- val flowOfDisableShortcuts = MutableSharedFlow <List <String >>()
138+ private val events = ChannelUiEventHost <UiEvent >()
139+ val uiEvents = events.uiEvents
142140
143141 /* *
144- * A notification that the study counts have changed
142+ * Errors emitted via the standard `launchCatchingIO { }` machinery.
143+ *
144+ * This satisfies [OnErrorListener] (which `launchCatchingIO` depends on) and is
145+ * forwarded into [uiEvents] as [UiEvent.ShowError] in [init].
146+ * Prefer collecting [uiEvents] in the UI rather than this flow directly.
145147 */
146- // TODO: most of the recalculation should be moved inside the ViewModel
147- val flowOfDeckCountsChanged = MutableSharedFlow <Unit >(extraBufferCapacity = 1 )
148+ override val onError = MutableSharedFlow <String >(extraBufferCapacity = 1 )
148149
149150 var loadDeckCounts: Job ? = null
150151 private set
@@ -155,8 +156,6 @@ class DeckPickerViewModel :
155156 */
156157 private var schedulerUpgradeDialogShownForVersion: Long? = null
157158
158- val flowOfPromptUserToUpdateScheduler = MutableSharedFlow <Unit >(extraBufferCapacity = 1 )
159-
160159 val flowOfCollectionHasNoCards = MutableStateFlow (true )
161160
162161 val flowOfDeckListInInitialState =
@@ -181,16 +180,13 @@ class DeckPickerViewModel :
181180 ! (isInInitialState == true || hasNoCards)
182181 }
183182
184- // HACK: dismiss a legacy progress bar
185- // TODO: Replace with better progress handling for first load/corrupt collections
186- // This MutableSharedFlow has replay=1 due to a race condition between its collector being started
187- // and a possible early emission that occurs when the user is on a metered network and a dialog has to show up
188- // to ask the user if they want to trigger a sync. Normally, the spinning progress indicator is
189- // dismissed via an emission to this flow after the sync is completed, but if the metered network
190- // warning dialog appears, we should immediately refresh the UI in case the user decides not to sync.
191- // Otherwise, the progress indicator remains indefinitely. This replay=1 ensures that the collector will
192- // receive the dismissal event even if it starts after the emission.
193- val flowOfDecksReloaded = MutableSharedFlow <Unit >(extraBufferCapacity = 1 , replay = 1 )
183+ init {
184+ // Bridge `OnErrorListener.onError` (driven by `launchCatchingIO { }`) into the
185+ // unified side-effects channel so the UI only needs one collector.
186+ viewModelScope.launch {
187+ onError.collect { events.emit(UiEvent .ShowError (it)) }
188+ }
189+ }
194190
195191 // TODO: Use a sensible default rather than null
196192 val flowOfOptionsMenuState = MutableStateFlow <OptionsMenuState ?>(null )
@@ -211,8 +207,10 @@ class DeckPickerViewModel :
211207 // to match and avoid unnecessary scrolls in `renderPage()`.
212208 focusedDeck = Consts .DEFAULT_DECK_ID
213209
214- deckDeletedNotification.emit(
215- DeckDeletionResult (deckName = deckName, cardsDeleted = changes.count),
210+ events.emit(
211+ UiEvent .DeckDeleted (
212+ DeckDeletionResult (deckName = deckName, cardsDeleted = changes.count),
213+ ),
216214 )
217215 }
218216
@@ -251,7 +249,7 @@ class DeckPickerViewModel :
251249 }
252250 }
253251 val result = undoableOp { removeCardsAndOrphanedNotes(toDelete) }
254- emptyCardsNotification .emit(EmptyCardsResult (cardsDeleted = result.count))
252+ events .emit(UiEvent . EmptyCardsDeleted ( EmptyCardsResult (cardsDeleted = result.count) ))
255253 }
256254
257255 // TODO: move withProgress to the ViewModel, so we don't return 'Job'
@@ -260,7 +258,7 @@ class DeckPickerViewModel :
260258 Timber .i(" empty filtered deck %s" , deckId)
261259 withCol { decks.select(deckId) }
262260 undoableOp { sched.emptyFilteredDeck(decks.selected()) }
263- flowOfDeckCountsChanged .emit(Unit )
261+ events .emit(UiEvent . DeckCountsChanged )
264262 }
265263
266264 /* *
@@ -274,7 +272,7 @@ class DeckPickerViewModel :
274272 decks.select(deckId)
275273 sched.rebuildFilteredDeck(decks.selected())
276274 }
277- flowOfDeckCountsChanged .emit(Unit )
275+ events .emit(UiEvent . DeckCountsChanged )
278276 }
279277
280278 /* *
@@ -291,7 +289,7 @@ class DeckPickerViewModel :
291289 fun browseCards (deckId : DeckId ) =
292290 launchCatchingIO {
293291 withCol { decks.select(deckId) }
294- flowOfDestination .emit(BrowserDestination .ToDeck (deckId))
292+ events .emit(UiEvent . Navigate ( BrowserDestination .ToDeck (deckId) ))
295293 }
296294
297295 fun addNote (
@@ -301,13 +299,13 @@ class DeckPickerViewModel :
301299 if (deckId != null && setAsCurrent) {
302300 withCol { decks.select(deckId) }
303301 }
304- flowOfDestination .emit(NoteEditorLauncher .AddNote (deckId))
302+ events .emit(UiEvent . Navigate ( NoteEditorLauncher .AddNote (deckId) ))
305303 }
306304
307305 /* *
308306 * Opens the Manage Note Types screen.
309307 */
310- fun openManageNoteTypes () = launchCatchingIO { flowOfDestination .emit(ManageNoteTypesDestination ()) }
308+ fun openManageNoteTypes () = launchCatchingIO { events .emit(UiEvent . Navigate ( ManageNoteTypesDestination () )) }
311309
312310 /* *
313311 * Opens study options for the provided deck
@@ -321,7 +319,7 @@ class DeckPickerViewModel :
321319 ) = launchCatchingIO {
322320 // open cram options if filtered deck, otherwise open regular options
323321 val filtered = isFiltered ? : withCol { decks.isFiltered(deckId) }
324- flowOfDestination .emit(DeckOptionsDestination (deckId = deckId, isFiltered = filtered))
322+ events .emit(UiEvent . Navigate ( DeckOptionsDestination (deckId = deckId, isFiltered = filtered) ))
325323 }
326324
327325 fun unburyDeck (deckId : DeckId ) =
@@ -331,7 +329,7 @@ class DeckPickerViewModel :
331329
332330 fun scheduleReviewReminders (deckId : DeckId ) =
333331 viewModelScope.launch {
334- flowOfDestination .emit(ScheduleRemindersDestination (deckId))
332+ events .emit(UiEvent . Navigate ( ScheduleRemindersDestination (deckId) ))
335333 }
336334
337335 /* *
@@ -384,7 +382,7 @@ class DeckPickerViewModel :
384382
385383 if (currentSchedulerVersion == 1L && schedulerUpgradeDialogShownForVersion != 1L ) {
386384 schedulerUpgradeDialogShownForVersion = 1L
387- flowOfPromptUserToUpdateScheduler .emit(Unit )
385+ events .emit(UiEvent . PromptUpdateScheduler )
388386 } else {
389387 schedulerUpgradeDialogShownForVersion = currentSchedulerVersion
390388 }
@@ -394,7 +392,7 @@ class DeckPickerViewModel :
394392 focusedDeck = withCol { decks.current().id }
395393 refreshUndoMenuState()
396394
397- flowOfDecksReloaded .emit(Unit )
395+ events .emit(UiEvent . DecksReloaded )
398396 }
399397 this .loadDeckCounts = loadDeckCounts
400398 return loadDeckCounts
@@ -427,7 +425,7 @@ class DeckPickerViewModel :
427425 */
428426 fun exportDeck (deckId : DeckId ) =
429427 launchCatchingIO {
430- flowOfExportDeck .emit(deckId)
428+ events .emit(UiEvent . ExportDeck ( deckId) )
431429 }
432430
433431 /* *
@@ -462,11 +460,13 @@ class DeckPickerViewModel :
462460 fullName,
463461 )
464462 }
465- flowOfCreateShortcut.emit(
466- ShortcutData (
467- deckId = deckId,
468- shortLabel = shortLabel,
469- longLabel = longLabel,
463+ events.emit(
464+ UiEvent .CreateShortcut (
465+ ShortcutData (
466+ deckId = deckId,
467+ shortLabel = shortLabel,
468+ longLabel = longLabel,
469+ ),
470470 ),
471471 )
472472 }
@@ -475,7 +475,7 @@ class DeckPickerViewModel :
475475 fun disableDeckAndChildrenShortcuts (deckId : DeckId ) =
476476 launchCatchingIO {
477477 val deckTreeDids = dueTree?.find(deckId)?.map { it.did.toString() } ? : emptyList()
478- flowOfDisableShortcuts .emit(deckTreeDids)
478+ events .emit(UiEvent . DisableShortcuts ( deckTreeDids) )
479479 }
480480
481481 sealed class StartupResponse {
@@ -694,6 +694,47 @@ enum class SyncIconState {
694694 NotLoggedIn ,
695695}
696696
697+ /* *
698+ * One-shot UI events emitted by [DeckPickerViewModel].
699+ *
700+ * @see com.ichi2.anki.ui.UiEventHost
701+ */
702+ sealed interface UiEvent {
703+ data class DeckDeleted (
704+ val result : DeckDeletionResult ,
705+ ) : UiEvent
706+
707+ data class EmptyCardsDeleted (
708+ val result : EmptyCardsResult ,
709+ ) : UiEvent
710+
711+ data class Navigate (
712+ val destination : Destination ,
713+ ) : UiEvent
714+
715+ data class ShowError (
716+ val message : String ,
717+ ) : UiEvent
718+
719+ data class ExportDeck (
720+ val deckId : DeckId ,
721+ ) : UiEvent
722+
723+ data class CreateShortcut (
724+ val data : ShortcutData ,
725+ ) : UiEvent
726+
727+ data class DisableShortcuts (
728+ val deckIds : List <String >,
729+ ) : UiEvent
730+
731+ data object PromptUpdateScheduler : UiEvent
732+
733+ data object DecksReloaded : UiEvent
734+
735+ data object DeckCountsChanged : UiEvent
736+ }
737+
697738/* * Menu state data for the options menu */
698739data class OptionsMenuState (
699740 val searchIcon : Boolean ,
0 commit comments