Skip to content

Commit 1bb472b

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 The test was updated as it should not have been accessing the flows of the ViewModel Assisted-by: Claude Opus 4.6
1 parent 3da1cab commit 1bb472b

5 files changed

Lines changed: 182 additions & 73 deletions

File tree

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

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ import com.ichi2.anki.deckpicker.EmptyCardsResult
111111
import com.ichi2.anki.deckpicker.OptionsMenuState
112112
import com.ichi2.anki.deckpicker.ShortcutData
113113
import com.ichi2.anki.deckpicker.SyncIconState
114+
import com.ichi2.anki.deckpicker.UiEvent
114115
import com.ichi2.anki.dialogs.AsyncDialogFragment
115116
import com.ichi2.anki.dialogs.BackupPromptDialog
116117
import com.ichi2.anki.dialogs.CreateDeckDialog
@@ -779,15 +780,20 @@ open class DeckPicker :
779780
.show()
780781
}
781782

782-
viewModel.deckDeletedNotification.launchCollectionInLifecycleScope(::onDeckDeleted)
783-
viewModel.emptyCardsNotification.launchCollectionInLifecycleScope(::onCardsEmptied)
784-
viewModel.flowOfDeckCountsChanged.launchCollectionInLifecycleScope(::onDeckCountsChanged)
785-
viewModel.flowOfDestination.launchCollectionInLifecycleScope(::onDestinationChanged)
786-
viewModel.flowOfExportDeck.launchCollectionInLifecycleScope(::onExportDeck)
787-
viewModel.flowOfCreateShortcut.launchCollectionInLifecycleScope(::createIcon)
788-
viewModel.flowOfDisableShortcuts.launchCollectionInLifecycleScope(::disableDeckAndChildrenShortcuts)
789-
viewModel.onError.launchCollectionInLifecycleScope(::onError)
790-
viewModel.flowOfPromptUserToUpdateScheduler.launchCollectionInLifecycleScope(::onPromptUserToUpdateScheduler)
783+
viewModel.uiEvents.launchCollectionInLifecycleScope { event ->
784+
when (event) {
785+
is UiEvent.DeckDeleted -> onDeckDeleted(event.result)
786+
is UiEvent.EmptyCardsDeleted -> onCardsEmptied(event.result)
787+
is UiEvent.Navigate -> onDestinationChanged(event.destination)
788+
is UiEvent.ShowError -> onError(event.message)
789+
is UiEvent.ExportDeck -> onExportDeck(event.deckId)
790+
is UiEvent.CreateShortcut -> createIcon(event.data)
791+
is UiEvent.DisableShortcuts -> disableDeckAndChildrenShortcuts(event.deckIds)
792+
UiEvent.PromptUpdateScheduler -> onPromptUserToUpdateScheduler(Unit)
793+
UiEvent.DecksReloaded -> onDecksReloaded(Unit)
794+
UiEvent.DeckCountsChanged -> onDeckCountsChanged(Unit)
795+
}
796+
}
791797
viewModel.flowOfOptionsMenuState.filterNotNull().launchCollectionInLifecycleScope(::onOptionsMenuUpdated)
792798
viewModel.flowOfStudiedTodayStats.launchCollectionInLifecycleScope(::onStudiedTodayChanged)
793799
viewModel.flowOfDeckListInInitialState.filterNotNull().launchCollectionInLifecycleScope(::onCollectionStatusChanged)
@@ -796,7 +802,6 @@ open class DeckPicker :
796802
viewModel.flowOfDeckList.launchCollectionInLifecycleScope(::onDeckListChanged)
797803
viewModel.flowOfFocusedDeck.launchCollectionInLifecycleScope(::onFocusedDeckChanged)
798804
viewModel.flowOfResizingDividerVisible.launchCollectionInLifecycleScope(::onResizingDividerVisibilityChanged)
799-
viewModel.flowOfDecksReloaded.launchCollectionInLifecycleScope(::onDecksReloaded)
800805
viewModel.flowOfStartupResponse.filterNotNull().launchCollectionInLifecycleScope(::onStartupResponse)
801806
}
802807

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

Lines changed: 84 additions & 43 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
@@ -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 */
698739
data class OptionsMenuState(
699740
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+
}

0 commit comments

Comments
 (0)