Building Flutter Watch Apps: Complete Voo Watch SDK Guide
Building companion watch apps for Flutter projects traditionally means diving into Swift for watchOS and Kotlin for Wear OS. That's two separate codebases, different build systems, and platform-specific APIs for what should be straightforward companion functionality.
Voo Watch SDK (voo_watch) solves this by providing a unified Flutter interface for both Apple Watch and Wear OS development. You get typed message passing between phone and watch, health data integration, haptic feedback, and a CLI that scaffolds the native watch targets automatically.
Check out the package on pub.dev and browse the source on GitHub.
The Multi-Platform Watch Problem
Consider a fitness app that needs to track workouts on the watch and sync data back to the phone. Without Voo Watch, you're building:
- A Swift WatchKit app with HealthKit integration
- A Kotlin Wear OS app using Health Services API
- Custom message passing protocols for each platform
- Platform-specific build configurations and deployment
Each platform has different paradigms. WatchKit uses digital crown interactions and force touch. Wear OS relies on rotary input and different gesture patterns. Health data APIs are completely different. You end up maintaining parallel codebases that do essentially the same thing.
Voo Watch abstracts these differences behind a single Flutter API while still giving you access to platform-specific features when needed.
Quick Start and Installation
Add the package to your Flutter project:
flutter pub add voo_watch
The SDK includes a CLI that scaffolds native watch targets. Run this from your Flutter project root:
flutter pub run voo_watch:scaffold
This command creates the watchOS target in your iOS folder and adds a Wear OS module to your Android project. It handles the boilerplate connectivity code, manifest configurations, and build script modifications.
Here's a minimal working example that sends messages between phone and watch:
import 'package:voo_watch/voo_watch.dart';
class WatchManager {
final VooWatch _watch = VooWatch();
Future<void> initialize() async {
await _watch.initialize();
// Listen for messages from watch
_watch.onMessageReceived.listen((message) {
print('Received from watch: ${message.data}');
});
}
Future<void> sendToWatch(Map<String, dynamic> data) async {
final message = WatchMessage(
type: 'workout_update',
data: data,
);
await _watch.sendMessage(message);
}
Future<bool> isWatchConnected() async {
return await _watch.isConnected();
}
}
This gives you bidirectional communication with type safety. The scaffolded native code handles the platform-specific connectivity automatically.
Core Concepts and Mental Model
Voo Watch operates on three main concepts: messages, capabilities, and sessions.
Messages are the primary communication mechanism. They're typed data structures that serialize consistently across platforms. Unlike raw WatchConnectivity or Wear's MessageClient, you define message schemas that both sides understand:
class WorkoutMessage extends WatchMessage {
final String workoutType;
final int duration;
final double heartRate;
WorkoutMessage({
required this.workoutType,
required this.duration,
required this.heartRate,
}) : super(type: 'workout');
@override
Map<String, dynamic> toJson() => {
'workoutType': workoutType,
'duration': duration,
'heartRate': heartRate,
};
}
Capabilities represent what each device can do. The watch might support heart rate monitoring but not GPS. The phone has cellular but limited battery for continuous sensor access. Voo Watch lets you query capabilities before attempting operations:
final capabilities = await _watch.getCapabilities();
if (capabilities.hasHealthSensors) {
await _watch.startHeartRateMonitoring();
}
Sessions manage the lifecycle of connected interactions. Starting a workout creates a session that keeps both devices active and maintains reliable message delivery. Sessions can be foreground (user actively interacting) or background (passive data sync).
The mental model is request-response with the phone as coordinator. The watch initiates actions (start workout, log data) but the phone manages state and makes API calls. This works because watches have limited processing power and intermittent connectivity.
Real-World Usage: Fitness Tracking
Let's build a workout tracking system that demonstrates message passing, health data, and session management. The watch starts workouts and streams sensor data while the phone handles storage and API sync.
First, define your message types:
abstract class FitnessMessage extends WatchMessage {
FitnessMessage(String type) : super(type: type);
}
class StartWorkoutMessage extends FitnessMessage {
final String workoutType;
final DateTime startTime;
StartWorkoutMessage(this.workoutType, this.startTime)
: super('start_workout');
@override
Map<String, dynamic> toJson() => {
'workoutType': workoutType,
'startTime': startTime.toIso8601String(),
};
}
class SensorDataMessage extends FitnessMessage {
final double heartRate;
final int steps;
final double calories;
SensorDataMessage({
required this.heartRate,
required this.steps,
required this.calories,
}) : super('sensor_data');
@override
Map<String, dynamic> toJson() => {
'heartRate': heartRate,
'steps': steps,
'calories': calories,
};
}
The phone-side controller manages workout state:
class WorkoutController {
final VooWatch _watch = VooWatch();
WorkoutSession? _currentSession;
StreamSubscription? _messageSubscription;
Future<void> initialize() async {
await _watch.initialize();
_messageSubscription = _watch.onMessageReceived.listen(_handleWatchMessage);
}
void _handleWatchMessage(WatchMessage message) async {
switch (message.type) {
case 'start_workout':
await _startWorkout(StartWorkoutMessage.fromJson(message.data));
break;
case 'sensor_data':
await _processSensorData(SensorDataMessage.fromJson(message.data));
break;
case 'end_workout':
await _endWorkout();
break;
}
}
Future<void> _startWorkout(StartWorkoutMessage message) async {
_currentSession = WorkoutSession(
type: message.workoutType,
startTime: message.startTime,
);
// Start session keeps both devices active
await _watch.startSession(
sessionType: SessionType.foreground,
keepAlive: true,
);
// Send confirmation back to watch
await _watch.sendMessage(WatchMessage(
type: 'workout_started',
data: {'sessionId': _currentSession!.id},
));
// Trigger haptic feedback on watch
await _watch.triggerHaptic(HapticType.success);
}
Future<void> _processSensorData(SensorDataMessage sensorData) async {
if (_currentSession == null) return;
_currentSession!.addDataPoint(sensorData);
// Stream to UI
_sensorDataController.add(sensorData);
// Persist locally
await _workoutRepository.saveSensorData(
_currentSession!.id,
sensorData,
);
}
}
The watch-side code (this is pseudocode since the exact watch API varies):
// Note: This represents the conceptual watch-side API
// Actual implementation is in the scaffolded native code
class WatchWorkoutManager {
final VooWatchClient _client = VooWatchClient();
Future<void> startWorkout(String workoutType) async {
// Request health permissions first
final hasPermission = await _client.requestHealthPermission();
if (!hasPermission) return;
// Send start message to phone
await _client.sendMessage(StartWorkoutMessage(
workoutType,
DateTime.now(),
));
// Begin sensor monitoring
_client.healthStream.listen((healthData) {
_client.sendMessage(SensorDataMessage(
heartRate: healthData.heartRate,
steps: healthData.steps,
calories: healthData.calories,
));
});
}
}
This pattern handles the most common watch app scenario: the watch as a sensor hub that streams data to the phone for processing and storage.
Real-World Usage: Smart Home Control
Here's a different use case that shows complications (watch face widgets) and quick actions. A smart home app that lets you control devices from your wrist:
class SmartHomeWatchController {
final VooWatch _watch = VooWatch();
Future<void> setupComplications() async {
// Register complications that show on watch face
await _watch.registerComplication(
ComplicationConfig(
id: 'home_status',
displayName: 'Home Status',
family: ComplicationFamily.circularSmall,
updateFrequency: Duration(minutes: 15),
),
);
// Initial data
await _updateHomeStatusComplication();
}
Future<void> _updateHomeStatusComplication() async {
final homeStatus = await _homeService.getStatus();
await _watch.updateComplication(
id: 'home_status',
data: ComplicationData(
shortText: '${homeStatus.temperature}°',
longText: 'Home: ${homeStatus.temperature}°F',
icon: homeStatus.isHome ? 'house.fill' : 'house',
tintColor: homeStatus.isSecure ? Colors.green : Colors.orange,
),
);
}
Future<void> handleQuickAction(String action) async {
switch (action) {
case 'toggle_lights':
await _homeService.toggleLights();
await _watch.triggerHaptic(HapticType.click);
break;
case 'arm_security':
await _homeService.armSecurity();
await _watch.triggerHaptic(HapticType.success);
break;
case 'garage_door':
await _homeService.toggleGarage();
await _watch.triggerHaptic(HapticType.directional);
break;
}
// Update complication after state change
await _updateHomeStatusComplication();
}
}
Complications are powerful because they surface app data directly on the watch face. Users don't need to launch your app to see current status.
Performance Characteristics and Scale Considerations
Voo Watch performs well for typical companion app scenarios but has important limitations at scale.
Message throughput tops out around 10-15 messages per second per device. This works fine for user interactions and periodic sensor data but won't handle high-frequency streams. If you're sending accelerometer data at 50Hz, you need to batch readings:
class SensorBatcher {
final List<SensorReading> _batch = [];
Timer? _batchTimer;
void addReading(SensorReading reading) {
_batch.add(reading);
_batchTimer ??= Timer(Duration(seconds: 1), _sendBatch);
// Send immediately if batch is full
if (_batch.length >= 50) {
_sendBatch();
}
}
void _sendBatch() {
if (_batch.isEmpty) return;
_watch.sendMessage(BatchSensorMessage(_batch));
_batch.clear();
_batchTimer = null;
}
}
Memory usage grows with message queue size. Each platform handles queuing differently. WatchConnectivity on iOS can buffer about 100 pending messages before dropping them. Wear OS is more restrictive at around 20-30 messages. Design for graceful degradation:
Future<void> sendMessageWithFallback(WatchMessage message) async {
try {
await _watch.sendMessage(message, timeout: Duration(seconds: 5));
} catch (e) {
// Queue locally and retry later
await _messageQueue.enqueue(message);
_scheduleRetry();
}
}
Battery impact varies significantly by use case. Background sessions drain 2-5% per hour on typical hardware. Continuous health monitoring can push that to 8-12%. Always provide user controls for monitoring intensity.
Platform differences matter for performance. Apple Watch Series 4+ handles complexity well but earlier models struggle with frequent UI updates. Wear OS performance varies wildly by manufacturer. Test on older hardware.
Common Gotchas and Integration Tips
Version pinning: Pin voo_watch to exact versions in production apps. Watch connectivity APIs change frequently and point releases may break compatibility:
dependencies:
voo_watch: 0.2.0 # Exact version, not ^0.2.0
Platform permissions: Both platforms require explicit health and connectivity permissions. The scaffolded code handles basic setup but you'll need to customize permission requests:
// Check permissions before initializing
final permissions = await VooWatch.checkPermissions();
if (!permissions.hasHealthAccess) {
final granted = await VooWatch.requestHealthPermissions([
HealthPermission.heartRate,
HealthPermission.steps,
HealthPermission.workouts,
]);
if (!granted) {
// Handle gracefully
return;
}
}
Background limitations: iOS heavily restricts background processing on watches. Don't assume your app stays alive between user interactions. Design for quick launch and state restoration:
class AppStateManager {
static const String _stateKey = 'watch_app_state';
Future<void> saveState(AppState state) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_stateKey, jsonEncode(state.toJson()));
}
Future<AppState?> restoreState() async {
final prefs = await SharedPreferences.getInstance();
final stateJson = prefs.getString(_stateKey);
return stateJson != null ? AppState.fromJson(jsonDecode(stateJson)) : null;
}
}
Testing challenges: Watch simulator testing is limited. You can't test actual connectivity between phone and watch simulators. Use the package's mock mode for unit tests:
// In test setup
VooWatch.enableMockMode(mockResponses: {
'start_workout': {'status': 'success', 'sessionId': 'test_123'},
'sensor_data': {'received': true},
});
Where Voo Watch Shines and Where It Doesn't
Voo Watch excels at companion app scenarios where the watch extends your main Flutter app. It's perfect for fitness apps, smart home controls, notification interactions, and quick data entry. The unified API saves weeks of platform-specific development.
The typed message system is genuinely helpful. You catch serialization errors at compile time instead of debugging mysterious watch connectivity failures. The CLI scaffolding works well and generates clean native code you can customize.
Health data integration is solid for common use cases. Heart rate, steps, workouts, and basic biometrics work consistently across platforms. The abstraction handles unit conversions and data format differences nicely.
But Voo Watch isn't suitable for standalone watch apps that don't need phone connectivity. If you're building a watch-only game or utility, you'll want native development. The package also doesn't support complex watch UI patterns like hierarchical navigation or custom animations. You get basic screens and interactions.
Advanced health features like ECG analysis, blood oxygen monitoring, or fall detection require platform-specific APIs that aren't abstracted yet. The package covers about 80% of common health use cases.
Performance-critical apps may hit the message passing limits. Real-time games, continuous audio streaming, or high-frequency sensor processing need native optimization.
Next Steps
Voo Watch solves the multi-platform companion app problem elegantly. If you're building a Flutter app that needs watch functionality, it's worth the evaluation. The 0.2.0 version is production-ready for standard use cases.
Start with the scaffold command and build a simple message passing prototype. Test on real hardware early since simulator testing has limitations. Focus on the user experience first, then optimize message batching and battery usage.
The package documentation covers advanced topics like custom complications, platform-specific features, and deployment considerations that we didn't cover here.
Want to dig deeper into voo_watch? Check out the package page for full docs and live stats. Need help integrating it into your stack? AgileStack helps teams adopt the right tools without the consulting-firm overhead. Book a 30-minute call.