Skip to Content
FeaturesGuidance (Tours & Info)In-App Guidance (Tours + Info Popovers)

In-App Guidance — Tours & Info Popovers

Standardized system for showing onboarding tours, per-feature walkthroughs, and single-element info popovers. One API, one overlay, one persistence layer — mounted once in shared code, active on Desktop / Android / iOS / wasmJs.

Overview

The guidance system replaces the ad-hoc walkthrough that previously lived in composeApp/.../desktopMain/.../ui/walkthrough/. Two presentations share one step model and one anchor registry:

  • Tour — full-screen scrim with a rectangular spotlight cutout around the target element, plus a bottom card with Skip / Back / Next / Finish. Used for multi-step product tours like the 7-step main-nav first_run flow.
  • Info — a small Popup anchored next to a single element, no scrim, dismissable via outside-click, Esc, or its close affordance. Used for “what’s this?” hints (OterInfoIcon).

Each Guidance is identified by a string id (first_run, finance_intro, tasks.priority_info, …) and tracked independently in GuidancePreferences. triggerOnce = true (default) guarantees the user sees it at most once; triggerOnce = false lets the same popover be re-opened on demand.

Architecture

The catalog is data; the ViewModel is glue; the overlay is presentation. To add a new tour you only touch the catalog and (optionally) the strings file.

How it works — deep dive

This section unpacks what actually happens on each interaction, in the order the composition sees them.

1. Mounting — one seam, every platform

The whole system is wired once, inside SharedAppNavHost (commonMain). Every platform’s app entry — desktop App.kt, Android MainActivity.kt, iOS IOSApp.kt, wasmJs App.kt — calls into this shared nav host, so mounting happens automatically:

// shared/.../navigation/AppNavHost.kt @Composable fun SharedAppNavHost(…) { val guidanceViewModel: GuidanceViewModel = koinInject() val guidanceState by guidanceViewModel.state.collectAsState() val guidanceAnchors = remember { GuidanceAnchors() } CompositionLocalProvider( LocalGuidanceAnchors provides guidanceAnchors, LocalGuidanceController provides guidanceViewModel, ) { Box(Modifier.fillMaxSize()) { if (isAuthRoute) navContent(Modifier) else shell(navController, navContent) GuidanceOverlay( state = guidanceState, onNext = guidanceViewModel::next, ) } } }

Two lessons this shape captures:

  • The overlay lives above the nav content, so its Box order matters — content first, overlay last, so the scrim and spotlight card sit on top.
  • The anchor registry is a remember { … } at the nav host, so it survives every nav transition but rebuilds on config changes (locale swap uses key(appLanguageTag) around the theme, which nukes and recomposes the tree — the anchor map goes with it, no stale rects).

Koin bindings for the VM + preferences are provided per-platform (desktop Modules.kt, AndroidAppModule.kt, IOSModules.kt, WebModules.kt). All use single { } so koinInject() in commonMain resolves the same instance regardless of platform.

2. Anchor lifecycle — the write path

When a feature screen composes, its call to Modifier.guidanceAnchor("tasks.filters") (shared/.../guidance/Modifier.guidanceAnchor.kt) does two things:

fun Modifier.guidanceAnchor(id: String): Modifier = composed { val anchors = LocalGuidanceAnchors.current DisposableEffect(id, anchors) { onDispose { anchors.unregister(id) } } onGloballyPositioned { coords -> anchors.register(id, coords.boundsInRoot()) } }
  • onGloballyPositioned fires on every layout pass with the composable’s window-relative Rect.
  • GuidanceAnchors.register is diff-only — if the new rect equals the stored one, no write, no state notification, no recomposition. This is why scrolling a list doesn’t churn the overlay.
  • DisposableEffect(id, anchors) runs its onDispose when the composable leaves the composition (screen pop, view-mode switch, tab flip). The rect is removed from the map so the next tour step that targets that anchor gets a clean null fallback instead of a stale ghost rect.

Because GuidanceAnchors is backed by a SnapshotStateMap, writes are observed by Compose — GuidanceOverlay recomposes automatically when the current step’s rect arrives.

3. Trigger flow — startIfUnseen and its guard

