feat(watch): clôture lot #91 - fréquence cardiaque live, notifications de séance et finitions montre
Consolide le lot applicatif watch companion validé : - télémétrie fréquence cardiaque live remontée montre -> téléphone (collecteur watch, adapter Wear Data Layer, persistance Drift, propagation aux écrans historique/programme/profil/exécution) - notifications de séance en arrière-plan côté téléphone (service foreground de statut + passerelle applicative) - finitions montre : chrono d'étape, score d'étape, retrait du bouton "lancer une séance", thème, icônes et polices watch_app Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -10,6 +10,7 @@ part 'app_database.g.dart';
|
||||
ActiveExerciseStepProgressStates,
|
||||
ActiveExerciseStepResults,
|
||||
ActiveRestStates,
|
||||
ActiveManualScoreStates,
|
||||
ActiveScoreStopwatchStates,
|
||||
ActiveSetTimerStates,
|
||||
ActiveSetResults,
|
||||
@ -48,7 +49,7 @@ final class AppDatabase extends _$AppDatabase {
|
||||
}
|
||||
|
||||
@override
|
||||
int get schemaVersion => 19;
|
||||
int get schemaVersion => 22;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration {
|
||||
@ -119,6 +120,15 @@ final class AppDatabase extends _$AppDatabase {
|
||||
if (from < 19) {
|
||||
await _migrateToSchema19();
|
||||
}
|
||||
if (from < 20) {
|
||||
await _migrateToSchema20(migrator);
|
||||
}
|
||||
if (from < 21) {
|
||||
await _migrateToSchema21();
|
||||
}
|
||||
if (from < 22) {
|
||||
await _migrateToSchema22();
|
||||
}
|
||||
await _createIndexes();
|
||||
},
|
||||
beforeOpen: (details) async {
|
||||
@ -199,6 +209,10 @@ final class AppDatabase extends _$AppDatabase {
|
||||
'CREATE INDEX IF NOT EXISTS idx_active_set_results_session_id '
|
||||
'ON active_set_results (active_workout_session_id)',
|
||||
);
|
||||
await customStatement(
|
||||
'CREATE INDEX IF NOT EXISTS idx_active_manual_score_states_session_id '
|
||||
'ON active_manual_score_states (active_workout_session_id)',
|
||||
);
|
||||
await customStatement(
|
||||
'CREATE INDEX IF NOT EXISTS idx_active_score_stopwatch_states_session_id '
|
||||
'ON active_score_stopwatch_states (active_workout_session_id)',
|
||||
@ -288,6 +302,7 @@ final class AppDatabase extends _$AppDatabase {
|
||||
const _syncableTableNames = [
|
||||
'active_exercise_step_progress_states',
|
||||
'active_exercise_step_results',
|
||||
'active_manual_score_states',
|
||||
'active_rest_states',
|
||||
'active_score_stopwatch_states',
|
||||
'active_set_timer_states',
|
||||
@ -770,6 +785,37 @@ CREATE TABLE IF NOT EXISTS active_set_timer_states (
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _migrateToSchema20(Migrator migrator) async {
|
||||
await migrator.createTable(activeManualScoreStates);
|
||||
}
|
||||
|
||||
Future<void> _migrateToSchema21() async {
|
||||
await _addColumnIfMissing(
|
||||
tableName: 'workout_history',
|
||||
columnName: 'average_heart_rate_bpm',
|
||||
definition:
|
||||
'average_heart_rate_bpm REAL CHECK '
|
||||
'(average_heart_rate_bpm IS NULL OR average_heart_rate_bpm > 0)',
|
||||
);
|
||||
await _addColumnIfMissing(
|
||||
tableName: 'workout_history',
|
||||
columnName: 'max_heart_rate_bpm',
|
||||
definition:
|
||||
'max_heart_rate_bpm INTEGER CHECK '
|
||||
'(max_heart_rate_bpm IS NULL OR max_heart_rate_bpm > 0)',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _migrateToSchema22() async {
|
||||
await _addColumnIfMissing(
|
||||
tableName: 'exercise_steps',
|
||||
columnName: 'linked_to_series_score',
|
||||
definition:
|
||||
'linked_to_series_score INTEGER NOT NULL DEFAULT 0 '
|
||||
'CHECK (linked_to_series_score IN (0, 1))',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _backfillWorkoutHistorySetSourceExerciseIds() async {
|
||||
await customStatement(r'''
|
||||
UPDATE workout_history_set_results AS result
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1192,6 +1192,19 @@ final class DriftActiveSessionRepository implements ActiveSessionRepository {
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveManualScoreState(domain.ActiveManualScoreState state) async {
|
||||
await _upsertWithChangeLog(
|
||||
database: database,
|
||||
tableName: 'active_manual_score_states',
|
||||
entityType: 'ActiveManualScoreState',
|
||||
metadata: state.metadata,
|
||||
write: () => database
|
||||
.into(database.activeManualScoreStates)
|
||||
.insertOnConflictUpdate(_activeManualScoreStateCompanion(state)),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveExerciseStepProgressState(
|
||||
domain.ActiveExerciseStepProgressState state,
|
||||
@ -1260,6 +1273,42 @@ final class DriftActiveSessionRepository implements ActiveSessionRepository {
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteManualScoreState({
|
||||
required String sessionId,
|
||||
required int programIndex,
|
||||
required int exerciseIndex,
|
||||
required int setIndex,
|
||||
required DateTime deletedAt,
|
||||
}) async {
|
||||
final row =
|
||||
await (database.select(database.activeManualScoreStates)..where(
|
||||
(table) =>
|
||||
table.activeWorkoutSessionId.equals(sessionId) &
|
||||
table.programIndex.equals(programIndex) &
|
||||
table.exerciseIndex.equals(exerciseIndex) &
|
||||
table.setIndex.equals(setIndex) &
|
||||
table.deletedAt.isNull(),
|
||||
))
|
||||
.getSingleOrNull();
|
||||
if (row == null) {
|
||||
return;
|
||||
}
|
||||
final revision = row.localRevision + 1;
|
||||
await (database.delete(
|
||||
database.activeManualScoreStates,
|
||||
)..where((table) => table.id.equals(row.id))).go();
|
||||
await _writeChangeLog(
|
||||
database: database,
|
||||
entityType: 'ActiveManualScoreState',
|
||||
entityId: row.id,
|
||||
operation: 'delete',
|
||||
localRevision: revision,
|
||||
originDeviceId: row.originDeviceId,
|
||||
createdAt: deletedAt,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<domain.ActiveScoreStopwatchState?> findScoreStopwatchState({
|
||||
required String sessionId,
|
||||
@ -1280,6 +1329,26 @@ final class DriftActiveSessionRepository implements ActiveSessionRepository {
|
||||
return row == null ? null : _activeScoreStopwatchStateFromRow(row);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<domain.ActiveManualScoreState?> findManualScoreState({
|
||||
required String sessionId,
|
||||
required int programIndex,
|
||||
required int exerciseIndex,
|
||||
required int setIndex,
|
||||
}) async {
|
||||
final row =
|
||||
await (database.select(database.activeManualScoreStates)..where(
|
||||
(table) =>
|
||||
table.activeWorkoutSessionId.equals(sessionId) &
|
||||
table.programIndex.equals(programIndex) &
|
||||
table.exerciseIndex.equals(exerciseIndex) &
|
||||
table.setIndex.equals(setIndex) &
|
||||
table.deletedAt.isNull(),
|
||||
))
|
||||
.getSingleOrNull();
|
||||
return row == null ? null : _activeManualScoreStateFromRow(row);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<domain.ActiveSetTimerState?> findSetTimerState({
|
||||
required String sessionId,
|
||||
@ -1432,6 +1501,26 @@ final class DriftActiveSessionRepository implements ActiveSessionRepository {
|
||||
.get();
|
||||
return rows.map(_activeScoreStopwatchStateFromRow).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<domain.ActiveManualScoreState>> listManualScoreStates(
|
||||
String sessionId,
|
||||
) async {
|
||||
final rows =
|
||||
await (database.select(database.activeManualScoreStates)
|
||||
..where(
|
||||
(table) =>
|
||||
table.activeWorkoutSessionId.equals(sessionId) &
|
||||
table.deletedAt.isNull(),
|
||||
)
|
||||
..orderBy([
|
||||
(table) => OrderingTerm.asc(table.programIndex),
|
||||
(table) => OrderingTerm.asc(table.exerciseIndex),
|
||||
(table) => OrderingTerm.asc(table.setIndex),
|
||||
]))
|
||||
.get();
|
||||
return rows.map(_activeManualScoreStateFromRow).toList();
|
||||
}
|
||||
}
|
||||
|
||||
final class DriftWorkoutHistoryRepository implements WorkoutHistoryRepository {
|
||||
@ -1522,6 +1611,51 @@ final class DriftWorkoutHistoryRepository implements WorkoutHistoryRepository {
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> patchHeartRateSummary({
|
||||
required String historyId,
|
||||
required double averageHeartRateBpm,
|
||||
required int maxHeartRateBpm,
|
||||
required DateTime patchedAt,
|
||||
}) async {
|
||||
if (averageHeartRateBpm <= 0 || maxHeartRateBpm <= 0) {
|
||||
return;
|
||||
}
|
||||
final row =
|
||||
await (database.select(database.workoutHistories)..where(
|
||||
(table) =>
|
||||
table.id.equals(historyId) &
|
||||
table.deletedAt.isNull() &
|
||||
table.averageHeartRateBpm.isNull() &
|
||||
table.maxHeartRateBpm.isNull(),
|
||||
))
|
||||
.getSingleOrNull();
|
||||
if (row == null) {
|
||||
return;
|
||||
}
|
||||
final revision = row.localRevision + 1;
|
||||
await (database.update(
|
||||
database.workoutHistories,
|
||||
)..where((table) => table.id.equals(historyId))).write(
|
||||
db.WorkoutHistoriesCompanion(
|
||||
updatedAt: Value(patchedAt.toUtc()),
|
||||
syncState: const Value('dirty'),
|
||||
localRevision: Value(revision),
|
||||
averageHeartRateBpm: Value(averageHeartRateBpm),
|
||||
maxHeartRateBpm: Value(maxHeartRateBpm),
|
||||
),
|
||||
);
|
||||
await _writeChangeLog(
|
||||
database: database,
|
||||
entityType: 'WorkoutHistory',
|
||||
entityId: historyId,
|
||||
operation: 'update',
|
||||
localRevision: revision,
|
||||
originDeviceId: row.originDeviceId,
|
||||
createdAt: patchedAt,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveSetResult(domain.WorkoutHistorySetResult result) async {
|
||||
await _upsertWithChangeLog(
|
||||
@ -2777,6 +2911,7 @@ Future<void> _replaceExerciseSteps(
|
||||
scoreUnit: Value(step.scoreUnit),
|
||||
defaultTargetScore: Value(step.defaultTargetScore),
|
||||
defaultTargetScoreTimeMs: Value(step.defaultTargetScoreTimeMs),
|
||||
linkedToSeriesScore: Value(step.linkedToSeriesScore),
|
||||
),
|
||||
),
|
||||
);
|
||||
@ -3252,6 +3387,7 @@ domain.ExerciseStep _exerciseStepFromRow(db.ExerciseStep row) {
|
||||
scoreUnit: row.scoreUnit,
|
||||
defaultTargetScore: row.defaultTargetScore,
|
||||
defaultTargetScoreTimeMs: row.defaultTargetScoreTimeMs,
|
||||
linkedToSeriesScore: row.linkedToSeriesScore,
|
||||
);
|
||||
}
|
||||
|
||||
@ -4013,6 +4149,45 @@ db.ActiveScoreStopwatchStatesCompanion _activeScoreStopwatchStateCompanion(
|
||||
);
|
||||
}
|
||||
|
||||
db.ActiveManualScoreStatesCompanion _activeManualScoreStateCompanion(
|
||||
domain.ActiveManualScoreState state,
|
||||
) {
|
||||
final values = _metadataValues(state.metadata);
|
||||
return db.ActiveManualScoreStatesCompanion(
|
||||
id: values[0] as Value<String>,
|
||||
createdAt: values[1] as Value<DateTime>,
|
||||
updatedAt: values[2] as Value<DateTime>,
|
||||
deletedAt: values[3] as Value<DateTime?>,
|
||||
schemaVersion: values[4] as Value<int>,
|
||||
syncState: values[5] as Value<String>,
|
||||
localRevision: values[6] as Value<int>,
|
||||
originDeviceId: values[7] as Value<String>,
|
||||
futureOwnerProfileId: values[8] as Value<String?>,
|
||||
lastSyncedAt: values[9] as Value<DateTime?>,
|
||||
remoteRevision: values[10] as Value<String?>,
|
||||
activeWorkoutSessionId: Value(state.activeWorkoutSessionId),
|
||||
programIndex: Value(state.programIndex),
|
||||
exerciseIndex: Value(state.exerciseIndex),
|
||||
setIndex: Value(state.setIndex),
|
||||
value: Value(state.value),
|
||||
scoreUpdatedAt: Value(state.updatedAt.toUtc()),
|
||||
);
|
||||
}
|
||||
|
||||
domain.ActiveManualScoreState _activeManualScoreStateFromRow(
|
||||
db.ActiveManualScoreState row,
|
||||
) {
|
||||
return domain.ActiveManualScoreState(
|
||||
metadata: _metadataFromRow(row),
|
||||
activeWorkoutSessionId: row.activeWorkoutSessionId,
|
||||
programIndex: row.programIndex,
|
||||
exerciseIndex: row.exerciseIndex,
|
||||
setIndex: row.setIndex,
|
||||
value: row.value,
|
||||
updatedAt: _utc(row.scoreUpdatedAt),
|
||||
);
|
||||
}
|
||||
|
||||
domain.ActiveScoreStopwatchState _activeScoreStopwatchStateFromRow(
|
||||
db.ActiveScoreStopwatchState row,
|
||||
) {
|
||||
@ -4188,6 +4363,8 @@ db.WorkoutHistoriesCompanion _workoutHistoryCompanion(
|
||||
totalActiveMs: Value(history.totalActiveMs),
|
||||
completed: Value(history.completed),
|
||||
historySnapshotJson: Value(history.historySnapshotJson),
|
||||
averageHeartRateBpm: Value(history.averageHeartRateBpm),
|
||||
maxHeartRateBpm: Value(history.maxHeartRateBpm),
|
||||
);
|
||||
}
|
||||
|
||||
@ -4303,6 +4480,8 @@ domain.WorkoutHistory _workoutHistoryFromRow(
|
||||
totalActiveMs: row.totalActiveMs,
|
||||
completed: row.completed,
|
||||
historySnapshotJson: row.historySnapshotJson,
|
||||
averageHeartRateBpm: row.averageHeartRateBpm,
|
||||
maxHeartRateBpm: row.maxHeartRateBpm,
|
||||
results: results,
|
||||
stepResults: stepResults,
|
||||
);
|
||||
@ -4560,6 +4739,8 @@ Map<String, Object?> _workoutHistoryPayload(domain.WorkoutHistory history) => {
|
||||
'totalActiveMs': history.totalActiveMs,
|
||||
'completed': history.completed,
|
||||
'historySnapshotJson': history.historySnapshotJson,
|
||||
'averageHeartRateBpm': history.averageHeartRateBpm,
|
||||
'maxHeartRateBpm': history.maxHeartRateBpm,
|
||||
};
|
||||
|
||||
Map<String, Object?> _localWorkoutHistoryPayload(
|
||||
@ -4714,6 +4895,8 @@ domain.WorkoutHistory _workoutHistoryFromLocalBackupPayload(
|
||||
completed: payload['completed'] as bool? ?? false,
|
||||
historySnapshotJson:
|
||||
payload['historySnapshotJson'] as String? ?? '{"programs":[]}',
|
||||
averageHeartRateBpm: (payload['averageHeartRateBpm'] as num?)?.toDouble(),
|
||||
maxHeartRateBpm: payload['maxHeartRateBpm'] as int?,
|
||||
results: _workoutHistorySetResultsFromPayload(payload['results'], metadata),
|
||||
stepResults: _workoutHistoryStepResultsFromPayload(
|
||||
payload['stepResults'],
|
||||
@ -5142,6 +5325,7 @@ List<domain.ExerciseStep> _stepsFromPayload(Object? value) {
|
||||
json,
|
||||
'defaultTargetScoreTimeMs',
|
||||
),
|
||||
linkedToSeriesScore: json['linkedToSeriesScore'] == true,
|
||||
);
|
||||
})
|
||||
.toList(growable: false);
|
||||
@ -5487,6 +5671,7 @@ List<domain.ExerciseStep> _decodeExerciseStepsSnapshot(String? encoded) {
|
||||
json,
|
||||
'defaultTargetScoreTimeMs',
|
||||
),
|
||||
linkedToSeriesScore: json['linkedToSeriesScore'] == true,
|
||||
);
|
||||
})
|
||||
.toList(growable: false);
|
||||
|
||||
@ -137,7 +137,7 @@ class PendingShareActions extends Table {
|
||||
@override
|
||||
List<String> get customConstraints => [
|
||||
"CHECK (action_type IN ('send', 'accept', 'decline', 'revoke'))",
|
||||
"CHECK (resource_type IS NULL OR resource_type IN "
|
||||
'CHECK (resource_type IS NULL OR resource_type IN '
|
||||
"('program', 'workoutTemplate'))",
|
||||
"CHECK (status IN ('pending', 'succeeded', 'failed'))",
|
||||
];
|
||||
@ -250,6 +250,8 @@ class ExerciseSteps extends SyncableTable {
|
||||
TextColumn get scoreUnit => text().nullable()();
|
||||
RealColumn get defaultTargetScore => real().nullable()();
|
||||
IntColumn get defaultTargetScoreTimeMs => integer().nullable()();
|
||||
BoolColumn get linkedToSeriesScore =>
|
||||
boolean().withDefault(const Constant(false))();
|
||||
|
||||
@override
|
||||
List<String> get customConstraints => [
|
||||
@ -276,6 +278,8 @@ class ExerciseSteps extends SyncableTable {
|
||||
'AND default_target_score IS NULL))',
|
||||
'CHECK (default_target_score IS NULL OR '
|
||||
'default_target_score_time_ms IS NULL)',
|
||||
'CHECK (NOT linked_to_series_score OR '
|
||||
"(has_score AND score_input_mode = 'manual'))",
|
||||
];
|
||||
}
|
||||
|
||||
@ -548,6 +552,29 @@ class ActiveScoreStopwatchStates extends SyncableTable {
|
||||
];
|
||||
}
|
||||
|
||||
class ActiveManualScoreStates extends SyncableTable {
|
||||
@override
|
||||
String get tableName => 'active_manual_score_states';
|
||||
|
||||
TextColumn get activeWorkoutSessionId =>
|
||||
text().references(ActiveWorkoutSessions, #id)();
|
||||
IntColumn get programIndex => integer()();
|
||||
IntColumn get exerciseIndex => integer()();
|
||||
IntColumn get setIndex => integer()();
|
||||
RealColumn get value => real()();
|
||||
DateTimeColumn get scoreUpdatedAt => dateTime()();
|
||||
|
||||
@override
|
||||
List<String> get customConstraints => [
|
||||
'UNIQUE (active_workout_session_id, program_index, exercise_index, '
|
||||
'set_index)',
|
||||
'CHECK (program_index >= 0)',
|
||||
'CHECK (exercise_index >= 0)',
|
||||
'CHECK (set_index >= 0)',
|
||||
'CHECK (value >= 0)',
|
||||
];
|
||||
}
|
||||
|
||||
class ActiveSetTimerStates extends SyncableTable {
|
||||
@override
|
||||
String get tableName => 'active_set_timer_states';
|
||||
@ -723,9 +750,15 @@ class WorkoutHistories extends SyncableTable {
|
||||
IntColumn get totalActiveMs => integer()();
|
||||
BoolColumn get completed => boolean()();
|
||||
TextColumn get historySnapshotJson => text().withLength(min: 1)();
|
||||
RealColumn get averageHeartRateBpm => real().nullable()();
|
||||
IntColumn get maxHeartRateBpm => integer().nullable()();
|
||||
|
||||
@override
|
||||
List<String> get customConstraints => ['CHECK (total_active_ms >= 0)'];
|
||||
List<String> get customConstraints => [
|
||||
'CHECK (total_active_ms >= 0)',
|
||||
'CHECK (average_heart_rate_bpm IS NULL OR average_heart_rate_bpm > 0)',
|
||||
'CHECK (max_heart_rate_bpm IS NULL OR max_heart_rate_bpm > 0)',
|
||||
];
|
||||
}
|
||||
|
||||
class WorkoutHistorySetResults extends SyncableTable {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
@ -12,11 +13,28 @@ final class HttpApiClient {
|
||||
this.timeout = const Duration(seconds: 10),
|
||||
}) : client = client ?? http.Client();
|
||||
|
||||
static const defaultBaseUrl = String.fromEnvironment(
|
||||
static const _configuredBaseUrl = String.fromEnvironment(
|
||||
'GAMETIME_API_BASE_URL',
|
||||
defaultValue: 'http://localhost:8080',
|
||||
defaultValue: '',
|
||||
);
|
||||
|
||||
static String get defaultBaseUrl =>
|
||||
defaultBaseUrlFor(isAndroid: Platform.isAndroid);
|
||||
|
||||
static String defaultBaseUrlFor({
|
||||
required bool isAndroid,
|
||||
String configuredBaseUrl = _configuredBaseUrl,
|
||||
}) {
|
||||
final configured = configuredBaseUrl.trim();
|
||||
if (configured.isNotEmpty) {
|
||||
return configured;
|
||||
}
|
||||
if (isAndroid) {
|
||||
return 'http://10.0.2.2:8080';
|
||||
}
|
||||
return 'http://localhost:8080';
|
||||
}
|
||||
|
||||
final Uri baseUrl;
|
||||
final http.Client client;
|
||||
final Duration timeout;
|
||||
@ -142,7 +160,7 @@ final class HttpApiClient {
|
||||
return switch (statusCode) {
|
||||
401 => RemoteAuthException(RemoteAuthFailure.invalidCredentials, message),
|
||||
409 => RemoteAuthException(RemoteAuthFailure.emailAlreadyUsed, message),
|
||||
>= 500 => RemoteAuthException(RemoteAuthFailure.network, message),
|
||||
>= 500 => RemoteAuthException(RemoteAuthFailure.server, message),
|
||||
_ => RemoteAuthException(RemoteAuthFailure.unknown, message),
|
||||
};
|
||||
}
|
||||
|
||||
@ -0,0 +1 @@
|
||||
export 'session_notification_gateway.dart';
|
||||
@ -0,0 +1,33 @@
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../../application/application.dart';
|
||||
|
||||
final class MethodChannelSessionNotificationGateway
|
||||
implements SessionNotificationGateway {
|
||||
const MethodChannelSessionNotificationGateway({
|
||||
MethodChannel methodChannel = const MethodChannel(_methodChannelName),
|
||||
}) : _methodChannel = methodChannel;
|
||||
|
||||
static const _methodChannelName = 'gametime.session_notification/methods';
|
||||
|
||||
final MethodChannel _methodChannel;
|
||||
|
||||
@override
|
||||
Future<void> show(SessionNotificationContent content) {
|
||||
return _invokeIgnoringMissingPlugin('show', content.toJson());
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> clear() {
|
||||
return _invokeIgnoringMissingPlugin('clear');
|
||||
}
|
||||
|
||||
Future<void> _invokeIgnoringMissingPlugin(
|
||||
String method, [
|
||||
Object? arguments,
|
||||
]) {
|
||||
return _methodChannel
|
||||
.invokeMethod<void>(method, arguments)
|
||||
.onError<MissingPluginException>((_, _) {});
|
||||
}
|
||||
}
|
||||
@ -16,6 +16,10 @@ final class WatchBridgeConnectionEvent {
|
||||
abstract interface class WatchBridgeNativeChannel {
|
||||
Stream<WatchCommandEnvelope> get commands;
|
||||
|
||||
Stream<WatchSensorSummary> get sensorSummaries;
|
||||
|
||||
Stream<WatchSensorSample> get sensorSamples;
|
||||
|
||||
Stream<WatchBridgeConnectionEvent> get connectionEvents;
|
||||
|
||||
Future<void> publishProjection(WatchSessionProjection projection);
|
||||
@ -38,17 +42,31 @@ final class MethodChannelWatchBridgeNativeChannel
|
||||
const MethodChannelWatchBridgeNativeChannel({
|
||||
MethodChannel methodChannel = const MethodChannel(_methodChannelName),
|
||||
EventChannel commandChannel = const EventChannel(_commandChannelName),
|
||||
EventChannel sensorSummaryChannel = const EventChannel(
|
||||
_sensorSummaryChannelName,
|
||||
),
|
||||
EventChannel sensorSampleChannel = const EventChannel(
|
||||
_sensorSampleChannelName,
|
||||
),
|
||||
EventChannel connectionChannel = const EventChannel(_connectionChannelName),
|
||||
}) : _methodChannel = methodChannel,
|
||||
_commandChannel = commandChannel,
|
||||
_sensorSummaryChannel = sensorSummaryChannel,
|
||||
_sensorSampleChannel = sensorSampleChannel,
|
||||
_connectionChannel = connectionChannel;
|
||||
|
||||
static const _methodChannelName = 'gametime.watch_bridge/methods';
|
||||
static const _commandChannelName = 'gametime.watch_bridge/commands';
|
||||
static const _sensorSummaryChannelName =
|
||||
'gametime.watch_bridge/sensor_summaries';
|
||||
static const _sensorSampleChannelName =
|
||||
'gametime.watch_bridge/sensor_samples';
|
||||
static const _connectionChannelName = 'gametime.watch_bridge/connection';
|
||||
|
||||
final MethodChannel _methodChannel;
|
||||
final EventChannel _commandChannel;
|
||||
final EventChannel _sensorSummaryChannel;
|
||||
final EventChannel _sensorSampleChannel;
|
||||
final EventChannel _connectionChannel;
|
||||
|
||||
@override
|
||||
@ -63,6 +81,30 @@ final class MethodChannelWatchBridgeNativeChannel
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<WatchSensorSummary> get sensorSummaries {
|
||||
return _sensorSummaryChannel
|
||||
.receiveBroadcastStream()
|
||||
.where((event) {
|
||||
return event is Map;
|
||||
})
|
||||
.map((event) {
|
||||
return WatchSensorSummary.fromJson(_stringObjectMap(event));
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<WatchSensorSample> get sensorSamples {
|
||||
return _sensorSampleChannel
|
||||
.receiveBroadcastStream()
|
||||
.where((event) {
|
||||
return event is Map;
|
||||
})
|
||||
.map((event) {
|
||||
return WatchSensorSample.fromJson(_stringObjectMap(event));
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<WatchBridgeConnectionEvent> get connectionEvents {
|
||||
return _connectionChannel
|
||||
|
||||
@ -2,6 +2,7 @@ import 'dart:async';
|
||||
|
||||
import 'package:watch_bridge_contract/watch_bridge_contract.dart';
|
||||
|
||||
import '../../application/use_cases.dart';
|
||||
import '../../application/watch_companion_use_cases.dart';
|
||||
import 'native_watch_bridge_channel.dart';
|
||||
|
||||
@ -10,20 +11,26 @@ final class WatchWearDataLayerAdapter implements WatchProjectionPublisher {
|
||||
required WatchBridgeNativeChannel nativeChannel,
|
||||
required WatchCommandIngress commandIngress,
|
||||
required WatchProjectionSource projectionSource,
|
||||
Duration heartbeatInterval = const Duration(seconds: 5),
|
||||
WorkoutHistoryUseCases? workoutHistoryUseCases,
|
||||
ActiveWorkoutSensorUseCases? activeWorkoutSensorUseCases,
|
||||
Duration projectionRefreshInterval = const Duration(seconds: 2),
|
||||
}) : _nativeChannel = nativeChannel,
|
||||
_commandIngress = commandIngress,
|
||||
_projectionSource = projectionSource,
|
||||
_heartbeatInterval = heartbeatInterval;
|
||||
_workoutHistoryUseCases = workoutHistoryUseCases,
|
||||
_activeWorkoutSensorUseCases = activeWorkoutSensorUseCases,
|
||||
_projectionRefreshInterval = projectionRefreshInterval;
|
||||
|
||||
final WatchBridgeNativeChannel _nativeChannel;
|
||||
final WatchCommandIngress _commandIngress;
|
||||
final WatchProjectionSource _projectionSource;
|
||||
final Duration _heartbeatInterval;
|
||||
final WorkoutHistoryUseCases? _workoutHistoryUseCases;
|
||||
final ActiveWorkoutSensorUseCases? _activeWorkoutSensorUseCases;
|
||||
final Duration _projectionRefreshInterval;
|
||||
final _commandAcks = <_WatchAdapterCommandKey, WatchCommandAck>{};
|
||||
final _subscriptions = <StreamSubscription<dynamic>>[];
|
||||
Future<void> _commandTail = Future<void>.value();
|
||||
Timer? _heartbeatTimer;
|
||||
Timer? _projectionRefreshTimer;
|
||||
WatchSessionProjection? _latestProjection;
|
||||
bool _started = false;
|
||||
bool _foregroundActive = false;
|
||||
@ -33,6 +40,7 @@ final class WatchWearDataLayerAdapter implements WatchProjectionPublisher {
|
||||
return;
|
||||
}
|
||||
_started = true;
|
||||
_ensureProjectionRefreshLoop();
|
||||
_subscriptions.add(
|
||||
_projectionSource.projections.listen((projection) {
|
||||
unawaited(publish(projection));
|
||||
@ -43,6 +51,22 @@ final class WatchWearDataLayerAdapter implements WatchProjectionPublisher {
|
||||
unawaited(_enqueueCommand(command));
|
||||
}),
|
||||
);
|
||||
final workoutHistoryUseCases = _workoutHistoryUseCases;
|
||||
if (workoutHistoryUseCases != null) {
|
||||
_subscriptions.add(
|
||||
_nativeChannel.sensorSummaries.listen((summary) {
|
||||
unawaited(workoutHistoryUseCases.updateHeartRateSummary(summary));
|
||||
}),
|
||||
);
|
||||
}
|
||||
final activeWorkoutSensorUseCases = _activeWorkoutSensorUseCases;
|
||||
if (activeWorkoutSensorUseCases != null) {
|
||||
_subscriptions.add(
|
||||
_nativeChannel.sensorSamples.listen((sample) {
|
||||
activeWorkoutSensorUseCases.recordTelemetrySample(sample);
|
||||
}),
|
||||
);
|
||||
}
|
||||
_subscriptions.add(
|
||||
_nativeChannel.connectionEvents.listen((event) {
|
||||
if (event.isReachable || event.requestsResync) {
|
||||
@ -55,8 +79,8 @@ final class WatchWearDataLayerAdapter implements WatchProjectionPublisher {
|
||||
}
|
||||
|
||||
Future<void> stop() async {
|
||||
_heartbeatTimer?.cancel();
|
||||
_heartbeatTimer = null;
|
||||
_projectionRefreshTimer?.cancel();
|
||||
_projectionRefreshTimer = null;
|
||||
for (final subscription in _subscriptions) {
|
||||
await subscription.cancel();
|
||||
}
|
||||
@ -66,10 +90,16 @@ final class WatchWearDataLayerAdapter implements WatchProjectionPublisher {
|
||||
|
||||
@override
|
||||
Future<void> publish(WatchSessionProjection projection) async {
|
||||
final previousProjection = _latestProjection;
|
||||
_latestProjection = projection;
|
||||
if (projection.phase == WatchSessionPhase.noActiveSession) {
|
||||
final previousSessionId = previousProjection?.deviceSessionId;
|
||||
if (previousSessionId != null && previousSessionId.isNotEmpty) {
|
||||
_activeWorkoutSensorUseCases?.clear(previousSessionId);
|
||||
}
|
||||
}
|
||||
await _nativeChannel.publishProjection(projection);
|
||||
await _syncForegroundService(projection);
|
||||
_syncHeartbeat(projection);
|
||||
}
|
||||
|
||||
Future<void> _enqueueCommand(WatchCommandEnvelope command) {
|
||||
@ -136,26 +166,13 @@ final class WatchWearDataLayerAdapter implements WatchProjectionPublisher {
|
||||
}
|
||||
}
|
||||
|
||||
void _syncHeartbeat(WatchSessionProjection projection) {
|
||||
if (!_hasRunningTimer(projection)) {
|
||||
_heartbeatTimer?.cancel();
|
||||
_heartbeatTimer = null;
|
||||
return;
|
||||
}
|
||||
_heartbeatTimer ??= Timer.periodic(_heartbeatInterval, (_) {
|
||||
void _ensureProjectionRefreshLoop() {
|
||||
_projectionRefreshTimer ??= Timer.periodic(_projectionRefreshInterval, (_) {
|
||||
unawaited(_projectionSource.emitCurrentProjection());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
bool _hasRunningTimer(WatchSessionProjection projection) {
|
||||
final timers = [
|
||||
if (projection.dominantTimer != null) projection.dominantTimer!,
|
||||
...projection.secondaryTimers,
|
||||
];
|
||||
return timers.any((timer) => timer.runState == WatchTimerRunState.running);
|
||||
}
|
||||
|
||||
final class _WatchAdapterCommandKey {
|
||||
_WatchAdapterCommandKey(WatchCommandEnvelope command)
|
||||
: sessionId = command.sessionId,
|
||||
|
||||
Reference in New Issue
Block a user