voo_watch_ui

Write your watch UI in Dart. Render it on Apple Watch and Wear OS.

voo_watch_ui is a cross-platform UI DSL for smartwatches. You define a UI tree in Dart on your phone; a tiny native SwiftUI interpreter renders it on Apple Watch, and a Flutter renderer draws it on Wear OS. One source of truth, two ecosystems.

The honest truth. Flutter's engine cannot run on watchOS — Apple does not allow it. This package does not run Flutter on Apple Watch. Instead, it ships a small Swift interpreter (added to your watchOS target by the init CLI) that walks a JSON tree and produces native SwiftUI views. From the developer's seat you write Dart that "feels like Flutter"; on the watch the user sees fully native rendering.

What's in the box

  • 40+ widget primitives covering layout, display, input, lists, motion, feedback, navigation. See Widget surface below.
  • Cross-platform theme system with phone-side auto-sync from Theme.of(context). One WatchUiThemeBinding and your watch follows the phone's brand colors automatically.
  • Modals: WatchAlert, WatchSheet, WatchToast — declarative, phone owns visibility.
  • Watch-native animation: WatchAnimatedScale / WatchAnimatedRotation for state-tracking animation, plus WatchPulse for trigger-based feedback that animates locally on the watch (no IPC round-trip jank).
  • Haptics: VooWatchUi.instance.haptic(WatchHapticKind.success) fires a tap on the wrist via the existing voo_watch bridge.
  • Crown / rotary support out of the box for sliders, steppers, and lists.
  • Per-instance color overrides on every input — switches, sliders, buttons, steppers all accept an activeColor / color that overrides the theme.

Quick start

dependencies:
  voo_watch_ui: ^0.4.0
  voo_watch: ^0.4.0    # the bridge
import 'package:flutter/material.dart';
import 'package:voo_watch_ui/voo_watch_ui.dart';

void main() => runApp(const MaterialApp(
  theme: ThemeData(useMaterial3: true, colorSchemeSeed: Colors.indigo),
  home: WatchUiBootstrap(child: HomeScreen()),
));

class HomeScreen extends StatefulWidget {
  @override State<HomeScreen> createState() => _HomeScreenState();
}

class _HomeScreenState extends State<HomeScreen> {
  int _bpm = 72;

  @override
  void initState() {
    super.initState();
    _push();
  }

  void _push() => VooWatchUi.instance.render(
    WatchView.column(
      mainAxisAlignment: WatchMainAxisAlignment.center,
      children: <WatchView>[
        WatchView.icon(WatchIcons.heart, color: WatchColor.danger),
        WatchView.text('Heart rate', style: WatchTextStyle.caption),
        WatchView.text('$_bpm bpm', style: WatchTextStyle.headline),
        WatchView.button(
          text: 'Refresh',
          onTap: () {
            VooWatchUi.instance.haptic(WatchHapticKind.click);
            setState(() => _bpm += 1);
            _push();
          },
        ),
      ],
    ),
  );

  @override Widget build(BuildContext context) =>
    const Scaffold(body: Center(child: Text('Watch UI driven from this app')));
}

Three things to notice in the snippet above:

  1. onTap: () => ... — interactive factories accept a Dart closure directly; no separate VooWatchUi.instance.onEvent('refresh', ...) registration required. The closure is auto-registered against a synthetic event id, replaced on each render() call. String / WatchEventId forms still work for cases where you want to share an id across the app.
  2. WatchIcons.heart — typed icon constant pairing an SF Symbol name (Apple Watch) with a Material code-point (Wear OS / preview). The factories also accept Flutter's IconData directly (e.g. Icons.star); SF Symbols are resolved via a curated catalog and fall back to questionmark.circle for uncatalogued icons. Raw String icon names continue to work.
  3. WatchUiBootstrap — single wrapper that nests the three required bindings in the right order (see below).