startIfUnseen(id) is the auto-trigger every feature screen uses:

// shared/.../ui/viewmodels/GuidanceViewModel.kt override fun startIfUnseen(guidanceId: String) { viewModelScope.launch { if (_state.value.active != null) return@launch // guard (2026-07) if (preferences.hasSeen(guidanceId)) return@launch val guidance = GuidanceCatalog.find(guidanceId) ?: return@launch startGuidance(guidance) } }

Three checks, in order:

  1. Active-guidance guard — if any guidance is already running, silently no-op. Without this, a feature screen’s LaunchedEffect(Unit) { startIfUnseen("tasks_intro") } would fire while first_run was still on step 4 and hijack it. start(id) (manual re-launch) has no such guard on purpose — the user explicitly asked for it.
  2. Seen checkGuidancePreferences.hasSeen reads the DataStore/localStorage set. This is also where the legacy walkthrough_completed migration runs on first call.
  3. Catalog lookup — unknown ids silently no-op so renaming a guidance mid-rollout doesn’t crash callers.

start(id) (manual) skips the first two checks and always starts. Both paths call startGuidance(guidance) which resets _state.value = GuidanceState(active = guidance, currentStepIndex = 0).

4. Overlay rendering — spotlight math + fallbacks

GuidanceOverlay (shared/.../ui/guidance/GuidanceOverlay.kt) reads:

  • state.active → the running Guidance (or null — render nothing).
  • state.currentStep → the step to display.
  • LocalGuidanceAnchors.current.boundsFor(step.targetAnchorId) → the target Rect, or null.

For Tour mode:

  • Draws a full-screen scrim in a Canvas.
  • If anchorBounds != null, punches a rounded rectangle “hole” in the scrim by drawing the bounds rect with BlendMode.Clear. This is the spotlight cutout — the anchored element shows through untouched.
  • Draws the step card (Skip / Back / Next / Finish) at the bottom, offset above the spotlight if it would collide.
  • If anchorBounds == null (anchor not mounted, or hasn’t laid out yet), renders scrim-only. No flicker, no jitter — the tour is honest about not knowing where to point.

For Info mode:

  • Renders a Compose Popup positioned via a PopupPositionProvider that honors the step’s Placement (BottomCenter / Above / Below / Left / Right of anchor).
  • Fallback: if anchorBounds == null, top-centered “toast” position.

5. Auto-trigger placement — where to put LaunchedEffect

Every feature intro follows the same shape at the top of its VM-wrapper composable:

@Composable fun MyFeatureScreen(viewModel: MyFeatureViewModel, …) { val guidance = LocalGuidanceController.current LaunchedEffect(Unit) { viewModel.loadData() guidance.startIfUnseen(GuidanceIds.MY_FEATURE_INTRO) } }

Rules of thumb:

  • Use LaunchedEffect(Unit), not LaunchedEffect(someKey). We want first-visit behavior, not “trigger every time X changes.”
  • Grab the controller via LocalGuidanceController.current, not koinInject<GuidanceViewModel>(). The local resolves to a NoOpGuidanceController in previews/tests where no ViewModel is in scope, so composables stay safe.
  • Place the call at the outer VM-wrapper, not deep inside a lazy-list item. Deeply-nested LaunchedEffects trigger every scroll and get cancelled on scroll-out.

6. Anchor id conventions

  • Route-level ids — one per feature landing screen, matching the nav route string: "dashboard", "tasks", "habits", etc. These are also the step 1 anchor of each feature intro tour.
  • Sub-anchors<feature>.<element>: "tasks.filters", "habits.add_button", "finance.budgets_tab". Keeps the flat namespace scannable and matches the file-org intuition of “which feature owns this.”
  • Never reuse an id across feature screens. Anchor registry is a global Map<String, Rect> — if two screens both register "list", whichever mounts last wins and the tour points at the wrong place.

