Skip to main content

05 - Adapters

Concept​

1. Why​

Presentation is framework-neutral on purpose, but apps run on a framework. A Flutter app's page logic lives in a Riverpod notifier. A Kotlin app's lives in an AndroidX ViewModel, which survives rotation and owns viewModelScope.

An adapter gives the same Event → Action → State + Effect shape inside the framework's own class, so a page gets the framework's lifecycle and Sinew's structure together. Adapters are optional. They contain no logic of their own, only binding and wiring.

2. Shape​

AdapterPlatformBindsHolds
sinew_riverpodFlutterRiverpod NotifierEventActionNotifier and StateEffectNotifier mixins, hooks, AsyncValue ↔ ViewState conversions. Depends on sinew_presentation and sinew_models.
sinew_riverpod_providersFlutterRiverpod providersready providers for Sinew services. Depends on network, storage and security.
sinew-viewmodelKotlinAndroidX ViewModel (multiplatform lifecycle)EventActionViewModel and StateEffectViewModel base classes, the ObserveEffects composable
(no Koin adapter)KotlinKoindropped in review: the ~10 lines of Koin wiring are an example in the Kotlin Architecture note

Riverpod​

A Riverpod notifier already extends Riverpod's own base class, so the adapter is a mixin that adds the presentation API on top:

class OrderListNotifier extends <Riverpod notifier base> with EventActionNotifier<OrderListState, OrderListEvent, OrderListAction, OrderListEffect> {
build() = OrderListState() // no side effects; loading starts from the Opened event
mapEventToAction(event) = … // as in Presentation
onAction(action) = …
}
The mixin providesImplemented with
onEvent(event)mapEventToAction, then onAction
setState(reduce)state = reduce(state)
effects, emitEffect(effect)an EffectEmitter, created on first use and disposed through the notifier's own dispose hook

Hooks (flutter_hooks) for the page:

HookDoes
useEffects(effects, onEffect)subscribes once, and cancels when the page leaves. onEffect gets a mounted context.
useOnOpened(onEvent, event)sends the opening event once, after the first frame, because Riverpod forbids changing a provider while the tree builds
useNetworkChecker()rebuilds when connectivity changes (in sinew_riverpod_providers, because it needs the network checker)

Conversions with Riverpod's AsyncValue. ViewState is modelled on it, so an AsyncNotifier page and a Sinew page can live side by side during a migration:

AsyncValue<T>ViewState<T>
AsyncLoading, previous value keptLoading(data: previous)
AsyncData(value)Done(data)
AsyncError(error, stackTrace), previous value keptFailed(data: previous, error, stackTrace). An error that isn't an AppException becomes UnexpectedException with layer VM.

The mapping is one-to-one in both directions. ViewState.toAsyncValue() drops Done's backend message, which AsyncValue has no place for.

Ready providers (sinew_riverpod_providers): one per Sinew service, built from a config provider the app overrides once at its root:

ProviderScope(overrides = [ sinewConfigProvider.overrideWithValue(config), tokenSourceProvider.overrideWithValue(myTokenSource) ], child = app)
// then: ref.watch(sinewHttpProvider), ref.watch(networkCheckerProvider), ref.watch(secureStoreProvider), …

AndroidX ViewModel​

A Kotlin class can't extend both ViewModel and Sinew's base, so the adapter offers ViewModel subclasses with the same API, running actions in viewModelScope:

class OrderListViewModel(orders, cancelOrder) : EventActionViewModel<OrderListState, OrderListEvent, OrderListAction, OrderListEffect>(OrderListState()) {
override fun mapEventToAction(event) = …
override suspend fun onAction(action) = …
}

@Composable OrderListRoute(onOrderSelected) {
viewModel = … // the app's DI
state by viewModel.state.collectAsStateWithLifecycle()
LaunchedEffect(Unit) { viewModel.onEvent(Opened) }
ObserveEffects(viewModel.effects) { effect -> … } // collects only while at least STARTED
OrderListScreen(state, viewModel::onEvent, onOrderSelected)
}
The adapter providesImplemented with
state: StateFlow<S>, setStateMutableStateFlow, update
effects: Flow<F>, emitEffecta buffered Channel, the same EffectEmitter
onEventmapEventToAction, then viewModelScope.launch { onAction(action) }
ObserveEffects(effects, onEffect)repeatOnLifecycle(STARTED) on the main dispatcher, so effects aren't handled while the screen is stopped, and buffered ones arrive when it starts again

