Flutter Wear OS Smartwatch App Guide: Build, Bridge, and Ship

Build, test, and ship a Wear OS smartwatch app with Flutter, including UI, rotary input, performance, native Tiles, and Play Store tips.

ASOasis
7 min read
Flutter Wear OS Smartwatch App Guide: Build, Bridge, and Ship

Image used for representation purposes only.

Overview

Flutter is a productive choice for building Wear OS apps when your primary experience is a touch-first, interactive app that runs on the watch. This guide walks you end‑to‑end—from project setup and UI patterns for tiny round screens, to handling the rotating crown, performance tuning, testing, and shipping to Google Play. You’ll also learn where Flutter shines on Wear OS and where you must bridge into native code for Tiles, Complications, or a Watch Face.

What Flutter can (and can’t) do on Wear OS

  • Well-suited:
    • Interactive apps with screens, navigation, gestures, animations, and networking
    • Reusable business logic shared with your phone app
    • Custom theming and brand polish
  • Use native (Kotlin/Java) with platform channels for:
    • Tiles (androidx.wear.tiles)
    • Complications (Jetpack Complications APIs)
    • Watch faces (Watch Face Format or Jetpack Watch Face)
    • Some background behaviors (foreground services, Alarms)

Flutter renders inside an Activity, which is perfect for an “app-first” experience. Tiles, complications, and watch faces are system surfaces and must be implemented natively, then integrated with your Flutter app via platform channels for state/config.

Prerequisites

  • Flutter SDK installed and on a recent stable channel
  • Android Studio (with Wear OS emulator images) and Android toolchain configured
  • A Wear OS device (optional but recommended) or a Wear OS emulator
  • Basic Android/Gradle familiarity

Quick checks:

flutter doctor
flutter devices

Project setup

Create a new Flutter app:

flutter create wear_timer
cd wear_timer

Bump Android SDK levels to current in android/app/build.gradle:

android {
    compileSdkVersion 34
    defaultConfig {
        applicationId "com.example.wear_timer"
        minSdkVersion 26
        targetSdkVersion 34
        versionCode 1
        versionName "1.0"
    }
}

Add watch capabilities to AndroidManifest.xml (android/app/src/main/AndroidManifest.xml):

<manifest ...>
    <uses-feature android:name="android.hardware.type.watch" android:required="true" />
    <uses-feature android:name="android.hardware.screen.round" android:required="false" />
    <uses-feature android:name="android.hardware.touchscreen" android:required="false" />

    <application ...>
        <!-- Mark as standalone if your app does not require a phone companion -->
        <meta-data
            android:name="com.google.android.wearable.standalone"
            android:value="true" />
    </application>
</manifest>

These flags ensure Google Play treats the APK as a watch-first build and avoids installing it on phones.

UI patterns for tiny, round screens

Design for glanceability and minimal input:

  • Layout
    • Keep primary actions reachable (center/bottom area)
    • Respect round insets; avoid clipped corners on square devices
    • Prefer single-column, large-touch-target lists
  • Typography
    • 16–20sp body, 24–32sp headline; test for legibility in bright sunlight
  • Color & contrast
    • High contrast; consider AMOLED-friendly dark backgrounds
  • Motion
    • Use subtle animations; keep frame times under 16ms to save battery

Practical helpers in Flutter:

// Pad content away from the round screen edge
class BezelSafeArea extends StatelessWidget {
  final Widget child;
  const BezelSafeArea({super.key, required this.child});
  @override
  Widget build(BuildContext context) {
    final size = MediaQuery.sizeOf(context);
    final shortest = size.shortestSide;
    final bezel = shortest * 0.06; // ~6% padding for round screens
    return Padding(
      padding: EdgeInsets.all(bezel.clamp(8, 24)),
      child: child,
    );
  }
}

Input and interaction on Wear OS

  • Touch: Keep targets ≥ 40–48dp, with generous spacing
  • Gestures: Vertical scroll is primary; horizontal for paging
  • Rotating crown (rotary encoder): Generates scroll events; default Scrollables respond automatically. You can also listen explicitly:
