App Platform Presenters
CommunityvRallev
Build and test App Platform ComposePresenters. Use when changing models, state, parent and child presenters, template selection or delegation, tests, or Gradle and host setup.
Overview
- Repository
- vRallev/app-platform
- Publisher
- vRallev
- Stars
- 331
- Forks
- 32
- Bundled files
- 1
- License
- Apache-2.0
- Updated
- Nov 14, 2026
Installation
tmp=$(mktemp -d)
git clone --depth 1 https://github.com/vRallev/app-platform.git "$tmp"
mkdir -p ~/.claude/skills
cp -R "$tmp/skills/app-platform-presenters" ~/.claude/skills/app-platform-presenters
rm -rf "$tmp"tmp=$(mktemp -d)
git clone --depth 1 https://github.com/vRallev/app-platform.git "$tmp"
mkdir -p ~/.codex/skills
cp -R "$tmp/skills/app-platform-presenters" ~/.codex/skills/app-platform-presenters
rm -rf "$tmp"Install the App Platform Presenters agent skill from https://github.com/vRallev/app-platform/tree/main/skills/app-platform-presenters into this project's skills folder.Instructions
App Platform Compose Presenters
ComposePresenter uses the Compose runtime to turn inputs and injected data into a BaseModel. The UI or a parent presenter reads the model and sends events through its callbacks. Compose UI is optional.
Before editing, identify the inputs, model, events, how long state must last, and who starts and stops the root. Follow the project's module, DI, and build choices. Check its App Platform version before using ExperimentalAppPlatform APIs. Examples omit imports; resolve unfamiliar APIs from existing code or matching public docs.
Models
Implement
ComposePresenter<InputT, ModelT>withModelT : BaseModel. Include all state and callbacks the consumer needs.Expose immutable model snapshots, preferably data classes or sealed types. Use
valproperties and immutable values, and return a new model when state changes. Keep equality and public types stable to satisfyBaseModel.Prefer presenter interfaces with implementations supplied through DI. Put shared contracts and models in
:public, implementations in:impl, and assemble them in the app. Keep implementations used by generated graphs across modules public.Nest
Modelunder its presenter. Putpresent()first, then helpers, the companion object, and nested types, withModellast.When consumers need only some fields, define a partial
Model : BaseModelinterface in:public. An immutable model in:implimplements it and adds fields used only by the implementation. Use a concrete public model when consumers need all fields.// :public interface SearchPresenter : ComposePresenter<String, SearchPresenter.Model> { interface Model : BaseModel { val query: String } } // :impl class SearchPresenterImpl : SearchPresenter { @Composable override fun present(input: String): Model { return Model(query = input, canSearch = input.isNotBlank()) } data class Model( override val query: String, val canSearch: Boolean, ) : SearchPresenter.Model }Parents using
SearchPresentercan readquery; its renderer in:implcan also readcanSearch.When consumers only pass a model along, hide its type with a generic interface:
interface CatalogPresenter<ModelT : BaseModel> : ComposePresenter<Unit, ModelT>Consumers can use
CatalogPresenter<*>. Parents that return different child models can useBaseModel.
Inputs
Use constructor or assisted-factory arguments for fixed values, such as an item ID. Use InputT for changing values, such as a filter, and always read the latest input. Use Unit when there is no input.
Keep callbacks on the model, not in inputs. Children report results through their models; parents decide what those results mean.
State and effects
Keep mutable state, caches, scopes, and effects inside present(). Presenter fields hold dependencies and fixed arguments. Presenters must not be singletons.
Use remember for local state and Compose runtime APIs such as collectAsState or produceState for changing data:
interface CounterPresenter : ComposePresenter<Unit, CounterPresenter.Model> {
data class Model(
val count: Int,
val onIncrement: () -> Unit,
) : BaseModel
}
class CounterPresenterImpl : CounterPresenter {
@Composable
override fun present(input: Unit): CounterPresenter.Model {
var count by remember { mutableIntStateOf(0) }
return CounterPresenter.Model(count = count, onIncrement = { count++ })
}
}Use LaunchedEffect to start work, DisposableEffect to clean up listeners, and rememberCoroutineScope for work from callbacks. Choose keys that restart work when needed. Don't start side effects directly in present(), which can run many times.
Parent and child presenters
Call children with present(input). Parents can select or combine their models to drive navigation. Create optional children through a factory inside the active branch and remember the instance. Call present() each time; do not remember its model. Children used on every call can be injected directly.
To reset state for a new item, key both child creation and its present() call. Creating a new instance alone does not reset the composition:
return key(input.itemId) {
val child = remember { itemPresenterFactory.create(input.itemId) }
child.present(Unit)
}Use androidx.compose.runtime.key. For lists, key children by stable item IDs. To keep a child's lifetime, pass changing input and key only the state or effects that need to reset.
Use presentDetached() only for costly child presenters. It can briefly pair new parent input with an old child model and does not inherit custom composition locals. Call present() directly when updates must agree or locals must be shared.
Backstack navigation
For push and pop navigation, use experimental presenterBackstack(initialPresenter) { models -> ... }. Wrap its models in an app-specific PresenterBackstackModel with onBack = { pop() }. The helper provides LocalBackstackScope; children read LocalBackstackScope.requireNotNull() and call push(), pop(), or replaceTop() from model callbacks. The stack holds presenter instances, keeps every entry in composition, and ignores a pop at the root.
Use PresenterBackstackRenderer for Navigation 3 UI. Set appPlatform { enableComposePresenterBackstack(true) } to add the module and enable Compose presenters and Compose UI. See the backstack guide for rendering and tests with a fake scope.
Template selection
Use a Template for app-level layout and shell choices. Use a normal parent model for local component layout. A template is the app-specific root model, and its variants name semantic slots instead of holding an untyped list:
sealed interface AppTemplate : Template {
data class FullScreen(val content: BaseModel) : AppTemplate
data class ListDetail(
val list: BaseModel,
val detail: BaseModel,
) : AppTemplate
}Wrap the root presenter in a template presenter. toTemplate follows ModelDelegate chains. It uses an AppTemplate returned by a child, or applies the default to the last delegated model:
class AppTemplatePresenter(
private val rootPresenter: ComposePresenter<Unit, *>,
) : ComposePresenter<Unit, AppTemplate> {
@Composable
override fun present(input: Unit): AppTemplate {
return rootPresenter.present(Unit).toTemplate<AppTemplate> {
AppTemplate.FullScreen(it)
}
}
}Keep app-wide composition locals or presenter services around rootPresenter.present(Unit) at this boundary.
An app-owned presenter can choose another layout without needing its own renderer:
data class Model(
val list: BaseModel,
val detail: BaseModel,
) : BaseModel, ModelDelegate {
override fun delegate(): BaseModel = AppTemplate.ListDetail(list, detail)
}Delegation may pass through navigation models, but it must end. Keep reusable feature libraries independent of app template types; let an app-owned presenter choose their slots. Put a shared template contract in :public when app-owned presenters need it, and put the root wrapper in :impl or app assembly. Follow the root lifecycle guidance below when hosting the wrapper.
Use the app-platform-renderers skill for template layout, child rendering, registration, factories, and platform hosts.
Platform code and UI
Keep Android, Compose UI, ViewModel, navigation controllers, and resource IDs out of shared presenter APIs. Put platform work behind injected interfaces.
Add a renderer only when a model needs direct visual output. Renderers handle localized text, layout, animations, and model callbacks. Presenters handle app state, navigation, and template selection. Register visible models through the app's renderer setup and check that the real app graph can resolve them.
Follow the app's existing effect design. If the root creates a dispatcher, remember it there and share it with children. Handle platform effects in the host. Helpers such as withCompositionLocal can provide a value while returning a model.
Add a screen
- Define the presenter interface, inputs, immutable model, and callbacks.
- Implement
present()and wire its dependencies through DI. - Add and register a renderer if the model has visible output.
- Connect it to a parent presenter or wrap it in the app's template.
- Test presenter behavior with fakes; add UI tests for layout and event wiring when needed.
Start and stop presenters
In a Compose host, remember the root and call present() directly if the host's composition should own its state. Otherwise, let a host own the scope and its cleanup:
class PresenterHost<ModelT : BaseModel>(
composePresenterScopeFactory: ComposePresenterScopeFactory,
rootPresenter: ComposePresenter<Unit, ModelT>,
) {
private val composePresenterScope = composePresenterScopeFactory.createComposePresenterScope()
val models = composePresenterScope.launchComposePresenter(rootPresenter, Unit).model
fun close() {
composePresenterScope.cancel()
}
}The factory applies platform defaults. Launching returns Presenter<ModelT>, with a StateFlow at .model. Pass a StateFlow input when the host needs to change it.
Call the host's close() from its owner's cleanup, such as ViewModel.onCleared() or window cleanup. Stopping collection does not stop the presenters. Canceling a ComposePresenterScope also cancels the wrapped coroutine scope, so use a scope the host owns.
Keep state after a child leaves
When a child stops being called, its ordinary remembered state and effects end. Keeping the presenter instance does not preserve its composition.
Use
withLocalRetainedValuesStore()andretainto keep values in memory. Keep one managed store per child outside the active branch. Stores outlive inactive children and end with their owner. They do not keep effects running or survive root or process recreation.This parent has two destinations and injected child factories:
@OptIn(ExperimentalAppPlatform::class) @Composable override fun present(input: Destination): BaseModel { val firstStore = key(Destination.First) { retainManagedRetainedValuesStore() } val secondStore = key(Destination.Second) { retainManagedRetainedValuesStore() } return when (input) { Destination.First -> withLocalRetainedValuesStore(firstStore) { val child = remember { createFirstPresenter() } child.present(Unit) } Destination.Second -> withLocalRetainedValuesStore(secondStore) { val child = remember { createSecondPresenter() } child.present(Unit) } } }Inside each child's
present(), using the counter model above:var count by retain { mutableIntStateOf(0) } return CounterPresenter.Model(count = count, onIncrement = { count++ })Use
ReturningSaveableStateHolderandrememberSaveablefor saveable values. Keep the holder in the parent. Use a different stable, saveable key for each child and remove saved state when a child is gone for good. Restoring after root or process recreation also needs a host that saves and restores the parent state registry.@OptIn(ExperimentalAppPlatform::class) @Composable override fun present(input: Destination): BaseModel { val holder = rememberReturningSaveableStateHolder() return when (input) { Destination.First -> holder.SaveableStateProvider(key = "first") { val child = remember { createFirstPresenter() } child.present(Unit) } Destination.Second -> holder.SaveableStateProvider(key = "second") { val child = remember { createSecondPresenter() } child.present(Unit) } } }Inside each child's
present():var count by rememberSaveable { mutableIntStateOf(0) } return CounterPresenter.Model(count = count, onIncrement = { count++ })
For shared state or state that outlives the parent, inject an owner that lives long enough. Use persistent storage for data that must survive closing and reopening the app; a StateFlow alone does not save data.
Build setup
Configure each module that defines, calls, or starts presenters, using the project's existing plugin version and conventions. A dependency's setup does not configure its consumers:
plugins {
id("software.ralf.app.platform")
}
appPlatform {
enableComposePresenters(true)
}This adds the Compose compiler, runtime, Molecule, presenter API, and test helpers. Don't repeat those dependencies. Enable Compose UI separately when needed.
Bind presenter interfaces to implementations through the app's existing DI setup. For Metro, use enableMetro(true).
In modules that build the app, addImplModuleDependencies(true) supplies defaults such as ComposePresenterScopeFactory. Keep :impl dependencies out of feature API modules and check that the app's DI graph builds.
The presenter option does not set Android's isReturnDefaultValues. Configure it in shared test setup if needed for Android stubs. Use Robolectric only for tests of real Android behavior.
Tests
Prefer commonTest, fakes, and fast, deterministic dependencies. Call runTest directly and pass its scope to presenter.test:
class CounterPresenterTest {
@Test
fun incrementUpdatesTheModel() = runTest {
CounterPresenterImpl().test(this) {
val initial = awaitItem()
assertEquals(0, initial.count)
initial.onIncrement()
assertEquals(1, awaitItem().count)
}
}
}The helper uses the test's background scope. For inputs, pass presenter.test(this, input = value) or an input StateFlow.
- Use Turbine's
awaitItemandexpectNoEvents. Drive model callbacks, input flows, and fakes; test behavior instead of private methods. - Test parents with fake children. Check child inputs, chosen models, state changes, and effects.
- Test the default template wrapper, each
ModelDelegateoverride, and state changes that select another template variant. These can be headless presenter tests. - Check state across recomposition, cleanup when children leave, and restored state when they return. Test the real host's save/restore path before claiming support for recreation.
- Use virtual time instead of sleeps. Model emissions can skip intermediate states.
Run focused tests. Compile affected targets and check host and DI setup when dependencies or host code change. Add UI tests only for behavior presenter tests cannot cover. Follow the project's formatting and API checks, and update docs when behavior changes.
Public docs: presenters, setup, modules, DI, renderers, templates, and testing.
Bundled files
FAQ
What does App Platform Presenters do?
Build and test App Platform ComposePresenters. Use when changing models, state, parent and child presenters, template selection or delegation, tests, or Gradle and host setup.
Who made App Platform Presenters?
vRallev (a community member) publishes it in the vRallev/app-platform repository on GitHub.
Is App Platform Presenters free?
Yes. App Platform Presenters is open source, published by vRallev under the Apache-2.0 license.
What comes with App Platform Presenters?
Its instructions and 1 file the agent reads only when a task needs them, such as references, examples or scripts.
Which agents can use App Platform Presenters?
Any coding agent that reads SKILL.md files, including Claude Code, Codex, Cursor, GitHub Copilot and Gemini CLI. The agent loads the skill when a task matches its description.
What are agent skills?
An agent skill is a folder with a SKILL.md file: instructions that teach a coding agent how to do one kind of task, and optionally files it can read while it works. The agent keeps only the skill's name and description in mind and reads the rest when a task calls for it.
How are agent skills different from MCP servers?
An MCP server gives an agent new tools to call, code that runs somewhere and returns results. A skill gives it knowledge: how to approach a task with the tools it already has. Many agents use both.
How can my agent test my Android app?
Robomori lets coding agents create, start and control Android emulators and devices from one command, so they can install your app, take screenshots and check dark mode, font sizes and languages.