voo_watch_ui 0.3.0 copy "voo_watch_ui: ^0.3.0" to clipboard
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). 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.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).

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.

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.
2
likes
0
points
295
downloads

Publisher

verified publishervoostack.com

Weekly Downloads

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).

Homepage
Repository (GitHub)
View/report issues

Topics

#flutter #watch #watchos #wear-os #sdui

License

unknown (license)

Dependencies

args, flutter, meta, path, voo_watch, voo_wear

More

Packages that depend on voo_watch_ui