feat(watch): Wear OS companion app - UX surfaces + bridge client (#91-E, #91-F)

This commit is contained in:
2026-07-25 20:00:01 +02:00
parent 68a87d1658
commit c65a5a76a9
26 changed files with 1973 additions and 0 deletions

View File

@ -0,0 +1,159 @@
import 'package:flutter/services.dart';
import 'package:watch_bridge_contract/watch_bridge_contract.dart';
final class WatchBridgeConnectionEvent {
const WatchBridgeConnectionEvent({
required this.isReachable,
this.requestsResync = false,
});
final bool isReachable;
final bool requestsResync;
}
final class WatchCommandAckEvent {
const WatchCommandAckEvent({
required this.commandId,
required this.status,
required this.sessionId,
this.revisionAtAck,
this.reasonCode,
});
final String commandId;
final WatchCommandAck status;
final String sessionId;
final int? revisionAtAck;
final String? reasonCode;
}
abstract interface class NativeWatchBridgeClient {
Stream<WatchSessionProjection> get projections;
Stream<WatchCommandAckEvent> get acks;
Stream<WatchBridgeConnectionEvent> get connectionEvents;
Future<void> sendCommand(WatchCommandEnvelope command);
Future<void> requestResync();
Future<void> requestCapabilityRefresh();
}
final class MethodChannelNativeWatchBridgeClient
implements NativeWatchBridgeClient {
const MethodChannelNativeWatchBridgeClient({
MethodChannel methodChannel = const MethodChannel(_methodChannelName),
EventChannel projectionChannel = const EventChannel(
_projectionChannelName,
),
EventChannel ackChannel = const EventChannel(_ackChannelName),
EventChannel connectionChannel = const EventChannel(
_connectionChannelName,
),
}) : _methodChannel = methodChannel,
_projectionChannel = projectionChannel,
_ackChannel = ackChannel,
_connectionChannel = connectionChannel;
static const _methodChannelName = 'gametime.watch_bridge/methods';
static const _projectionChannelName = 'gametime.watch_bridge/projections';
static const _ackChannelName = 'gametime.watch_bridge/acks';
static const _connectionChannelName = 'gametime.watch_bridge/connection';
final MethodChannel _methodChannel;
final EventChannel _projectionChannel;
final EventChannel _ackChannel;
final EventChannel _connectionChannel;
@override
Stream<WatchSessionProjection> get projections {
return _projectionChannel
.receiveBroadcastStream()
.where((event) => event is Map)
.map((event) {
return WatchSessionProjection.fromJson(_stringObjectMap(event));
});
}
@override
Stream<WatchCommandAckEvent> get acks {
return _ackChannel
.receiveBroadcastStream()
.where((event) => event is Map)
.map((event) {
final json = _stringObjectMap(event);
return WatchCommandAckEvent(
commandId: _stringFromJson(json['commandId']),
status: _enumFromJson(
json['status'],
WatchCommandAck.values,
WatchCommandAck.rejectedPhoneBusy,
),
sessionId: _stringFromJson(json['sessionId']),
revisionAtAck: _nullableIntFromJson(json['revisionAtAck']),
reasonCode: _nullableStringFromJson(json['reasonCode']),
);
});
}
@override
Stream<WatchBridgeConnectionEvent> get connectionEvents {
return _connectionChannel
.receiveBroadcastStream()
.where((event) => event is Map)
.map((event) {
final json = _stringObjectMap(event);
return WatchBridgeConnectionEvent(
isReachable: json['isReachable'] == true,
requestsResync: json['requestsResync'] == true,
);
});
}
@override
Future<void> sendCommand(WatchCommandEnvelope command) {
return _methodChannel.invokeMethod<void>('sendCommand', command.toJson());
}
@override
Future<void> requestCapabilityRefresh() {
return _methodChannel.invokeMethod<void>('requestCapabilityRefresh');
}
@override
Future<void> requestResync() {
return _methodChannel.invokeMethod<void>('requestResync');
}
}
Map<String, Object?> _stringObjectMap(Object? value) {
if (value is Map) {
return value.map((key, value) => MapEntry(key.toString(), value));
}
return const {};
}
String _stringFromJson(Object? value) {
return value is String ? value : '';
}
String? _nullableStringFromJson(Object? value) {
return value is String ? value : null;
}
int? _nullableIntFromJson(Object? value) {
return value is int ? value : value is num ? value.toInt() : null;
}
T _enumFromJson<T extends Enum>(Object? value, List<T> values, T fallback) {
if (value is String) {
for (final enumValue in values) {
if (enumValue.name == value) {
return enumValue;
}
}
}
return fallback;
}