7. Cross-platform gotchas

  • Android rotation / config change — the remember { GuidanceAnchors() } is scoped to the composition. Compose retains state across config changes automatically, so anchor identities are preserved but the individual rects re-emit from onGloballyPositioned after re-layout. The active guidance survives via GuidanceViewModel (a single in Koin, not tied to activity lifetime).
  • iOS Popup safe area — Info popovers use PopupPositionProvider that clamps positions inside the window. If a popover appears clipped against the notch, verify the anchor’s bounds include padding above the safe area.
  • wasmJs localStorage — synchronous underneath, but the expect class API is suspend for symmetry. Migrations on wasm are no-ops (no legacy key existed there).
  • Desktop dev preview key — deleting ~/.config/OterDesktop/file.preferences_pb resets every preference (theme, locale, seen-ids). To reset only guidance in the field, add a Reset guidance affordance that calls preferences.markSeen(id) for the empty set — not yet built.

Authoring a New Tour

// 1. Add string keys to shared/commonMain/composeResources/values/strings.xml (and values-es/). // <string name="guidance_my_tour_step1_title">…</string> // <string name="guidance_my_tour_step1_body">…</string> // 2. Register the guidance in GuidanceCatalog.kt. private val myFeatureTour = Guidance( id = "my_feature_intro", mode = GuidanceMode.Tour, steps = listOf( GuidanceStep( id = "step1", titleRes = Res.string.guidance_my_tour_step1_title, bodyRes = Res.string.guidance_my_tour_step1_body, targetAnchorId = "my_feature.header", ), // … ), routeForAnchor = mapOf("my_feature.header" to "my_feature"), )
// 3. Anchor the target element in the screen (any depth). Box(modifier = Modifier.guidanceAnchor("my_feature.header")) { MyFeatureHeader(…) }
// 4. Trigger it. Either auto on first visit: val guidance = koinInject<GuidanceViewModel>() LaunchedEffect(Unit) { guidance.startIfUnseen("my_feature_intro") } // or from a button: TextButton(onClick = { guidance.start("my_feature_intro") }) { Text("Take the tour") }

Authoring a New Info Popover

// 1. Add the strings (title + body). // 2. Register in the catalog with mode = GuidanceMode.Info. private val myInfo = Guidance( id = "my_feature.spend_chart_info", mode = GuidanceMode.Info, steps = listOf( GuidanceStep( id = "info", titleRes = Res.string.guidance_my_info_title, bodyRes = Res.string.guidance_my_info_body, targetAnchorId = "my_feature.spend_chart", placement = Placement.BelowAnchor, ), ), triggerOnce = false, // info popovers are usually re-openable ) // 3. Drop the icon anywhere in the UI; it self-registers as its own anchor. OterInfoIcon( guidanceId = "my_feature.spend_chart_info", anchorId = "my_feature.spend_chart", )

Anchor Lifecycle

Modifier.guidanceAnchor(id) publishes the composable’s boundsInRoot() rect into GuidanceAnchors (a SnapshotStateMap<String, Rect>) and registers a DisposableEffect that removes the entry when the composable leaves the composition. Two guarantees:

  • No stale rects after a screen pops or a locale change recomposes the tree.
  • No write storms: the registry rejects writes that don’t differ from the stored rect, so anchor updates don’t fire on every scroll frame.

The overlay reads anchors.boundsFor(currentStep.targetAnchorId). If the rect is null (anchor not yet measured, or off-screen), Tour renders a scrim-only overlay (no flicker), and Info falls back to a centered top-of-window position.

Auto-Navigation

When a tour step’s targetAnchorId belongs to a screen the user isn’t currently on, AppNavHost’s LaunchedEffect(guidanceState.active?.id, currentStepIndex) looks the id up in active.routeForAnchor and calls navController.navigate(route) { launchSingleTop = true }. The next recomposition resolves the rect from the now-mounted anchor and draws the spotlight.

Keeping the route map at the Guidance level (not on each step) lets the same anchor id move between routes without rewriting steps.

Persistence

GuidancePreferences mirrors the existing OnboardingPreferences pattern: an expect class with DataStore-backed actuals on JVM/Android/iOS and a localStorage-backed actual on wasmJs.

