Skip to content

architecture

this document describes how the codebase is actually organized. it is the source of truth for current behavior. PROJECT_PLAN.md is the original phased roadmap, kept for historical context only — treat its "phase N" labels as history, not status. for the reasoning behind specific technical choices, see ../adr/.

layers

the app follows a simple layered structure inside lib/:

lib/
  main.dart              # phone + desktop entrypoint
  main_wear.dart          # Wear OS entrypoint
  app.dart                # MaterialApp.router root widget: theme, routing, localization
  core/                   # cross-cutting, feature-agnostic code
    theming/              # AppTheme (Material 3 seed-color builder), design tokens,
                           # theme + ui-preferences providers
    routing/              # go_router configuration (AppRoutes, appRouterProvider),
                           # the navigation shell, MeditationTheme
    utils/                # small pure helpers (durations, file names, sharing, drafts)
    widgets/              # design-system components shared across features
    window/               # desktop window helpers (minimize-to-tray)
  data/                   # everything that talks to storage or platform plugins
    database/             # drift: AppDatabase, tables/, connection/, built-in seed data
    models/               # plain Dart models (e.g. Sound) that decouple features
                           # from drift's generated table classes
    repositories/         # typed, testable wrappers around the database/services
    services/             # wrappers around platform plugins (audio, tts, health,
                           # notifications, sync) - always behind an interface so
                           # features can be tested without the real plugin
  features/               # one folder per user-facing feature; each feature owns
                           # its screens, its riverpod providers, and any
                           # feature-specific widgets
  l10n/
    app_en.arb            # source-of-truth english strings (lowercase, see below)
    generated/            # generated by `flutter gen-l10n` - never hand-edited
test/                     # mirrors lib/

there is intentionally no web target. integration_test/ exists but is not part of the flutter test suite: those tests run the real audio stack (engine, audio_session, focus events) on a real android runtime, driven by the android-integration ci job on release tags or by hand. they print KYB_IT:* markers that tool/run_integration_tests.sh turns into injected os events (a gsm call, screen off/on) — never run the file bare, run the script. see ci-and-packaging.md for the job.

