voo_watch_ui 0.3.0
voo_watch_ui: ^0.3.0 copied to clipboard
Cross-platform UI DSL for smartwatches. Define your watch UI in Dart and render it identically on Apple Watch (native SwiftUI) and Wear OS (Flutter).
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). OneWatchUiThemeBindingand your watch follows the phone's brand colors automatically. - Modals:
WatchAlert,WatchSheet,WatchToast— declarative, phone owns visibility. - Watch-native animation:
WatchAnimatedScale/WatchAnimatedRotationfor state-tracking animation, plusWatchPulsefor 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/colorthat overrides the theme.
Quick start #
dependencies:
voo_watch_ui: ^0.3.0
voo_watch: ^0.3.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: WatchUiThemeBinding(child: HomeScreen()),
));
class HomeScreen extends StatefulWidget {
@override State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
int _bpm = 72;
@override
void initState() {
super.initState();
VooWatchUi.instance.onEvent('refresh', (_) {
VooWatchUi.instance.haptic(WatchHapticKind.click);
setState(() => _bpm += 1);
_push();
});
_push();
}
void _push() => VooWatchUi.instance.render(
WatchView.column(
mainAxisAlignment: WatchMainAxisAlignment.center,
children: <WatchView>[
WatchView.text('Heart rate', style: WatchTextStyle.caption),
WatchView.text('$_bpm bpm', style: WatchTextStyle.headline),
WatchView.button(text: 'Refresh', onTap: 'refresh'),
],
),
);
@override Widget build(BuildContext context) =>
const Scaffold(body: Center(child: Text('Watch UI driven from this app')));
}
WatchUiThemeBinding is a StatelessWidget you place under your MaterialApp
— it watches Theme.of(context).colorScheme and pushes a matching WatchTheme
to the watch automatically. Toggle dark mode and the watch follows.
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:
| 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 |
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"
Pass --force to overwrite already-copied files (useful after upgrading
the package).
3. Replace the watch target's App.swift #
import SwiftUI
@main
struct MyWatchApp: App {
@StateObject private var state = WatchUiState.shared
init() {
WatchUiSession.shared.start()
}
var body: some Scene {
WindowGroup {
WatchUiRenderer.render(state.root)
.environmentObject(state)
.environment(\.watchUiTheme, state.theme)
}
}
}
4. 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.
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).
Theme — WatchTheme (9 semantic colors), WatchUiThemeBinding (auto-sync
from Material), WatchTheme.fromColorScheme, per-widget overrides.
Haptics — VooWatchUi.instance.haptic(WatchHapticKind.success) (7 kinds:
click / success / warning / failure / notification / start / stop).
Escape hatch — WatchView.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:
- 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. - DerivedData hash mismatch between IDE and CLI. Xcode and
xcodebuildcompute DerivedData paths via different formulas. If you built from the CLI withxcodebuildwhile 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
xcodebuildagainst 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.
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:
applicationIdmatches 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
WatchUiThemeBindingwith a Material theme that already adapts — that's enough for most apps). - Animated theme transitions.
- Tiles, complications, watch faces — see
voo_watch.