expect class GuidancePreferences { val seenIds: Flow<Set<String>> suspend fun markSeen(id: String) suspend fun hasSeen(id: String): Boolean }
  • Storage keys (JVM/Android/iOS): guidance_seen_ids (stringSetPreferencesKey) and guidance_migrated_v1 (booleanPreferencesKey).
  • Migration: on the first hasSeen(…) call, if legacy walkthrough_completed = true and guidance_migrated_v1 is not yet set, "first_run" is added to seenIds and the v1 flag is written. The legacy key is never written again — there is one source of truth after migration.

GuidanceViewModel.finish() calls markSeen(activeGuidanceId) only when the guidance has triggerOnce = true. Info popovers (triggerOnce = false) leave no trace.

Key Files

FilePurpose
shared/.../oter/guidance/Guidance.ktGuidance, GuidanceStep, GuidanceMode, Placement
shared/.../oter/guidance/GuidanceState.ktActive tour/info session state (current step, helpers)
shared/.../oter/guidance/GuidanceAnchors.ktAnchor-id → Rect registry, diff-only writes
shared/.../oter/guidance/Modifier.guidanceAnchor.ktModifier that registers/unregisters anchor bounds
shared/.../oter/guidance/GuidanceController.ktstart / startIfUnseen / dismiss interface + LocalGuidanceController
shared/.../oter/guidance/GuidanceCatalog.ktIn-code registry of named guidances (firstRunTour, etc.)
shared/.../oter/ui/guidance/GuidanceOverlay.ktTour scrim + spotlight + card; Info Popup + position provider
shared/.../oter/ui/guidance/OterInfoIcon.ktDrop-in info-icon button (self-anchoring)
shared/commonMain/.../preferences/GuidancePreferences.ktexpect class for seen-id persistence
shared/nonJsMain/.../preferences/GuidancePreferences.ktDataStore-backed actual + legacy migration
shared/wasmJsMain/.../preferences/GuidancePreferences.wasmJs.ktlocalStorage-backed actual
shared/src/commonMain/kotlin/ui/viewmodels/GuidanceViewModel.ktImplements GuidanceController, owns GuidanceState (moved to shared in 2026-07 as part of the mobile-parity work).
shared/.../navigation/AppNavHost.ktProvides LocalGuidanceAnchors, LocalGuidanceController, renders GuidanceOverlay; auto-starts first_run after auth. Every platform (Desktop/Android/iOS) now inherits this wiring for free.
composeApp/desktopMain/.../di/Modules.ktKoin singletons for GuidancePreferences + GuidanceViewModel
shared/commonMain/composeResources/values/strings.xmlguidance_* action + content strings (also values-es/)

Testing

  • Unit (recommended): GuidanceViewModel.start → next → next → … → finish ends with the guidance id in GuidancePreferences.seenIds. Skip writes the id too.
  • Unit: GuidanceViewModel.startIfUnseen("first_run") no-ops when the id is already in seenIds.
  • Unit: GuidancePreferences migration — pre-seed legacy walkthrough_completed = true, observe seenIds after one hasSeen call, assert "first_run" is present and guidance_migrated_v1 = true.
  • Manual: delete ~/.config/OterDesktop/file.preferences_pb, run the desktop app, log in — the first-run tour should auto-start and walk Dashboard → Tasks → Habits → Finance → Timers → Chat. Re-launch — it does not re-trigger.
  • Manual: click the OterInfoIcon on TasksScreen — small popover appears below the icon, dismisses on outside-click, Esc, and the close button.
  • Manual (locale change mid-tour): open first_run, switch language in Settings — key(appLanguageTag) rebuilds the tree, the anchor registry is rebuilt, no stale spotlight rect remains.

Troubleshooting

Tour appears but with no spotlight (scrim-only)

  • The anchor for that step isn’t mounted. Check the screen owning targetAnchorId is mapped in routeForAnchor so auto-nav can switch to it.
  • Confirm the anchor element actually uses Modifier.guidanceAnchor(id) with the same id string.

Info popover renders in the top-center instead of next to the element

  • anchorBounds is null — the anchor either isn’t mounted or hasn’t laid out yet on the first frame. Triggering from a click on an OterInfoIcon avoids this (the icon itself is the anchor); for external triggers, ensure the anchor was visible before start(id) is called.