WatchUiBootstrap nests three required bindings in the correct order:

  • WatchUiHotRestartBinding — re-hydrates the watch after a Dart isolate wipe (hot restart). Debug-only.
  • WatchUiHotReloadBinding — re-publishes the last watch tree on every hot reload, even when render(...) lives in initState or an event handler that doesn't re-fire on reload.
  • WatchUiThemeBinding — watches Theme.of(context).colorScheme and pushes a matching WatchTheme to the watch automatically. Toggle dark mode and the watch follows.

Each layer can be disabled with a flag (hotReload: false, hotRestart: false, themeSync: false). For finer-grained control, drop back to nesting the three bindings yourself in the same order.

Hot-reload preview workflow

You don't need an iOS Watch target, a paired watch, or even a simulator to iterate on a WatchView tree. WatchUiPreview wraps the same Flutter renderer that ships on Wear OS in a watch-shaped frame, so you get sub-second hot-reload of layout changes:

import 'package:flutter/material.dart';
import 'package:voo_watch_ui/voo_watch_ui.dart';

class WatchPreview extends StatefulWidget {
  const WatchPreview({super.key});
  @override State<WatchPreview> createState() => _WatchPreviewState();
}

class _WatchPreviewState extends State<WatchPreview> {
  int _count = 0;

  @override
  Widget build(BuildContext context) => WatchUiPreview(
    tree: WatchView.column(children: <WatchView>[
      WatchView.text('$_count', style: WatchTextStyle.headline),
      WatchView.button(text: 'tap', onTap: 'increment'),
    ]),
    dispatch: (id, _) => setState(() => _count += id == 'increment' ? 1 : 0),
    shape: WatchPreviewShape.appleWatch, // or wearOsRound
  );
}

For the fastest loop, use apps/voo_watch_preview in this repo as a ready-to-go scaffold:

cd apps/voo_watch_preview
flutter run -d chrome   # edits to lib/main.dart hot-reload in the browser

VooWatchUi.instance.render(...) is web-safe — it falls back to an in-memory transport on kIsWeb, so existing phone-side code keeps compiling and runs as a no-op in the browser. You can also point flutter run -d chrome at apps/voo_watch_demo to iterate on the full demo (run-session controller, alerts, sheets) without an iOS device.

Set up the Apple Watch side

1. Add the watchOS target in Xcode

open ios/Runner.xcodeproj → File → New → Target → watchOS App. Pick a display name (e.g. MyApp Watch App); Xcode generates the bundle id and a default App.swift. Close Xcode after the target is created — we'll come back to it once the Swift bridge is in place.

2. Scaffold the SwiftUI interpreter

From your Flutter project root:

dart run voo_watch_ui:init

This auto-detects the watchOS target folder under ios/ and copies four Swift files into it, then rewrites the target's @main App.swift to wire the renderer:

File Role
WatchUiNode.swift JSON node model — mirrors WatchView in Dart
WatchUiRenderer.swift Walks a node tree and produces native SwiftUI views
WatchUiState.swift @StateObject holding the current root + theme
WatchUiSession.swift WCSession delegate that drains incoming updates
<TargetName>App.swift Xcode-generated; rewritten to wire the renderer

If auto-detection misses the target (e.g. unusual project layout), pass --watch-target=<path-relative-to-project-root>:

dart run voo_watch_ui:init --watch-target="ios/MyApp Watch App"

init is conservative about App.swift: if the file already has the canonical wiring, it's left alone; if it looks user-modified, init skips it with a warning. Pass --force to overwrite anyway (also forces re-copy of the four Swift files, useful after upgrading the package).

3. Fix the iOS+watchOS Xcode build cycle (one-time)

Companion-app builds hit Xcode's "Cycle inside Runner" error out of the box. One-time fix from your project root:

dart run voo_watch:init --fix-build-phases

Idempotent — safe to re-run after Pod updates or scheme changes.

4. Verify the setup

dart run voo_watch:doctor

Health-checks the project: pubspec, ios/ folder, watch target detection, the four SwiftUI renderer files, App.swift wiring, Podfile.lock, and that the watch target is registered in Runner.xcodeproj. Reports each silent-failure mode as an actionable diagnostic instead of letting Xcode fail 60 seconds into a build.