class RotaryList extends StatelessWidget {
  final controller = ScrollController();
  RotaryList({super.key});
  @override
  Widget build(BuildContext context) {
    return Listener(
      onPointerSignal: (signal) {
        if (signal is PointerScrollEvent) {
          controller.jumpTo(
            (controller.offset + signal.scrollDelta.dy).clamp(0.0, controller.position.maxScrollExtent),
          );
        }
      },
      child: ListView.builder(
        controller: controller,
        itemCount: 40,
        itemBuilder: (_, i) => ListTile(title: Text('Item $i')),
      ),
    );
  }
}
  • Haptics: Use HapticFeedback.lightImpact() for confirmations sparingly

Sample app: a one-screen Pomodoro timer

Demonstrates glanceable UI, crown-adjustable duration, vibration on completion.

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

void main() => runApp(const WearTimerApp());

class WearTimerApp extends StatelessWidget {
  const WearTimerApp({super.key});
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: ThemeData(brightness: Brightness.dark, colorSchemeSeed: Colors.teal),
      home: const TimerScreen(),
    );
  }
}

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

class _TimerScreenState extends State<TimerScreen> {
  int minutes = 25; // adjustable via crown
  int remaining = 25 * 60;
  Timer? _timer;

  void _start() {
    _timer?.cancel();
    setState(() => remaining = minutes * 60);

    _timer = Timer.periodic(const Duration(seconds: 1), (t) async {
      if (remaining <= 1) {
        t.cancel();
        setState(() => remaining = 0);
        HapticFeedback.vibrate();
      } else {
        setState(() => remaining -= 1);
      }
    });
  }

  void _stop() => _timer?.cancel();

  @override
  void dispose() {
    _timer?.cancel();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final m = (remaining ~/ 60).toString().padLeft(2, '0');
    final s = (remaining % 60).toString().padLeft(2, '0');

    return Scaffold(
      body: BezelSafeArea(
        child: Listener(
          onPointerSignal: (signal) {
            if (signal is PointerScrollEvent && _timer == null) {
              final delta = signal.scrollDelta.dy.sign.toInt();
              setState(() => minutes = (minutes + delta).clamp(1, 60));
            }
          },
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Text('$m:$s', style: const TextStyle(fontSize: 40, fontFeatures: [FontFeature.tabularFigures()])),
              const SizedBox(height: 8),
              Text('Duration: $minutes min', style: const TextStyle(fontSize: 16, color: Colors.white70)),
              const SizedBox(height: 16),
              Wrap(spacing: 12, children: [
                ElevatedButton.icon(onPressed: _start, icon: const Icon(Icons.play_arrow), label: const Text('Start')),
                ElevatedButton.icon(onPressed: _stop, icon: const Icon(Icons.stop), label: const Text('Stop')),
              ])
            ],
          ),
        ),
      ),
    );
  }
}

class BezelSafeArea extends StatelessWidget {
  final Widget child;
  const BezelSafeArea({super.key, required this.child});
  @override
  Widget build(BuildContext context) {
    final size = MediaQuery.sizeOf(context);
    final shortest = size.shortestSide;
    final bezel = shortest * 0.06;
    return Padding(padding: EdgeInsets.all(bezel.clamp(8, 24)), child: child);
  }
}

Bridging to native for Tiles, Complications, Watch Faces

Flutter can’t render these surfaces. The pattern is:

  1. Implement the native service (Kotlin)
  2. Persist or fetch tile/complication data from a shared store (e.g., Room/SharedPreferences)
  3. Allow your Flutter UI to configure that data via MethodChannel

Method channel from Flutter

import 'package:flutter/services.dart';

class NativeBridge {
  static const _ch = MethodChannel('wear_bridge');
  static Future<void> updateTileCountdown(int seconds) async {
    await _ch.invokeMethod('updateTileCountdown', {'seconds': seconds});
  }
}

Minimal TileService skeleton (Kotlin)