first_run keeps appearing on every launch

  • GuidancePreferences.markSeen was not called — usually because dismiss() was invoked directly instead of going through finish() (only finish() persists). Verify Skip / Finish button paths call skip() / next() from the last step.
  • For a fresh slate, delete the DataStore file: rm ~/.config/OterDesktop/file.preferences_pb.

Legacy migration didn’t apply

  • GuidancePreferences.runMigrationOnce runs inside hasSeen(...). If your code only ever calls start(id) (which bypasses hasSeen), the migration is deferred until the first startIfUnseen or hasSeen call. AppNavHost.kt calls startIfUnseen(FIRST_RUN) on auth, which is the trigger.
  • Onboarding — separate from guidance; gates the first-launch wizard via OnboardingPreferences, not the guidance seen-set.
  • Notifications — guidance is local-only; there is no server-driven “show this tour to user X” yet.

Registered guidances (Slice A, 2026-07)

Alongside first_run, finance_intro, and tasks.priority_info, the Plan-cluster feature intros were added — a 3-step Tour per feature, auto-triggered via LaunchedEffect { startIfUnseen(...) } at the top of each screen’s VM-wrapper composable. Step 1 spotlights the whole screen (overview); steps 2–3 spotlight distinct sub-components / sub-features:

IdFeature screenStep anchors (in order)
dashboard_introDashboardScreen.ktdashboarddashboard.up_nextdashboard.feature_cards
tasks_introTasksScreen.kttaskstasks.filterstasks.list
habits_introHabitsScreen.kthabitshabits.sectionshabits.add_button
projects_introProjectsScreen.ktprojectsprojects.heroprojects.add_button
timers_introTimersScreen.kttimerstimers.segmentstimers.add_button

startIfUnseen gained a guard that skips when another guidance is already active — this keeps first_run from being hijacked by a per-feature auto-trigger and vice-versa. start(id) (manual re-launch) is unaffected.

Slices B–D (Life / Money / Hub) follow the same shape: add anchor id + strings + catalog entry, drop a LaunchedEffect { startIfUnseen(id) } at the top of the feature screen, and wrap the screen root in Modifier.guidanceAnchor(id).

Slice A file map

Concrete pointers to the code paths that landed for the Plan-cluster tours:

  • shared/.../guidance/GuidanceCatalog.kt — five <feature>IntroTour values + GuidanceIds.*_INTRO constants + 10 new GuidanceAnchorIds.* sub-anchor constants.
  • shared/.../ui/screens/DashboardScreen.kt — outer Box(Modifier.fillMaxSize().guidanceAnchor(DASHBOARD)) at the VM-wrapper; sub-anchors on the UpNextCard item (DASHBOARD_UP_NEXT) and the first bento row (DASHBOARD_FEATURE_CARDS, using itemsIndexed to pick row 0).
  • shared/.../ui/screens/TasksScreen.kt — the shared inner screen takes a Modifier; the VM-wrapper passes Modifier.fillMaxSize().guidanceAnchor(TASKS). Sub-anchors chained onto RangeChipsRow’s modifier (TASKS_FILTERS) and the main LazyVerticalGrid (TASKS_LIST).
  • shared/.../ui/screens/HabitsScreen.kt — same modifier-threading pattern for HABITS; sub-anchors on the sections LazyColumn (HABITS_SECTIONS) and the FAB Box (HABITS_ADD_BUTTON).
  • shared/.../ui/screens/ProjectsScreen.kt — outer Box wrap (PROJECTS); Box wraps around MobileHero (PROJECTS_HERO) and the FAB inside floatingActionButton (PROJECTS_ADD_BUTTON).
  • shared/.../ui/screens/TimersScreen.kt — outer Box wrap (TIMERS); TimersSegmentedControl modifier chained (TIMERS_SEGMENTS); FAB Box (TIMERS_ADD_BUTTON).
  • shared/src/commonMain/composeResources/values/strings.xml + values-es/strings.xml — three guidance_<feature>_intro_* string pairs per feature.
  • shared/src/commonTest/kotlin/.../GuidanceCatalogTest.kt — asserts every intro is a Tour, has 3 steps, and every anchor has a matching routeForAnchor entry.