3. API​

NameSignature (pseudocode)Package
EventActionNotifier<S, E, A, F>mixin on a Riverpod notifier: onEvent, setState, effects, emitEffect; abstract mapEventToAction, onActionsinew_riverpod
StateEffectNotifier<S, F>mixin: setState, effects, emitEffectsinew_riverpod
useEffectsuseEffects<F>(Stream<F> effects, void Function(BuildContext, F) onEffect)sinew_riverpod
useOnOpeneduseOnOpened<E>(void Function(E) onEvent, E event)sinew_riverpod
toViewState, toAsyncValueAsyncValue<T>.toViewState(module, function): ViewState<T>, ViewState<T>.toAsyncValue(): AsyncValue<T>sinew_riverpod
useNetworkCheckeruseNetworkChecker(): NetworkStatussinew_riverpod_providers
providerssinewConfigProvider, tokenSourceProvider (app overrides); sinewHttpProvider, networkCheckerProvider, secureStoreProvider, keyValueStoreProvider, fieldCipherProvidersinew_riverpod_providers
EventActionViewModel<S, E, A, F>abstract class : ViewModel: state, effects, onEvent; protected setState, emitEffect; abstract mapEventToAction, suspend onActionsinew-viewmodel
StateEffectViewModel<S, F>abstract class : ViewModel: state, effects; protected setState, emitEffectsinew-viewmodel
ObserveEffects@Composable ObserveEffects(effects: Flow<F>, onEffect: suspend (F) -> Unit)sinew-viewmodel

4. Build steps​

  1. sinew-viewmodel: StateEffectViewModel, then EventActionViewModel, tested with the lifecycle test helpers. Tests:
    • onEvent runs onAction in viewModelScope;
    • clearing the ViewModel cancels a running action;
    • effects are buffered and delivered once.
  2. ObserveEffects, tested with a Compose UI test: an effect emitted while the screen is stopped is handled once after it starts.
  3. sinew_riverpod mixins, tested with a ProviderContainer. Tests:
    • events in, state out;
    • effects delivered once;
    • disposing the provider disposes the emitter.
  4. toViewState / toAsyncValue, tested for every row of the conversion table, including a non-AppException error.
  5. The hooks, tested in widget tests: useOnOpened sends once across rebuilds, and useEffects cancels on unmount.
  6. The ready providers, tested so that a missing sinewConfigProvider override fails with a clear message naming the provider.

Done when

  • The same order-list logic runs unchanged on the neutral base, the Riverpod mixin and the ViewModel base. Only the class header differs.
  • Rotation (Kotlin) and rebuilds (Flutter) neither re-run the opening load nor replay an effect.
  • No adapter contains page logic.

5. Edge cases​

CaseDecided behavior
The app forgets to override sinewConfigProviderReading any provider that needs it throws immediately, with a message naming the override to add.
A Riverpod notifier is rebuilt (build runs again)build returns the initial state and has no side effects. Loading starts only from the page's opening event, guarded by mapEventToAction.
An effect arrives while the screen is stopped (Kotlin)It stays buffered in the channel. ObserveEffects delivers it when the screen starts again.
Two ObserveEffects on one effect flowNot supported: a Channel delivers each effect to one collector. The rule is one collector per screen.
A Kotlin app without ComposeEventActionViewModel works on its own. ObserveEffects is the only Compose-dependent part, so it sits in the same module but has no effect on non-Compose callers beyond the dependency.
A Flutter app without RiverpodIt uses the neutral bases from sinew_presentation directly.

Implementation​

1. Why​

A Kotlin app's page logic lives in an AndroidX ViewModel, which survives rotation and owns viewModelScope. A class can't extend both ViewModel and Sinew's neutral base, so sinew-viewmodel provides ViewModel subclasses with the same API. It also provides ObserveEffects, which collects effects only while the screen is visible.

