voo_watch_ui 0.2.0
voo_watch_ui: ^0.2.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.2.0
voo_watch: ^0.2.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.
Set up the Apple Watch side #
-
In Xcode, add a watchOS App target to your Flutter project (File → New → Target → watchOS App).
-
From your project root:
dart run voo_watch_ui:init. This copies the Swift interpreter (WatchUiNode.swift,WatchUiRenderer.swift,WatchUiState.swift,WatchUiSession.swift) into the watch target folder. -
Replace the watch target's
App.swiftbody with:@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) } } } -
Build the watch scheme. Done.
If your iOS+watchOS companion build hits Xcode's "Cycle inside Runner" error,
run dart run voo_watch:init --fix-build-phases.
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.
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.