class PomodoroTileService : TileService() {
    override fun onTileRequest(requestParams: RequestBuilders.TileRequest): ListenableFuture<Tile> {
        val state = readSecondsFromPrefs()
        val timeline = Timeline.fromLayoutElement(
            LayoutElementBuilders.Text.Builder()
                .setText("${'$'}state s")
                .build()
        )
        return Futures.immediateFuture(
            Tile.Builder().setResourcesVersion("1").setTimeline(timeline).build()
        )
    }
}

Receiving method calls in Android (Kotlin)

class MainActivity: FlutterActivity() {
    private val channel = "wear_bridge"
    override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
        super.configureFlutterEngine(flutterEngine)
        MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channel).setMethodCallHandler { call, result ->
            when (call.method) {
                "updateTileCountdown" -> {
                    val seconds = call.argument<Int>("seconds") ?: 0
                    saveSecondsToPrefs(seconds)
                    requestTileUpdate(ComponentName(this, PomodoroTileService::class.java))
                    result.success(null)
                }
                else -> result.notImplemented()
            }
        }
    }
}

The same integration approach applies to Complications (data sources) and Watch Face configuration activities. Your Flutter UI acts as the rich configuration/companion app; the surface itself is native.

Notifications and background work

  • Local notifications: trigger reminders or progress updates (use a well-maintained notifications plugin). Ensure channels are created and importance is appropriate for Wear.
  • Push notifications: deliver via FCM; keep payloads small and actions glanceable.
  • Background tasks: prefer scheduled work (WorkManager) or foreground services with a clear user benefit; avoid long-running background Dart code to conserve battery.

Performance and battery life

  • Aim for 60 FPS with minimal jank; profile with Flutter DevTools (CPU, memory, frame times)
  • Use const widgets and avoid rebuilding large trees
  • Prefer vector assets (SVG via Picture widgets) or small, cached rasters
  • Debounce network calls; batch or cache data on-device
  • Test on-device with battery saver enabled to spot corner cases

Testing and debugging

  • Emulator: create a Wear OS device in Android Studio’s Device Manager; run via flutter run -d <emulator_id>
  • Physical device: enable ADB over Bluetooth or Wi‑Fi; flutter devices should list the watch
  • Automated tests:
    • Unit and widget tests for shared logic and UI
    • Golden tests for small, round screens using constrained layouts (e.g., 384×384)

Packaging and Play distribution

  • App icon: provide crisp, round-friendly launcher assets (monochrome variant recommended)
  • Split builds:
    • Use ABI splits to reduce size
    • Consider product flavors for “wear” vs “mobile” targets if you share codebases
  • Manifest sanity:
    • <uses-feature android:name="android.hardware.type.watch" android:required="true" />
    • Standalone meta-data if no phone dependency
  • Store listing: add Wear OS screenshots (round and square), short description, and feature graphic

CI/CD tips

  • Cache Gradle and Flutter artifacts to speed builds
  • Use flutter test and flutter analyze gates
  • Sign release with Play App Signing and upload via CLI (fastlane/Gradle Play Publisher)

Troubleshooting

  • App appears on phone: Missing or incorrect android.hardware.type.watch feature flag
  • Rotary crown doesn’t scroll: Ensure your top-level widget isn’t intercepting pointer signals unintentionally; prefer default Scrollables
  • Clipped UI on round screens: Add bezel-safe padding and test on multiple DPIs
  • Jank on animations: Profile; reduce rebuilds; precompute layouts; lower image decode cost

Launch checklist

  • Runs smoothly on at least two emulator sizes (small/large) and one physical watch
  • Text legible at a glance; primary action reachable
  • Offline behavior tested
  • Battery impact acceptable after a 24‑hour cycle
  • Store listing complete with Wear OS screenshots and a short demo video

Where to go next

  • Add a native Tile that mirrors your app’s current state
  • Provide a simple Complication data source (e.g., remaining minutes)
  • Offer a Watch Face configuration Activity powered by your Flutter UI
  • Harden for production: crash reporting, analytics, and strict ANR monitoring

With these patterns, you can ship a performant, glanceable Wear OS experience with Flutter for the app UI, while augmenting system surfaces natively where required.

Related Posts