why this shape

  • data/services vs data/repositories: services wrap a platform plugin or external system directly (e.g. AudioService wraps flutter_soloud and audio_session, HealthService wraps the health package). player screens use wakelock_plus for the keep on ScreenBehavior and audio_service for the Android background-playback media notification (SessionForegroundService publishes a media session state, so the notification renders with media controls — play/pause, stop, and segment skip for multi-segment meditations — plus the current segment in the subtitle line and, on Android 13+, a progress bar that the system animates from the published position and playback speed). when the session ends — naturally or on stop — the service shuts the media session down and asks the activity to drop the service's foreground state over a small method channel: audio_service only does that in onDestroy, which never runs because media browsers keep the service bound, and a notification attached to a foreground service cannot be cancelled any other way. a delayed second dismiss sweeps any record a queued notification rebuild reposts in the meantime, keeping a zombie card with dead controls from lingering. a CountdownOverlay is shown before playback when countdownSeconds > 0, and SessionVisualizer is conditionally rendered based on visualizerEnabled. repositories sit above services/the database and expose the operations a feature actually needs (e.g. SettingsRepository.incrementAppOpenCount()). features never import a plugin package directly - always through a repository or service interface, so unit tests can supply a fake/mock instead.
  • features/* are intentionally shallow and screen-oriented. business logic that isn't specific to one screen belongs in data/. when a screen's sections grow large, they split into sibling widgets under a widgets/ subdirectory of the feature (see lib/features/editor/widgets/), which keep rendering-local state while the screen keeps owning the screen-level state — for the editor that is the AutoSaveDraft and its text controllers, which sections write through the shared EditorDraftMutator.
  • Riverpod providers are colocated with the feature or repository they belong to (e.g. lib/features/easy_mode/easy_mode_providers.dart), using riverpod_generator (@riverpod/@Riverpod(keepAlive: true)) rather than hand-written providers, for consistency and compile-time safety.

code generation

several tools generate code from annotations. after pulling changes, or after editing anything they cover, run:

flutter gen-l10n
dart run build_runner build
  • drift (*.g.dart next to app_database.dart): SQLite table/query code from the Table classes in data/database/tables/.
  • riverpod_generator (*.g.dart next to any file with part '..._providers.g.dart';): provider boilerplate from @riverpod annotated functions/classes.
  • freezed/json_serializable: immutable data classes and json (de)serialization, used for anything that crosses the .med/backup-bundle export boundary.
  • flutter gen-l10n: lib/l10n/generated/app_localizations.dart from lib/l10n/app_en.arb (and any future app_<locale>.arb files).

generated files are excluded from analysis and must never be hand-edited (see analysis_options.yaml).

theming

core/theming/app_theme.dart builds a full ThemeData from a single accent color using Material 3's ColorScheme.fromSeed. the app-wide theme uses defaultAccentColor. per-meditation theme overrides call the same builder with a different accent color, scoped to that meditation's screens — never mutating the global theme.

MeditationTheme (core/routing/meditation_theme.dart) is the wrapper that applies the override: the router wraps the player and recitation-player routes in it, and the meditation editor calls MeditationTheme.themeForSeed(...) to preview its own colorSeed live. both paths resolve through AppTheme.light() / AppTheme.dark(), so a themed screen gets the same component defaults, typography, visual density, and AMOLED true-black preference as the rest of the app — building a bare ThemeData from ColorScheme.fromSeed inside a screen is the bug this helper exists to prevent.

soundscapes and visual backgrounds

the app distinguishes two independent background concepts:

  • soundscapes (audio): ambient/binaural audio loops played during a session. these are per-segment: each Segment carries its own list of MeditationSoundscape entries. the soundscape library is stored in the soundscapes table and managed via the sounds library screen at /settings/soundscapes. in code and UI, these are always called "soundscapes" (never "backgrounds"). the meditation_soundscapes join table has an auto-increment row id (schema v47) so a segment can contain the same soundscape more than once and file/binaural rows with the same numeric id do not collide; rows are ordered by that id when loaded. deleting a soundscape removes its rows in the same transaction, and a row whose target no longer exists is skipped when the meditation loads rather than failing it. alongside the table-backed soundscapes, the app synthesizes colored-noise loops on the fly — they have no table row and are identified by stable negative ids — see "generated noise" in docs/dev-guide/audio-assets.md.

a soundscape's SoundSource says where its audio comes from, and SegmentSoundscapePlayer routes on it: file and noise are mixed by AudioService, binaural goes to BinauralService, and generated goes to GeneratedSoundscapeService (see "generated soundscapes" below). the last two render a single voice each, so a segment plays at most one binaural beat and one generated soundscape; the editor disables the remaining options of that kind once a row has one.

  • backgrounds (visual): image or solid-color fills rendered behind the session content by SessionBackground. a meditation can define a defaultBackground (a BackgroundRef), and each segment can override it with its own background. the visual background library is stored in the image_backgrounds and color_presets tables and managed via the background library screen at /settings/backgrounds. in code and UI, these are always called "backgrounds" (never "visual backgrounds").

BackgroundRef is a stable name+builtin reference (like SoundRef) that survives sync even when row ids change. resolveBackgroundProvider resolves a BackgroundRef against the library at render time, returning a ResolvedBackground (an image path or a Color). the SessionBackground widget paints the resolved background with BoxFit.cover (images) or a solid fill (colors), with an optional scrim for legibility.

soundscape playback never hard-cuts: SoloudAudioService plays each voice at volume 0 and fades it in, fades voices out (with a scheduled stop and a deferred source dispose) on stop, and ramps briefly down/up on pause/resume. the fade-out length depends on why the voice is ending: a slow 3 s ramp when a new mix replaces it at a segment boundary — a crossfade — and a half-second ramp on an explicit stop so ending a session or closing an editor preview stays responsive. SoloudBinauralService applies the same treatment to the binaural stereo pair, and SoloudGeneratedSoundscapeService to its stream. there is no user-facing fade setting.

generated soundscapes

a generated soundscape is a synthesized drone, pulse, and chime layer that the user shapes with a few sliders. it is a library entity like a binaural beat: a row in the generated_soundscapes table (schema v59) holding a name and one parameters json column, edited at /settings/generated_soundscapes (GeneratedSoundscapeLibraryScreen and GeneratedSoundscapeEditorScreen), mapped into the unified Sound model by SoundsRepository, and synced as a SyncGeneratedSoundscape. four built-ins are seeded with stable builtin:generated_* sync ids.

  • GeneratedSoundscapeConfig (data/models/) is the set of user-facing knobs. it clamps every value on construction and its fromJson falls back to defaults for missing or malformed fields, so stored or synced data can never drive the synth out of range. new knobs are added as new json keys with a default — no migration.
  • in the unified list a generated soundscape's Sound.id is its row id plus generatedSoundIdBase. the three soundscape tables all number their rows from 1, and easy mode and the shared pickers identify a soundscape by Sound.id alone, so the offset keeps a generated soundscape from being mistaken for the file or binaural row with the same id. easy-mode settings persist the offset id; SoundsRepository.getSoundscapeById understands it. (file and binaural rows can still share an id with each other, as before.)
  • segments refer to soundscapes by name, so the editor refuses a name another generated soundscape already has, and resolveSoundRef prefers the sound whose source matches the ref when a recording and a generated soundscape share a name. for the paths with nobody to refuse to, the repositories (generated soundscapes and binaural beats) and BackupMapper give a second row of one name a number (drift (2), uniqueName). a built-in and one of the user's may share a name: the ref's isBuiltin tells them apart. a row renamed while it is applied from a bundle is stamped as changed now, so the new name reaches the other devices.
  • parameters this version does not know (a newer version wrote them) are kept in GeneratedSoundscapeConfig.unknownParameters and written back by toJson, so an older device does not strip them when it stores or syncs the soundscape. only plain values are kept, and only so many.
  • the built-ins are seeded with one fixed updated_at (BuiltInGeneratedSoundscape.seededAt) on every device, so a later install cannot out-date, and sync back over, a built-in the user edited. the seeders insert missing presets one by one (re-run by the schema v60 upgrade), so a built-in added in a later release reaches libraries that were already seeded while a tombstoned one stays deleted.
  • the pulse can change tempo over time: tempoAt(elapsedSeconds) glides linearly from startTempo to endTempo and then holds. this is the only time-dependent parameter. rampSecondsFor decides how long the glide takes: rampPercent of the run when the caller knows how long the soundscape will play, otherwise rampMinutes (also used when rampPercent is 0).
  • the run is the stretch SegmentSoundscapePlayer plays through without restarting: the starting segment plus every following segment with an identical soundscape list (soundscapeRunDuration). the meditation player measures it from its block timings, so lengths adjusted at session start and waited-for bells are included and prompt speech is not, and passes it as runDuration on play/transitionTo. an open-ended segment in the run makes it unknown, and the recitation player never passes one, so both fall back to rampMinutes.
  • previews pass maxRampDuration (previewTempoRampCap: 30 seconds, or the preview's own length when shorter), which caps the glide so a minutes-long change can be judged by ear.
  • data/services/soundscape_synth/ is the audio: SynthCore renders the three layers from an EngineParameters snapshot, the GeneratedSoundscapeEngineParameters extension maps the knobs onto that snapshot, and GeneratedSoundscapeRenderer feeds the synth the parameters for the time rendered so far at the start of each block. the ramp clock is therefore rendered audio, not wall time — pausing playback pauses the ramp, and the whole thing renders offline in tests. a live edit (update) keeps the ramp where it is unless it changes one of the tempo settings, which starts the ramp again so the edit can be heard.
  • the drone and its reverb pass through a leveller before the chimes and the pulse are added: a peak follower that turns the bus down when it rises above a ceiling, and leaves it untouched below. a steady drone note that lands on one of the reverb's resonances rings many times louder than its neighbours when space is high, and without the leveller it sat in the output clipper and distorted. the follower falls slowly while the sound holds its level, so the gain does not move within a cycle of a low note, and quickly once the sound has dropped well below it, so the gain does not stay down for seconds after a live edit shortens the reverb. the drone's own slow beating dips that far as well, so a levelled drone's beating comes out a little shallower. the chimes have a reverb of their own and are not levelled: levelled together with the drone, the tail of each loud chime turned the drone down by several decibels. the price is that with every level and space at their maximum the loudest chime peaks reach the output clipper (under 0.3% of samples in a sweep of every root note). a tail that has decayed far below hearing is cut to exact silence.
  • SoloudGeneratedSoundscapeService runs the renderer in its own isolate (soundscapeRenderIsolateMain) and pushes the float pcm it returns into a flutter_soloud buffer stream, keeping about 700 ms queued ahead of the engine. the ui isolate only forwards bytes. update swaps the config in the running isolate without restarting the stream, which is how the editor's sliders are heard live through SoundPreviewController. the service holds an update that arrives while the isolate is still starting and hands it over when it is ready (the preview controller does not send one that early; the editor catches up itself once the preview has started). the top-up timer does not run while paused.
  • every channel drops a start that a stop overtook: the generated service while its isolate spawns, SoloudBinauralService and AudioService.playSoundscapes/playBell while they load. a start that finished after its stop would be sound nothing ends. a newer start replaces an older one the same way, except for bells, which may ring over one another.
  • a segment transition that keeps the same soundscape list does not restart playback (SegmentSoundscapePlayer.transitionTo), so a tempo ramp runs across segment boundaries; a changed list starts it again.
  • SegmentSoundscapePlayer starts the recordings first, then the binaural beat, then the generated soundscape, so the wait for the render isolate does not hold the rest of a segment's sound up. a start overtaken by a newer start or a stop starts nothing further. each channel is paused as soon as it has started when the session is paused (a segment change in a paused session stays paused) or an interruption is under way (AudioService.soundscapesInterrupted).
  • when one channel fails to start (a recording that will not load), SegmentSoundscapePlayer.play, which is how a session starts, stops the channels that did start and rethrows. a host whose start failed never calls stop, so nothing else would end a generated soundscape or binaural beat started before the failure. transitionTo does not throw: it logs the channel that failed, ends it, and starts the other channels, because mid-session a segment with a soundscape missing is better than a silent one, and the hosts call it from their tick and count handling, where a thrown error would stall the session. (within the file channel, AudioService.playSoundscapes stops at the first recording that will not load, so recordings listed after it do not play.)
  • the Wear OS meditation player does not pass a generated (or binaural) service, so those soundscapes are silent there.

sound previews (the easy mode and editor pickers, the sound library) play through those same shared AudioService soundscape/bell channels and BinauralService, coordinated by the shared SoundPreviewController so only one preview plays at a time. previews also auto-stop on a timer that acts on the shared channels, so every player screen stops the preview controller before starting its session — a screen that pushed the player stays mounted, its dispose never runs, and a preview stop left armed would otherwise fade out the session's own mix a few seconds in.

the channels have no owner, so the controller's requests (play, playSegmentSoundscapes, stop) take turns. each silences the channels at once and then waits for the requests before it. a preview that was still starting when it was superseded (a file loading, a render isolate spawning) starts its audio after that silencing, and silences the channels again itself; because the newer request waited, that happens before the newer preview, or the session awaiting stop, plays anything. the wait is capped at five seconds so a sound that never loads cannot hold a session up. a preview that was given up on silences nothing when it finally returns: the channels may be a session's by then, and it has nothing of its own to end, because the services drop a start that a stop overtook.

a preview is shown as playing only once its sound has started. the services do not throw for every failure (a session carries on without a soundscape that will not start), so the controller asks afterwards: GeneratedSoundscapeService.isActive, BinauralService.isActive, AudioService.soundscapesActive.

bell shortcuts

bell shortcuts allow users to embed bell sounds inside spoken (TTS) prompt text by typing an emoji that is assigned to a bell. when the app speaks a prompt, the emoji is replaced by the bell's audio instead of being read aloud.

data model

  • BellsTable.shortcut (data/database/tables/bells_table.dart) is a nullable text column storing the emoji string. each emoji can be assigned to at most one bell.
  • Sound.shortcut (data/models/sound.dart) carries the shortcut through to the feature layer, decoupled from drift's generated classes.
  • SoundsRepository.setBellShortcut() (data/repositories/sounds_repository.dart) assigns or clears a shortcut. if the emoji is already assigned to another bell, _clearShortcutFromOtherBells() removes it first to maintain the one-emoji-per-bell invariant.
  • three built-in bells receive default shortcuts during database migration (BuiltInSeeders.updateBuiltInBellShortcuts()): 🔔 for bell_tibetan_1.mp3, 🥣 for bowl_tibetan_1.mp3, and 🛎️ for chime_soft_1.mp3.

UI

  • SoundLibraryScreen (features/sounds/sound_library_screen.dart) renders a _ShortcutButton as the leading widget of each bell tile. the button shows the assigned emoji or 🔇 when none is set.
  • tapping the button opens _BellShortcutPickerDialog, which uses emoji_picker_flutter to let the user pick any emoji or clear the shortcut by selecting "no shortcut" (which pops with null).

prompt splitting

splitPromptByShortcuts() (data/services/bell_shortcut_service.dart) is the core function that turns a prompt body string into a list of PromptPart pieces:

  • TextPromptPart — text to be spoken by the TTS engine.
  • BellPromptPart — a bell Sound to be played as audio.

the function iterates the prompt text character by character, matching against the shortcut map (sorted longest-first so compound emojis win over single-codepoint ones). matched emojis produce BellPromptParts; intervening text is accumulated and emitted as TextPromptParts with any unassigned emojis stripped via _stripEmojis().

playback

both MeditationPlayerController and RecitationPlayerController build a _bellShortcutMap (Map<String, Sound>) from the bell list at session start. when a text prompt is spoken, _speakPrompt() calls splitPromptByShortcuts() with the map, then iterates the resulting parts:

  • TextPromptPart → _speakTextPart() sends the text to TtsService.
  • BellPromptPart → _playBellPart() plays the bell via AudioService.playBell() and uses a timer based on sound.durationSeconds to advance to the next part.

the session timer is frozen while any prompt part is being spoken or played, so bells embedded in prompts do not consume meditation time.

haptics

HapticPattern (lib/data/models/haptic_pattern.dart) is the source of truth for all supported patterns. it has two classes: one-shot patterns (shortPulse, doublePulse, longPulse, triplePulse) and continuous patterns (dot, dash, slow dot, slow dash, glacial dot, glacial dash, ramp up, ramp down, continuous double pulse, continuous triple pulse, raindrops).

HapticPattern.isContinuous distinguishes them; the pattern also exposes vibrationPattern and intensitiesFor(amplitude) so the service can build the native vibration pattern without embedding platform logic in the model. the ramp patterns use a pulse-width ramp: each 50ms step's on-time grows or shrinks across the 1000ms cycle, so the haptic clearly ramps on devices that do and do not support per-segment amplitude control.

HapticService (lib/data/services/haptic_service.dart) is the interface. VibrationHapticService uses the vibration package on Android/iOS and falls back to HapticFeedback.lightImpact() on desktop or unsupported devices. it supports one-shot trigger() and continuous startContinuous() / stopContinuous(). the platform branch and the raindrop Random are constructor parameters (operatingSystem, random) so tests exercise every path on any host, and tests swap VibrationPlatform.instance for a fake that records the calls — the plugin's official seam, below the service rather than above it.

VibrationHapticService.startContinuous():

  • uses Vibration.vibrate(..., pattern: ..., repeat: 0) for fixed patterns when the device supports custom vibrations.
  • falls back to a repeating Timer that re-triggers Vibration.vibrate() when custom pattern support is missing.
  • raindrops is handled specially: it schedules 200ms pulses with random intensity (within +/- 10% of the chosen global intensity) and random waits between 100ms and 600ms.

MeditationPlayerController and RecitationPlayerController both route every haptic event through _playHaptic(). the controller tracks the active continuous pattern and stops it before starting a new one, pauses/stops it on pause/stop, and restarts it on resume. breathing phase transitions replace the active phase continuous pattern without leaving the previous one running.

sub-segment cycles and interval triggers

interval triggers and breathing exercises share one model: SubSegmentCycle (lib/data/models/sub_segment_cycle.dart) splits a segment into repeating sub-segments — delimited parts that each begin with an optional bell, haptic, and spoken prompt. a meditation interval "every n seconds" is a cycle of one n-second part; a breathing exercise is a cycle of two to four parts (inhale, hold, exhale, hold) with per-phase bells, haptics, and prompts; a recitation interval is a single count-delimited part. on top, an optional interval effect (effectEvery / effectBell / effectHaptic) fires at the start of every nth cycle — for an interval trigger that is every boundary; for a breathing exercise it is "every n completed breaths". when the effect fires it replaces that boundary's own bell and/or haptic.

the interval's unit is implied by the segment's mode — seconds for meditation, completed breath cycles for breathing, recitations for recitation — so it is never stored. the model is IntervalSettings (lib/data/models/interval_settings.dart): every, bell, haptic; the trigger is enabled only when every is positive and at least one action is set.

a segment stores the same fields inline (intervalEvery, intervalBell, intervalHaptic on timer_segments, schema v54) behind its useDefaultInterval flag: when set, IntervalSettings.resolve substitutes the per-mode default from MeditationDefaults (meditationInterval, recitationInterval, breathingInterval), which the player receives via ResolvedSessionOptions.intervalDefaults. there is no meditation-level interval — defaults apply per segment mode.

the two clock domains:

  • timed (meditation and breathing segments): _computeTiming (MeditationPlayerController) enumerates every part boundary of the segment's cycle into the AudioService.scheduleBells list with a duration auto-stop, so phase and interval bells keep ringing with the screen off; the paired haptic and phase prompt fire in _onTick at the same offsets. phase prompts speak through PromptSpeaker.speakUnmanaged, which is a no-op while a managed prompt is in progress — speaking then would cut the prompt mid-sentence, and clearing tts.onComplete would steal the completion callback its part tracking relies on. boundaries land strictly inside the segment so they never collide with the end bell. the boundary on the segment's meditation start (a cycle's first part) is left out of the schedule and played live by the meditation-start milestone — it also fires on paths the tick loop cannot reach (session start, segment jumps, prompt-deferred starts). open-ended segments can't be enumerated — the tick loop evaluates their cycle live and plays the bell directly.
  • counted (recitation segments): RecitationPlayerController feeds the completed count to SubSegmentCycle.effectFiresAt — the count doubles as the cycle index. the trigger is suppressed on the count that completes the segment (the end bell owns it) and deduplicated so a decrement plus re-increment doesn't refire.

spoken prompts share the session clock with the engine schedule. a prompt is due at its segment's boundary (promptStart in _computeTiming), ahead of the start bell even when the segment waits for it, so a prompted segment always runs prompt → bell → content. at session start and on segment jumps the schedule is left unarmed when the boundary prompt is about to speak — enqueueing the due-now start bell would race the prompt's start cancel and ring it underneath the utterance — and when a prompt begins, PromptSpeaker.onStarted cancels every scheduled bell — ringing ones included — so nothing fires underneath the utterance, while the timer freezes for the stretch. when speech finishes, the schedule is re-armed from the frozen position; bells due exactly at that point (a segment's start bell cancelled by its own prompt) are pushed out by a short post-prompt delay so the bell rings a beat after the utterance rather than colliding with it — prompt first, then bell. a "wait for start bell" segment keeps its meditation-start milestone a bell duration past the boundary, so the prompt finishing does not start the content early — the tick loop fires the milestone once the bell window has run. the same re-arm runs on resume and on segment jumps without a prompt, so a jump target's start bell rings on arrival. the recitation player has no scheduled bells — it holds the destination segment's start bell back while the prompt speaks and rings it a beat after the utterance instead. to ring a bell before or inside an utterance instead, embed it in the prompt text with a bell shortcut.

BreathingPhaseController no longer owns a clock: it is a deriver that turns the player's session clock (which already excludes pauses and prompt speech) into the visualizer's BreathingState on a 16 ms ticker, so phase progress stays aligned with the scheduled phase bells. ambient visualizers with no session pass no clock and get an internal wall clock instead.

SoloudAudioService does not enqueue a whole schedule at once: flutter_soloud counts still-pending scheduled voices against its active-voice cap and silently steals the oldest pending voice of the same source once the cap is hit, so scheduleBells keeps at most 48 bells pending and stashes the rest in an app-side queue that topUpScheduledBells (called once per player tick) refills. the cap is raised to 64 while a schedule is active for ringing-bell, soundscape, and speech headroom. scheduleBells, topUpScheduledBells, and cancelScheduledBells run through one serialized op chain because each can suspend on source loads — without it a cancel issued mid-schedule could clear a half-built batch, or a late enqueue could resurrect a bell the cancel meant to drop and ring it underneath a prompt.

SoloudAudioService also owns the engine's output-device lifecycle across a session. flutter_soloud 5 stops the device ~500 ms after the last voice ends, which releases the audioserver AudioMix wakelock; a stopped device freezes the engine clock every scheduled bell rides on, and a restart requested while the app is backgrounded can silently fail, leaving valid, unpaused voices that render silence. startSession therefore sets a null idle timeout for the session's span — the engine then keeps the device running, and starts it immediately if it was stopped — and subscribes to audioDeviceStartFailures to retry starts the engine's own retry gave up on; endSession restores the default timeout so the idle battery savings still apply outside sessions. the player screens arm the session as soon as a pre-session countdown begins so the device is already running, and holding its wakelock, if the screen turns off before the countdown finishes; startSession/endSession run through a second serialized op chain so a close that races the arm still releases the session, and the controller's own startSession after the screen's arm is a no-op.

the keep-alive only prevents the engine's idle stop — the os can still tear the device down mid-session, and a spontaneous stop never reaches audioDeviceStartFailures because no start was attempted. every topUpScheduledBells therefore samples the engine clock against a monotonic wall clock: engine time only advances while the device mixes, so wall time running ahead of it means the device stalled. on a stall the service restarts a stopped device through the serialized recoverAudioDevice path and re-bases every still-pending bell by the lag, so the bell keeps its wall-clock deadline instead of ringing late. a bell the stall already pushed past its deadline is replayed at the current engine time — it fires on the next mix — while older stale bells are dropped, so a long stall ends in one catch-up ring rather than a burst of missed bells. the baseline re-anchors on each scheduleBells and drops with the schedule.

both phone player screens cover the same recovery from the other direction: on AppLifecycleState.resumed they run recoverAudioDevice plus MeditationPlayerController.onAmbientExit() — the watch's ambient→active recovery — which re-arms the remaining bell schedule from the wall-clock position (the last emitted tick lags by the whole suspended stretch), resumes soundscapes, and replays the continuous haptic the os cancelled.

startSession also subscribes to the platform audio session's interruption events for the session's span. any interruption begin fades the mix down and pauses it; the matching end fades it back up for transient pause and duck interruptions (a notification ping, a navigation prompt, a spoken prompt ducking the mix). a permanent focus loss (unknown) pauses and does not auto-resume — the os gave the focus away, so resuming on its own would play over whatever took it. an interruption that ends while the host has the soundscapes paused (pauseSoundscapes without a resumeSoundscapes since) does not resume them either.

the binaural beat and the generated soundscape play through their own services, which the audio service cannot reach. it reports each interruption on AudioService.soundscapeInterruptions (true on begin, false on the end of a pause or duck), and SegmentSoundscapePlayer follows that stream while its soundscapes play, pausing and resuming the two synthesized channels by the same rules.

repeats need no separate handling: SegmentExpander copies the interval fields onto every ExpandedSegment, so each repeated piece runs its own trigger. sync carries the fields on SyncSegment (bell by sync id, haptic by wire value), .med bundles serialize them on the freezed Segment and copy the referenced bell file, and WearSoundResolver falls back for missing interval bells like any other bell ref.

system volume during sessions

SystemVolumeService (lib/data/services/system_volume_service.dart) wraps the system_volume method channel handled in MainActivity, which reads and sets AudioManager.STREAM_MUSIC as a 0..1 fraction without showing the system volume panel. AndroidSystemVolumeService captures the current level on the first apply() of a session and restore() puts it back; apply/restore calls are serialized so a session stopped while its apply is still in flight still restores cleanly. other platforms no-op and the settings UI is Android-only.

the global default lives on MeditationDefaults.systemVolume (null = leave the volume alone). a meditation overrides it with systemVolume + useDefaultSystemVolume (meditations.system_volume / use_default_system_volume columns, schema v53). easy mode has no control and inherits the global default. all four player screens resolve the effective volume, apply it when the session actually starts, and restore it on stop, completion, start failure, and dispose.

player screen labels

only the circle style renders text itself: SessionVisualizer draws the primary/secondary labels inside the progress ring. the other visualizer styles are purely graphical, so the phone player renders a separate timer block below the visualizer (_buildTimerLabels in meditation_player_screen.dart, keyed session_timer_labels). for breathing segments the phase prompt sits above the visualizer and the session time rides below; the none style uses the same block in place of a visual. the watch player keeps its labels centred over the visualizer — the round face leaves no room below.

every text and icon glyph on the player screens carries the shared dark scrim shadow from scrimTextShadows (lib/core/widgets/scrim_shadows.dart) — introduced on Wear to keep labels legible over the animation and now applied to the phone players' labels, prompts, transport icons, and the transparent app bar (AppScaffold adds it to the title whenever a session background is painted behind the chrome). scrimShadowedIcon copies a Material Icon with the shadow applied while preserving its size, color, and semantics. so every surface has a timer to show, breathing segments always expose the session time on MeditationPlayerState.secondaryLabel regardless of segment count.

session start screen

saved practices start on a shared setup surface instead of jumping straight into the session. on the phone it is SessionStartView (lib/features/player/session_start_view.dart) — the pre-start body of both MeditationPlayerScreen and RecitationPlayerScreen: a soundscape picker on top, one preview row per segment (label, length, an edit pencil that opens showSegmentLengthDialog from segment_length_dialog.dart), and the play button anchored to the bottom of a CustomScrollView via SliverFillRemaining so it scrolls with the rows only when they overflow. the watch has no room for a start screen, so its players show a pencil next to play that pushes WearSessionSetupScreen (lib/features/wear/wear_session_setup_screen.dart) — a rotary list with the same soundscape row and in-place wheel editors, returning a WearSessionSetupResult on pop.

every edit is session-scoped. each player keeps a _sessionMeditation copyWith chain plus a SessionSoundscapeChoice (session_soundscape_choice.dart), and applySessionSoundscapeChoice folds the choice into the meditation handed to the controllers — keep the authored mix, strip all soundscapes, or override every segment with one sound and volume — so the stored meditation is never touched, including through the countdown path (_finishCountdown reads the session copy). segment edits land on the source segment before SegmentExpander runs, so repeats inherit them. segmentLengthKind picks the wheel: timed for meditation segments, counts for recitations (the recitation player counts every segment regardless of segment.mode, so a recitation-mode meditation always edits counts), and cycles or a fixed duration for breathing segments. a zero duration stays open-ended only on the last segment, matching the editor's openEndedNotLastSegment rule. autostart — easy mode — skips the screen because its own controls already set these values. the soundscape option list is resolved once into a Future per screen: the view unmounts and remounts as sessions start and stop, and re-listening to the repository's single-subscription stream would throw.

insights: history, journal, and stats

InsightsScreen is a three-tab shell over the sessions table: HistoryScreen, JournalScreen, and StatsScreen (lib/features/history/). sessions store the check-in answers as preMood/postMood emoji strings and preJournal/journal free text on SessionsTable; moods are arbitrary emojis from the editable MoodPresets list, so nothing assigns them an ordinal score.

  • allSessionsProvider streams every session; all filtering is client-side. sessionMatchesQuery and journalMatchSnippet (lib/features/history/session_search.dart) are the shared text-search helpers — they match journal text, meditation name, tags, and the mood emoji fields, and return the matched excerpt the history row shows as its snippet. the history screen adds a has journal chip and one chip per recorded mood emoji on top of the meditation/date/tag filters.
  • JournalScreen lists Session.hasJournal sessions with its own search field and opens the same session detail route.
  • SessionDetailScreen edits the check-in answers through SessionsRepository.updateMoodAndJournal and reloads the session.
  • SessionStats (lib/data/services/session_stats.dart) aggregates the minute/count/streak metrics plus preMoodCounts, postMoodCounts, and moodShifts — pre→post emoji transitions for sessions that recorded both. StatsScreen renders them under the by mood chart group as a grouped bar chart and a shift list.
  • practice goals (lib/features/goals/, lib/data/services/goal_progress.dart) are six optional daily/weekly targets — minutes, sessions, and recitations — stored as one JSON blob under the practice_goals settings key. computeGoalProgress buckets sessions exactly like SessionStats (calendar day, locale-aware SessionStats.weekStart, per-session minute truncation) and honors streak_only_completed; newlyMetGoals diffs the session list around a just-logged row to find the goals it pushed to met, which the four player screens feed to SessionCompleteView's "goal reached" lines. the key is in syncableSettings (backup_mapper_shared.dart), so goals ride every sync bundle including phone→watch. GoalProgressSection on the stats tab lists progress for the configured goals; GoalsSettingsSection on the insights settings screen edits them.

export and import

  • ListExportService (lib/data/services/list_export_service.dart) handles .tls (plain text) and .als (zip with audio files) round-trips for prompt and audio list libraries. it also builds bulk .zip packages and extracts them on import.
  • MedExportService (lib/data/services/med_export_service.dart) handles .med packages: zip archives with meditation.json, manifest.json, and a sounds/ folder.
  • the UI never writes directly to a user-chosen directory. exports are staged in getTemporaryDirectory() and then handed to shareExportedFile (lib/core/utils/share_file.dart). on Android and iOS this opens the system share sheet; on Linux it falls back to FilePicker.saveFile because share_plus does not support file sharing on Linux.
  • imports use FilePicker.platform.pickFiles and read the returned cached paths, which works for network/cloud storage providers and avoids the content:// URI issues that getDirectoryPath returns on Android.

localization & the "lowercase" convention

all English UI strings live in lib/l10n/app_en.arb and are surfaced via the generated AppLocalizations class - never hardcode user-facing text directly in a widget. per the project's design guidelines, all English source strings are lowercase (including this app's own name). this is a stylistic choice for the English source language only - it should not be forced onto community translations, since casing conventions (e.g. German noun capitalization) and scripts without case (e.g. Japanese) vary. screen-reader semantics (Semantics.label) may use natural casing even when the visible Text is lowercase.

the convention applies to documentation too: docs/dev-guide/, docs/user-guide/, docs/adr/, and the root README.md, AGENTS.md, CONTRIBUTING.md, and CHANGELOG.md files are written in lowercase prose. proper nouns (Wear OS, Android, GitLab, F-Droid) and code identifiers (has_seen_welcome, tool/build.sh) keep their canonical casing — never lowercase a string that is a real identifier. PROJECT_PLAN.md is historical and exempt. the user-guide files are bundled into the app and rendered by the help screens, so their casing is user-visible, not just stylistic.

welcome tour and in-app help

  • WelcomeTourGate (lib/features/welcome/welcome_gate.dart) sits in app.dart's builder chain. after the first frame it reads hasSeenWelcomeProvider and pushes /welcome once when the has_seen_welcome settings key is still false. the key is deliberately not part of the synced SyncSettings map — a device restored from a sync bundle is still a new install and gets its own tour.
  • WelcomeTourScreen (lib/features/welcome/welcome_tour_screen.dart) is a custom PageView over the catalog in welcome_pages.dart. info pages are icon + bullets; the appearance, accessibility, and (on supported platforms) health pages embed live controls backed by the same providers the settings screens use, so choices persist immediately. leaving the screen by any means — done, skip, or system back — marks the tour seen via PopScope. it is replayable from the "welcome tour" settings tile.
  • the help feature (lib/features/help/) renders the bundled docs/user-guide/ markdown at /help — the guide files are declared as app assets so user-facing documentation stays the single source of truth and is never duplicated into ARB strings. HelpContentService splits each file on ## headings into sections (HelpSection), resolves docs/user-guide/<language>/ translations before falling back to the English source, and maps relative *.md links back to in-app routes.
  • the reader's topic menu and previous/next buttons browse in place (context.replace), so the app-bar back button always leaves the reader instead of retracing browsed sections; only in-content links push, so back still returns to the section a link was followed from. the app bar shows a help › <document> breadcrumb under the section title.
  • doc contract: because the guide ships inside the app, each ## section of a user-guide file must read as a standalone topic, links between user-guide files must stay relative (reminders.md, not absolute paths or URLs), and images must either resolve as bundled assets or be avoided.
  • help is reachable from the settings screen and the ? action on the easy mode app bar. Wear OS is untouched — WearApp has its own entrypoint and shows neither the tour nor the help screens.

logging and troubleshooting

the app is local-only with no crash reporting, so the diagnostic trail is a persistent on-device log (lib/data/services/logging_service.dart).

  • LoggingService appends one line per entry to <app support>/logs/yyyy-MM-dd.log — daily rotation falls out of the filename, and files at least seven days old are pruned at init. each day's file is capped (~2 MB, dropping the oldest half when it crosses), the last 500 lines are also buffered in memory for the viewer and for lines logged before the directory resolves, and every write is wrapped so logging can never throw — it runs inside catch blocks and error handlers.
  • the top-level log(message, {error, stackTrace, level, tag}) mirrors the dart:developer signature it replaced across the codebase; importing logging_service.dart is the only change a callsite needs. level defaults to info, and to error automatically when an error/stackTrace is attached; tag prefixes the line with a subsystem name (audio, sync, session, nav). dart:developer, debugPrint, and print are no longer used for diagnostics — they never reached release builds or disk.
  • bootstrapApp initializes the service before anything else, installs FlutterError.onError and PlatformDispatcher.onError handlers that persist uncaught framework and async errors, then applies the persisted log_level settings key once the settings table is readable. both entrypoints share this path, so watch sessions and crashes are captured too (the watch still has no viewer — its files are read from the phone's side only if the watch's storage is inspected manually).
  • settings > help > troubleshooting (TroubleshootingScreen at /settings/troubleshooting) holds the log_level dropdown (default info, applied live), a link to the log viewer, a link to the bundled faq.md, and a privacy note — files stay on the device unless the user explicitly copies or shares them.
  • LogViewerScreen (/settings/troubleshooting/logs) browses one day at a time through a date selector listing every file still on disk, newest first. the app bar copies the selected day's full text, shares the file through shareExportedFile, refreshes, and clears all stored logs after confirmation.
  • breadcrumbs worth knowing about: session start/stop (session), audio lifecycle and interruptions (audio), sync runs (sync), and navigation transitions (nav, via LoggingNavigatorObserver on the GoRouter at debug level).

testing

  • unit/widget tests live in test/, mirroring lib/.
  • test/app_test.dart is the end-to-end smoke test: it boots the full KnowThatYouAreBreathingApp with an in-memory drift database and a no-op audio service (via provider overrides) and asserts the easy mode screen renders with its timer, pickers, and controls.
  • platform plugins are always wrapped in an interface under data/services/ so tests can substitute a mocktail fake instead of the real plugin.
  • run flutter test --coverage locally; the GitLab flutter job in .gitlab-ci.yml runs the same command and reports a line-coverage percentage (via the coverage: regex) for the merge-request widget. there is no hard coverage threshold — it is informational.

recitation mode

meditations have a mode field (meditation, recitation, or breathing; stored as timer, counter, breathing) on MeditationsTable. recitation meditations use Segment.targetCount instead of durationSeconds and are played by RecitationPlayerScreen, routed from MeditationListScreen and EasyModeScreen.

RecitationPlayerController and RecitationPlayerState (lib/features/editor/) own recitation session behavior and are shared by the phone RecitationPlayerScreen and the watch WearRecitationScreen. the controller drives multi-segment recitation meditations, start/end bells, soundscapes (per-segment ambient audio), TTS prompts, count direction, segment transitions, pre/post session check-ins, and session logging. the watch player uses the same controller so it has full feature parity with the phone.

RecitationInputService is the domain interface for turning physical/volume/media input into increment/decrement events. DefaultRecitationInputService debounces raw RecitationInputEvents and maps FocusNode/keyboard, physical keyboard (arrows/space), and, on Android, volume/media/camera buttons.

RecitationHardwareInputChannel uses MethodChannel('recitation_input_control') and EventChannel('recitation_input_events') to ask MainActivity to intercept keys and receive the events. the Android MainActivity consumes enabled keys in dispatchKeyEvent and sends only increment/decrement strings on ACTION_DOWN.

SessionStats now tracks recitation counts, per-day/weekly/monthly counts, counts-by-tag, and separate current/longest streaks for days with any recitation counts. StatsScreen adds a recitations tab and summary row.

global hardware-input preferences are stored as RecitationInputSettings in SettingsRepository and edited under settings > meditation defaults > player experience > recitation input. easy mode keeps separate EasyModeSettings (meditation), EasyModeRecitationSettings (recitation), and EasyModeBreathingSettings (breathing), selected by the last_easy_mode_mode flag. Wear OS keeps its own WearRecitationInputSettings for stem button, rotary crown, and local debounce; these are intentionally not synced.

Wear OS

the Wear OS app is built as a wear product flavor of the existing Android module, following the decision in docs/adr/0006-wear-os-product-flavor.md.

  • android/app/build.gradle.kts defines a device flavor dimension with phone (default) and wear flavors. the wear flavor sets minSdk = 30 and uses the same applicationId as the phone flavor so the Wearable Data Layer can communicate. tool/build.sh --dev overrides the id to com.wesorick.john.knowthatyouarebreathing.dev for all flavors at once, so a dev phone build still pairs with a dev wear build — but never with a prod wear install.
  • android/app/src/wear/AndroidManifest.xml is the flavor overlay that declares the app as a watch app and marks it standalone.
  • lib/main_wear.dart is the Wear OS entrypoint; it reuses the shared lib/core/bootstrap.dart initialization but keeps initializeBackgroundServices: false because the full phone scheduler runs an immediate Data Layer sync that can ANR on the watch. SessionForegroundService.init() is not behind that flag — the mediaPlayback foreground service is what keeps a session's audio and timers alive once the watch enters ambient mode. it then runs lib/features/wear/wear_app.dart.
  • android/app/src/wear/res/values/styles.xml and android/app/src/wear/res/drawable/launch_background.xml default the Wear launch window to a dark/black theme. these files live in the wear flavor source set so the phone splash keeps its existing day/night behavior.
  • lib/features/wear/wear_app.dart builds a dark-first, amoled watch theme and wraps the app in an AmbientMode overlay. it loads WearHomeScreen through a WearBackupSyncScheduler so the first sync is deferred until after the first frame.
  • lib/features/wear/wear_backup_sync_scheduler.dart is a WidgetsBindingObserver that does not trigger the heavy onAppResumed sync while the watch is in the foreground. it only forwards paused and detached lifecycle events to BackupSyncService, so the two-way Data Layer sync runs while the watch app is in the background or when a screen explicitly calls syncAllEnabled (for example, after a completed session or a manual tap of sync now). this keeps the home screen and player responsive at launch and during use.
  • lib/features/wear/wear_home_screen.dart is the vertical, rotary-scrollable home list with four entries: easy mode, meditations, insights, and settings. there is no separate recitation entry — recitation sessions start from easy mode or from a saved recitation meditation.
  • lib/features/wear/wear_meditation_list_screen.dart is the read-only, rotary-scrollable list of saved meditations; tapping a meditation or breathing meditation routes to WearPlayerScreen, while recitation meditations route to WearRecitationScreen. mixed-mode meditations use a layered icon.
  • lib/features/wear/wear_meditation_providers.dart exposes a Riverpod stream of non-archived meditations for the watch.
  • lib/features/wear/wear_scaffold.dart is the round-screen-safe watch scaffold used by every wear screen.
  • lib/features/wear/wear_rotary_list.dart is the shared ListView wrapper for watch lists. it needs no rotary-specific code: see "rotary crown input" below.
  • lib/features/wear/wear_rotary_input_scope.dart provides the PrimaryScrollController shared by the watch screens.
  • lib/features/wear/wear_ambient_shell.dart wraps the Wear UI, enables setAutoResumeEnabled, and starts the WearOngoingService so the watch face shows a tappable OngoingActivity icon and the system is less likely to return to the watch face. the actual posting of the OngoingActivity happens in MainActivity.kt.
  • lib/features/wear/wear_ongoing_service.dart is the Dart-to-native wrapper around MainActivity.kt's OngoingActivity methods. it is used from wear_ambient_shell.dart and is called directly by WearPlayerScreen to post and tear down the ongoing activity.
  • lib/features/wear/wear_wake_lock_service.dart is the Dart-to-native wrapper around MainActivity.kt's wear_wake_lock channel, which holds a PowerManager.PARTIAL_WAKE_LOCK. it is opt-in through the watch's keep awake setting (wearKeepAwakeControllerProvider in wear_keep_awake_providers.dart, persisted as the device-local wear_keep_awake_sessions settings key) for watches that still suspend a running session despite the foreground service.
  • lib/features/wear/wear_sound_resolver.dart resolves a meditation's bell and soundscape references against the sounds actually present on the watch, replacing any missing sound with the first built-in one of the same category.
  • lib/features/wear/wear_player_screen.dart is the watch meditation and breathing player. it reuses the shared MeditationPlayerController, shows a silent countdown when configured, renders SessionVisualizer or the dot/tree/wave BreathingVisualizer widgets based on the resolved VisualizerStyle, draws the shared scrimTextShadows behind its centred labels and behind the icon glyphs in the transport controls so the time and breath phase stay legible over the animation without hiding it, conditionally renders live breathing phase labels during breathing segments, shows pre/post session check-ins when the meditation or global settings require them, honors count-direction and screen-behavior overrides, speaks prompts over TTS when available, falls back missing sounds to built-ins, and relays completed sessions to the paired phone. like the phone player it requests notification permission at session start and runs the session inside the mediaPlayback SessionForegroundService (title, play/pause, skip, and stop controls on the watch media card), but a denied permission only drops the card — the session still starts. on the ambient→active transition it calls MeditationPlayerController.onAmbientExit(), which recovers the audio engine's output device, re-arms the remaining scheduled bells, resumes soundscapes, and replays a continuous haptic the OS cancelled while the screen was off.
  • lib/features/wear/wear_control_button.dart wraps the standard AppIconButton for wear controls. it copies the passed Icon and adds the same dark text-shadow used for labels, so each play/pause/stop glyph is readable without the full-width pill used earlier.
  • lib/features/player/synthetic_breathing_visualizer.dart drives the dot/tree/wave visualizers for meditation-mode segments with a synthetic 6-second inhale/exhale cycle. it is shared between the phone and Wear players.
  • lib/features/wear/wear_recitation_screen.dart is the watch recitation player. It reuses the shared RecitationPlayerController so it has the same multi-segment, count-direction, bell, soundscape, TTS, and prompt behavior as the phone. the screen supports on-screen taps (increment), long-press (decrement), Wear stem and rotary input, haptic feedback, wakelock, a silent countdown, conditional visualizer, an ongoing activity, the same SessionForegroundService media card and ambient→active recovery as the watch meditation player, and Data Layer session relay.
  • lib/features/wear/wear_recitation_input_settings_screen.dart and WearRecitationInputSettings (lib/data/models/) hold the watch-local recitation input preferences: stem buttons, rotary crown, and a single debounce window for both taps and hardware. these settings are stored with a Wear-specific key and are excluded from sync.
  • lib/features/wear/wear_insights_screen.dart is the compact, scrollable watch insights screen. it shows the current streak, this-week sessions and minutes, the last session, and a short recent-sessions list, reusing SessionStats and the wearInsightsProvider in lib/features/wear/wear_insights_providers.dart.
  • lib/features/wear/wear_insights_providers.dart combines allSessionsProvider and streakOnlyCompletedProvider into a small WearInsightsState for the watch insights screen.
  • lib/features/wear/wear_settings_screen.dart is the Data Layer/Health Connect status screen for the watch. it shows a note that health sync is handled by the paired phone, not by the watch itself, a keep awake toggle that holds a partial wake lock during sessions (watch-local, more battery), and an about/version tile at the bottom of the scrollable list.
  • lib/core/package_info_provider.dart wraps PackageInfo.fromPlatform() in a generated packageInfoProvider so any screen can read the real app version.
  • lib/core/widgets/about_tile.dart is the shared, cross-platform about tile used by both the phone/desktop SettingsScreen and WearSettingsScreen.

Data Layer sync

the watch and phone sync bundles and sessions through the Wearable Data Layer:

  • DataLayerChannel (lib/data/services/backup_sync/data_layer_channel.dart) is the Dart wrapper around the data_layer_sync method channel.
  • DataLayerPlugin and DataLayerListenerService on Android use DataClient/MessageClient from play-services-wearable. the listener is a WearableListenerService that wakes the phone app when a watch message or data item arrives.
  • DataLayerSyncProvider (lib/data/services/backup_sync/providers/data_layer_sync_provider.dart) implements SyncProvider and uses DataClient assets for bundles. the phone writes /sync/phone_bundle; the watch writes /sync/watch_bundle.
  • watch sessions are sent as small MessageClient messages on /sync/session. DataLayerListenerService parses the JSON, writes to Health Connect through HealthConnectService, and caches the result.
  • a watch session that cannot silence the watch itself — Wear OS only grants notification policy access through adb — publishes a /sync/dnd data item instead; WearDndRelay on the phone applies dnd and restores it on the off-write, an expiry alarm, or the boot/app-start reconcile. see WearSilencePhoneService and the dnd relay section of docs/dev-guide/sync.md.
  • phone-to-watch bundles use BundleExportOptions(watchSync: true, isWearable: false) so only active meditations whose syncToWatch flag is on are included. the bundle also contains the phone's sessions and only the bells, soundscapes, lists, and tags those meditations reference. reminders are omitted. media is sent incrementally based on each entity's updatedAt, per item for audio lists.
  • watch-to-phone bundles use BundleExportOptions(watchSync: true, isWearable: true) and contain the watch's sessions, the active watch-synced meditations, and their referenced sounds, lists, and tags, but no media.
  • phone bundles use BundleSyncScope.watchSubset, which tells BackupMapper to delete any local meditations and sounds that are no longer in the active watch subset. watch bundles use BundleSyncScope.delta so they merge without pruning.
  • the watch UI shows a syncing media snackbar through WearSyncSnackbarListener when the media bytes being transferred total 2 MiB or more, deduped by MediaNotificationGate so an unchanged payload only notifies once per app run.

health relay

session health writes share one implementation: resolveHealthTarget and SessionHealthWriter (lib/data/services/session_health_writer.dart). the phone players call SessionHealthWriter.writeSession — target resolution, permission, HealthService.writeSession, and the healthSynced row update in one place — while the wear players call resolveHealthTarget against the watch's platform and relay the resolved target in the session message.

HealthRelayService (lib/data/services/health_relay_service.dart) is the single Dart entry point for writing watch sessions to the phone's health store:

  • when the app is in the foreground, BackupSyncService.importWearSessionQueue passes each queued session from the Data Layer cache to HealthRelayService.writeQueuedSession. the service preserves the clientRecordId supplied by the watch and updates healthSynced only when HealthService.writeSession succeeds.
  • after a bundle merge, BackupSyncService calls HealthRelayService.syncUnsyncedWearSessions to find any originDevice == 'wear' sessions with healthSynced == false and an endedAt. the service resolves the health target through resolveHealthTarget (lib/data/services/session_health_writer.dart) — the same function the phone and wear players use (meditation override → user default → platform fallback, with an explicit none honored everywhere) — and writes each one with a stable wear_<syncId>_<target> client record id.
  • HealthRelayService is platform-agnostic; it defers to HealthService, which returns isSupported() == false on Wear OS so the watch never attempts a Health Connect write itself.

native Android Health Connect

HealthConnectService is a shared Kotlin service that wraps HealthConnectClient. it is used both by the Flutter HealthConnectPlugin (health_connect method channel) and by DataLayerListenerService when a watch session arrives while the phone app is closed. HealthServiceProviders selects this native implementation on Android, while iOS/macOS continue to use the health package and desktop uses a no-op.

rotary crown input

the Wear OS crown reports rotation as a MotionEvent with ACTION_SCROLL and InputDevice.SOURCE_ROTARY_ENCODER. most devices use AXIS_SCROLL, but some (OnePlus, Android 14) report AXIS_VSCROLL or AXIS_HSCROLL. a few devices also report a non-rotary source. MainActivity.isRotaryScroll therefore matches any ACTION_SCROLL event whose AXIS_SCROLL/AXIS_VSCROLL/AXIS_HSCROLL value is non-zero, then uses the first of those values it finds.

Flutter's AndroidTouchProcessor reads only AXIS_VSCROLL / AXIS_HSCROLL, and only from pointer-class sources, so these events are silently discarded by the engine and no amount of Dart-side wiring can observe them.

MainActivity.dispatchGenericMotionEvent translates each recognized rotary event into a synthetic mouse-wheel MotionEvent (AXIS_VSCROLL + SOURCE_MOUSE, anchored at the window centre) and dispatches that to the Flutter view. Flutter then handles the crown exactly like a mouse wheel, so every Scrollable responds with normal physics and no platform channel, plugin, or per-screen scroll-controller wiring is needed. the synthetic event uses a stable pointer id so it can be built even when the original crown event carries no pointer information.

the watch recitation player can also treat the crown as a count input. when WearRecitationInputSettings.enableRotaryCrown is true, MainActivity accumulates crown detents in handleRotaryEvent and sends increment or decrement events on the recitation_input_events event channel. these feed RecitationInputService with the same debounce setting as on-screen taps.

AXIS_VSCROLL is negated when synthesizing the event because Flutter negates that axis when computing its scroll delta; this keeps a clockwise turn scrolling the list downward.

wear recitation input

Wear OS watches have a primary (top) button, an optional secondary button, and the rotary crown. MainActivity.dispatchKeyEvent handles KEYCODE_STEM_1/2/3, KEYCODE_STEM_PRIMARY, and the translated crown wheel events. when WearRecitationInputSettings enables stem and/or rotary input, RecitationHardwareInputChannel.setIntercepting tells MainActivity to consume those keys and emit RecitationInputEvent values on the recitation_input_events event channel. these events feed the same DefaultRecitationInputService that debounces screen taps, so taps and hardware share one local debounce setting and never overwrite phone/global recitation preferences.

build and run commands for the watch:

flutter run --flavor wear -t lib/main_wear.dart
flutter build apk --flavor wear -t lib/main_wear.dart