5. Run

In Xcode pick the watch scheme and run on a paired Apple Watch simulator. The watch app will boot to "Awaiting UI…" and immediately switch to whatever your Dart VooWatchUi.instance.render(...) is pushing.

Pick one driver per session — IDE or CLI, not both. See Troubleshooting below.

Set up the Wear OS side

A Wear OS Flutter app uses WatchUiTreeListener:

import 'package:flutter/material.dart';
import 'package:voo_watch_ui/voo_watch_ui.dart';
import 'package:voo_wear/voo_wear.dart';

class WatchHome extends StatelessWidget {
  @override
  Widget build(BuildContext context) => VooWearScaffold(
    body: WatchUiTreeListener(
      // Show brand colors from frame 1, before the phone's first update.
      initialTheme: WatchTheme.fromColorScheme(Theme.of(context).colorScheme),
      builder: (context, rendered) =>
        rendered ?? const Center(child: Text('Awaiting phone…')),
    ),
  );
}

Widget surface

Layout — Column, Row, Stack, Wrap, Center, Padding, SizedBox, Expanded, Flexible, Spacer, SafeArea.

Display — Text, Image, Icon, Container, Divider, Card, Avatar (circle image / initials fallback), Badge (count or dot overlay), Chip (pill-shaped tag).

Input — Button, IconButton, TextField, Switch, Slider, Checkbox, Stepper (+/- with crown support). Every input accepts an optional per-instance accent color that overrides the theme.

Lists — ListView, ScrollView, ListTile (composable sugar).

Motion — GestureDetector, AnimatedOpacity, AnimatedScale, AnimatedRotation, Pulse (trigger-based scale animation; runs locally on the watch — no IPC round-trip per frame).

Feedback — ProgressIndicator (color/size/strokeWidth), Alert, Sheet, Toast.

Navigation — Page, PageView (horizontal-swipe paging with native dot indicators).

ThemeWatchTheme (9 semantic colors), WatchUiThemeBinding (auto-sync from Material), WatchTheme.fromColorScheme, per-widget overrides.

HapticsVooWatchUi.instance.haptic(WatchHapticKind.success) (7 kinds: click / success / warning / failure / notification / start / stop).

Escape hatchWatchView.custom(type: 'my-thing', props: {...}) lets you plug your own native view types into the renderer.

How it works

Touch events are string IDs (onTap: 'refresh'). When the user taps on the watch, the watch sends WatchUiEvent('refresh') back to the phone via voo_watch's message bridge. The phone resolves the callback in a Dart-side registry and calls it. Re-renders are full-tree replacements with deep-equality deduplication — both renderers diff and patch on their side.

Latency on tap is ~50–150ms on real devices (one bridge round trip); ~500–1000ms on simulators (WCSession is much slower in simulation). UI updates from the phone are similarly asymmetric.

For animations that need to feel instant — tap feedback, success bursts — prefer WatchPulse (watch-side, single IPC) over WatchAnimatedScale (phone drives every frame, two IPCs per pulse).

Testing

import 'package:voo_watch_ui/testing.dart';

testWidgets('button event reaches the registered handler', (tester) async {
  // FakeWatchUi swaps the bridge with an in-process fake.
  await FakeWatchUi.run(() async {
    var taps = 0;
    VooWatchUi.instance.onEvent('tap', (_) => taps += 1);
    await VooWatchUi.instance.render(
      WatchView.button(text: 'Hi', onTap: 'tap'),
    );
    FakeWatchUi.simulateTap('tap');
    expect(taps, 1);
  });
});

The Wear OS rendering path is also covered by wear_os_smoke_test.dart in this package — every primitive renders cleanly through WatchUiFlutterRenderer.

Troubleshooting

Xcode: "unable to attach DB: error: accessing build database"

Xcode's build database (SQLite under DerivedData) got corrupted, usually because the IDE and CLI xcodebuild both wrote to it during the same session.