2. Shape​

public abstract class StateEffectViewModel<S, F>(initial: S) : ViewModel() {
private val _state = MutableStateFlow(initial)
public val state: StateFlow<S> = _state.asStateFlow()
protected val currentState: S get() = _state.value
private val emitter = EffectEmitter<F>()
public val effects: Flow<F> = emitter.effects
protected fun setState(reduce: S.() -> S) { _state.update(reduce) }
protected fun emitEffect(effect: F) { emitter.emit(effect) }
override fun onCleared() { emitter.dispose() }
}

public abstract class EventActionViewModel<S, E, A, F>(initial: S) : StateEffectViewModel<S, F>(initial) {
public fun onEvent(event: E) {
val action = mapEventToAction(event) ?: return
viewModelScope.launch { onAction(action) }
}
protected abstract fun mapEventToAction(event: E): A?
protected abstract suspend fun onAction(action: A)
}

@Composable
public fun <F> ObserveEffects(effects: Flow<F>, onEffect: suspend (F) -> Unit) {
val owner = LocalLifecycleOwner.current
LaunchedEffect(effects, owner) {
owner.repeatOnLifecycle(Lifecycle.State.STARTED) {
withContext(Dispatchers.Main.immediate) { effects.collect(onEffect) }
}
}
}
RuleWhy
The multiplatform lifecycle artifacts (JetBrains build of AndroidX Lifecycle)The same ViewModel and repeatOnLifecycle in commonMain, for all four targets
ObserveEffects collects on Dispatchers.Main.immediateAn effect is handled in the same frame it's received, so it isn't lost between a lifecycle stop and the collection ending
The route sends Opened from LaunchedEffect(Unit). Paged slices rely on the pager's Initial-once rule; plain slices on the opened flag.A rotation re-sends Opened without reloading

On the screen​

@Composable
internal fun OrderListRoute(onOrderSelected: (String) -> Unit, viewModel: OrderListViewModel = koinViewModel()) {
val state by viewModel.state.collectAsStateWithLifecycle()
val localizer = rememberSinewLocalizer()
val snackbar = LocalSnackbar.current
LaunchedEffect(Unit) { viewModel.onEvent(OrderListEvent.Opened) }
ObserveEffects(viewModel.effects) { effect ->
when (effect) {
is OrderListEffect.Cancelled -> snackbar.show("Order ${effect.number} cancelled")
is OrderListEffect.Failed -> snackbar.showError(effect.error.displayMessage(localizer))
}
}
OrderListScreen(state = state, onEvent = viewModel::onEvent, onOrderSelected = onOrderSelected)
}

3. API​

NameKotlin
StateEffectViewModel<S, F>public abstract class : ViewModel()
EventActionViewModel<S, E, A, F>public abstract class : StateEffectViewModel<S, F>
ObserveEffects@Composable public fun <F> ObserveEffects(effects: Flow<F>, onEffect: suspend (F) -> Unit)

There are no AsyncValue conversions on Kotlin: that's a Riverpod type.

4. Build steps​

  1. Both ViewModel bases, tested with ViewModelStore + TestScope (Dispatchers.setMain). Tests:
    • onEvent runs in viewModelScope;
    • clear() cancels a running action and disposes the emitter.
  2. ObserveEffects, tested with runComposeUiTest and a test lifecycle owner: an effect sent while the owner is CREATED is handled once after it moves to STARTED.

Done when

  • The sample order list runs on all four targets on EventActionViewModel.
  • A rotation on Android neither reloads the list nor replays a snackbar (instrumented test).

5. Edge cases​

CaseDecided behavior
Two ObserveEffects on the same effects flowNot supported: the channel delivers each effect to one collector. One collector per screen.
A non-Compose Android screen (Views)It collects effects with repeatOnLifecycle itself. ObserveEffects is only a convenience.
The view model is shared by two screensEach screen gets state. Only one should collect effects.
Decided by default (revisit during implementation)
  • Duplicated code between EventActionHandler and EventActionViewModel, about 15 lines, rather than a delegate object. It keeps both classes readable, and the shared EffectEmitter holds the subtle part.