# 1. Quit Xcode cleanly so SWBBuildService releases its locks.
osascript -e 'quit app "Xcode"'

# 2. Delete the project's DerivedData entry (find the right `Runner-<hash>`
#    in ~/Library/Developer/Xcode/DerivedData/), or nuke the lot:
rm -rf ~/Library/Developer/Xcode/DerivedData

# 3. Delete any local CLI build output that might shadow it:
rm -rf ios/build

# 4. Reopen Xcode and Build (Cmd+B) before Run (Cmd+R).

"Simulator device failed to install the application. NSPOSIXErrorDomain code 2"

POSIX 2 = ENOENT — Xcode is trying to install a .app bundle that doesn't exist at the path it expected. Two common causes:

  1. You hit Run before Xcode finished a fresh Build. Wait for the "Indexing | Processing files" indicator to clear, then Product → Clean Build Folder (Shift+Cmd+K), then Build (Cmd+B), then Run.
  2. DerivedData hash mismatch between IDE and CLI. Xcode and xcodebuild compute DerivedData paths via different formulas. If you built from the CLI with xcodebuild while Xcode was open, the IDE may still be looking at its own (now-empty) DerivedData hash. Fix: in the IDE do Product → Clean Build Folder then Build — that lets Xcode rebuild into its own path.

Rule of thumb: don't mix Xcode IDE and CLI xcodebuild against the same project in the same session. Quit Xcode before running CLI builds for verification (or vice versa).

Watch shows "Awaiting UI…" forever

The phone hasn't pushed a tree yet. Check that your phone-side code actually calls VooWatchUi.instance.render(...) and that WatchUiSession.shared.start() is in the watch target's App.init.

Theme is wrong / stuck on indigo

If you're using WatchUiThemeBinding, make sure it's inside MaterialApp (or its builder: callback) so Theme.of(context) resolves to your seeded theme. If it's above the MaterialApp, the binding will read the ambient default theme, not yours.

On cold start before the phone app has been launched, the watch follows this resolution order:

  1. Last theme cached from a prior session (SharedPreferences on Wear OS, UserDefaults on watchOS).
  2. Last theme retained by the OS via updateApplicationContext (Apple Watch only — Wear OS Dart side currently doesn't subscribe to that channel).
  3. The initialTheme you pass to WatchUiTreeListener (or WatchUiTheme.defaultDark if omitted).

If a user installs the watch app and opens it before ever launching the phone app, only step 3 fires — so always pass an initialTheme that matches your brand:

WatchUiTreeListener(
  initialTheme: WatchTheme.fromColorScheme(myBrandLightScheme),
  builder: (_, ui) => ui ?? const SizedBox(),
)

Animations feel laggy (chunky pulse, delayed scale)

Simulator IPC is much slower than real devices (500–1000ms vs 50–150ms). For interactive feedback, prefer WatchPulse (animates locally on the watch, single IPC) over WatchAnimatedScale (phone drives every frame, two IPCs per pulse). On real hardware both feel fine.

Wear OS app doesn't see the phone

Confirm:

  • applicationId matches between the phone and Wear OS Android modules (Google's Wearable Data Layer requires identical ids).
  • Both AVDs are paired via the Wear OS companion app on the phone AVD.
  • The Wear AVD has Google Play services.

Status

v0.2.0 ships the full design system: theme, modals, animation, haptics, plus the rounded-out widget set. APIs are considered stable for v0.x.

Out of scope (planned for later):

  • Light/dark adaptive theme presets (use WatchUiThemeBinding with a Material theme that already adapts — that's enough for most apps).
  • Animated theme transitions.
  • Tiles, complications, watch faces — see voo_watch.

Libraries

testing
Test utilities for voo_watch_ui — install a fake transport without the per-test boilerplate of constructing a FakeVooWatchTransport, VooWatch.debugInstall-ing it, and resetting both singletons.
voo_watch_ui
Cross-platform UI DSL for smartwatches.