diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 694f359..2d74980 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,3 +1,5 @@ +import java.util.Properties + plugins { id("com.android.application") id("org.jetbrains.kotlin.android") @@ -5,6 +7,26 @@ plugins { id("dev.flutter.flutter-gradle-plugin") } +val sharedSigningPropertiesFile = rootProject.file("key.properties") +val sharedSigningProperties = + Properties().also { properties -> + check(sharedSigningPropertiesFile.isFile) { + "Shared signing config missing: ${sharedSigningPropertiesFile.absolutePath}" + } + sharedSigningPropertiesFile.inputStream().use(properties::load) + } + +fun requiredSharedSigningProperty(name: String): String = + sharedSigningProperties.getProperty(name)?.takeIf { it.isNotBlank() } + ?: error("Shared signing property '$name' missing in ${sharedSigningPropertiesFile.absolutePath}") + +val sharedSigningStoreFile = + sharedSigningPropertiesFile.parentFile.resolve(requiredSharedSigningProperty("storeFile")) + +check(sharedSigningStoreFile.isFile) { + "Shared signing keystore missing: ${sharedSigningStoreFile.absolutePath}" +} + android { namespace = "com.gametime.app" compileSdk = flutter.compileSdkVersion @@ -23,11 +45,18 @@ android { versionName = flutter.versionName } + signingConfigs { + create("sharedLocal") { + storeFile = sharedSigningStoreFile + storePassword = requiredSharedSigningProperty("storePassword") + keyAlias = requiredSharedSigningProperty("keyAlias") + keyPassword = requiredSharedSigningProperty("keyPassword") + } + } + buildTypes { release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. - signingConfig = signingConfigs.getByName("debug") + signingConfig = signingConfigs.getByName("sharedLocal") } } } diff --git a/lib/application/use_cases.dart b/lib/application/use_cases.dart index 2540afb..0bc1638 100644 --- a/lib/application/use_cases.dart +++ b/lib/application/use_cases.dart @@ -5647,41 +5647,68 @@ double _estimatedCaloriesKcal({ } final class WorkoutHistoryUseCases { - const WorkoutHistoryUseCases({required this.repository, required this.clock}); + WorkoutHistoryUseCases({required this.repository, required this.clock}); final WorkoutHistoryRepository repository; final Clock clock; + final _pendingHeartRateSummaries = {}; - Future findById(String id) => repository.findById(id); + Future findById(String id) async { + final history = await repository.findById(id); + if (history == null) { + return null; + } + final changed = await _applyPendingHeartRateSummaries([history]); + return changed ? repository.findById(id) : history; + } - Future> listActive() => repository.listActive(); + Future> listActive() async { + final histories = await repository.listActive(); + final changed = await _applyPendingHeartRateSummaries(histories); + return changed ? repository.listActive() : histories; + } Future delete(String id) => repository.delete(id, clock.now()); Future updateHeartRateSummary(WatchSensorSummary summary) async { - if (summary.sampleCount < 3 || - summary.averageHeartRateBpm == null || - summary.maxHeartRateBpm == null) { - return; - } - if (summary.sessionId.trim().isEmpty || - summary.averageHeartRateBpm! <= 0 || - summary.maxHeartRateBpm! <= 0) { + if (!_isUsableHeartRateSummary(summary)) { return; } + final sessionId = summary.sessionId.trim(); final histories = await repository.listActive(); - WorkoutHistory? history; - for (final candidate in histories) { - if (candidate.metadata.id == summary.sessionId || - candidate.sourceActiveWorkoutSessionId == summary.sessionId) { - history = candidate; - break; + final applied = await _applyHeartRateSummary(histories, summary); + if (applied) { + _pendingHeartRateSummaries.remove(sessionId); + return; + } + if (!_hasHistoryForSummary(histories, summary)) { + _pendingHeartRateSummaries.putIfAbsent(sessionId, () => summary); + } + } + + Future _applyPendingHeartRateSummaries( + List histories, + ) async { + var changed = false; + for (final summary in _pendingHeartRateSummaries.values.toList()) { + final applied = await _applyHeartRateSummary(histories, summary); + if (applied) { + _pendingHeartRateSummaries.remove(summary.sessionId.trim()); + changed = true; } } + return changed; + } + + Future _applyHeartRateSummary( + List histories, + WatchSensorSummary summary, + ) async { + final history = _findHistoryForSummary(histories, summary); if (history == null || history.averageHeartRateBpm != null || history.maxHeartRateBpm != null) { - return; + return false; } await repository.patchHeartRateSummary( historyId: history.metadata.id, @@ -5689,6 +5716,37 @@ final class WorkoutHistoryUseCases { maxHeartRateBpm: summary.maxHeartRateBpm!, patchedAt: clock.now(), ); + return true; + } + + bool _hasHistoryForSummary( + List histories, + WatchSensorSummary summary, + ) { + return _findHistoryForSummary(histories, summary) != null; + } + + WorkoutHistory? _findHistoryForSummary( + List histories, + WatchSensorSummary summary, + ) { + final sessionId = summary.sessionId.trim(); + for (final candidate in histories) { + if (candidate.metadata.id == sessionId || + candidate.sourceActiveWorkoutSessionId == sessionId) { + return candidate; + } + } + return null; + } + + bool _isUsableHeartRateSummary(WatchSensorSummary summary) { + return summary.sampleCount >= 3 && + summary.sessionId.trim().isNotEmpty && + summary.averageHeartRateBpm != null && + summary.averageHeartRateBpm! > 0 && + summary.maxHeartRateBpm != null && + summary.maxHeartRateBpm! > 0; } } diff --git a/test/application/use_cases_test.dart b/test/application/use_cases_test.dart index 91ca9cf..de1c134 100644 --- a/test/application/use_cases_test.dart +++ b/test/application/use_cases_test.dart @@ -2864,6 +2864,53 @@ void main() { expect(repository.histories.single.maxHeartRateBpm, 171); }); + test( + 'WorkoutHistoryUseCases applies pending heart rate summary when history appears', + () async { + final repository = _FakeWorkoutHistoryRepository(); + final useCase = WorkoutHistoryUseCases( + repository: repository, + clock: _FakeClock(DateTime.utc(2026, 7, 25, 12, 1)), + ); + + await useCase.updateHeartRateSummary( + const WatchSensorSummary( + sessionId: 'session-1', + sampleCount: 12, + averageHeartRateBpm: 126.5, + maxHeartRateBpm: 171, + ), + ); + repository.histories.add( + WorkoutHistory( + metadata: _metadata('history-1'), + sourceActiveWorkoutSessionId: 'session-1', + nameSnapshot: 'Seance', + startedAt: DateTime.utc(2026, 7, 25, 11), + endedAt: DateTime.utc(2026, 7, 25, 12), + totalActiveMs: 3600000, + completed: true, + historySnapshotJson: '{"programs":[]}', + ), + ); + + final histories = await useCase.listActive(); + + expect(histories.single.averageHeartRateBpm, 126.5); + expect(histories.single.maxHeartRateBpm, 171); + await useCase.updateHeartRateSummary( + const WatchSensorSummary( + sessionId: 'session-1', + sampleCount: 14, + averageHeartRateBpm: 130, + maxHeartRateBpm: 180, + ), + ); + expect(repository.histories.single.averageHeartRateBpm, 126.5); + expect(repository.histories.single.maxHeartRateBpm, 171); + }, + ); + test( 'ActiveWorkoutSensorUseCases tracks live heart rate and calories', () async { diff --git a/watch_app/android/app/build.gradle.kts b/watch_app/android/app/build.gradle.kts index 58bdfba..b57f29d 100644 --- a/watch_app/android/app/build.gradle.kts +++ b/watch_app/android/app/build.gradle.kts @@ -1,9 +1,32 @@ +import java.util.Properties + plugins { id("com.android.application") id("org.jetbrains.kotlin.android") id("dev.flutter.flutter-gradle-plugin") } +val sharedSigningPropertiesFile = + rootProject.projectDir.parentFile.parentFile.resolve("android/key.properties") +val sharedSigningProperties = + Properties().also { properties -> + check(sharedSigningPropertiesFile.isFile) { + "Shared signing config missing: ${sharedSigningPropertiesFile.absolutePath}" + } + sharedSigningPropertiesFile.inputStream().use(properties::load) + } + +fun requiredSharedSigningProperty(name: String): String = + sharedSigningProperties.getProperty(name)?.takeIf { it.isNotBlank() } + ?: error("Shared signing property '$name' missing in ${sharedSigningPropertiesFile.absolutePath}") + +val sharedSigningStoreFile = + sharedSigningPropertiesFile.parentFile.resolve(requiredSharedSigningProperty("storeFile")) + +check(sharedSigningStoreFile.isFile) { + "Shared signing keystore missing: ${sharedSigningStoreFile.absolutePath}" +} + android { namespace = "com.gametime.watch" compileSdk = flutter.compileSdkVersion @@ -15,16 +38,25 @@ android { } defaultConfig { - applicationId = "com.gametime.app" + applicationId = "com.gametime.watch" minSdk = 30 targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName } + signingConfigs { + create("sharedLocal") { + storeFile = sharedSigningStoreFile + storePassword = requiredSharedSigningProperty("storePassword") + keyAlias = requiredSharedSigningProperty("keyAlias") + keyPassword = requiredSharedSigningProperty("keyPassword") + } + } + buildTypes { release { - signingConfig = signingConfigs.getByName("debug") + signingConfig = signingConfigs.getByName("sharedLocal") } } } diff --git a/watch_app/lib/presentation/watch_session_screen.dart b/watch_app/lib/presentation/watch_session_screen.dart index 44f5ae2..ce7faa4 100644 --- a/watch_app/lib/presentation/watch_session_screen.dart +++ b/watch_app/lib/presentation/watch_session_screen.dart @@ -679,10 +679,6 @@ final class _ActiveContent extends StatelessWidget { _SmallLabel(dominantLabel), const SizedBox(height: 2), _DominantValue(dominantValue), - if (_heartRateLabel(state.sensorSample) case final heartRate?) ...[ - const SizedBox(height: 1), - _LiveHeartRateLine(heartRate), - ], if (timer != null) ...[ const SizedBox(height: 3), _TimerToggleButton( @@ -796,10 +792,6 @@ final class _ManualScoreContent extends StatelessWidget { ? const _PendingDot() : const SizedBox.shrink(), ), - if (_heartRateLabel(state.sensorSample) case final heartRate?) ...[ - const SizedBox(height: 1), - _LiveHeartRateLine(heartRate), - ], const SizedBox(height: 5), if (timer != null) _CompactTimerLine( @@ -942,33 +934,6 @@ final class _PendingDot extends StatelessWidget { } } -final class _LiveHeartRateLine extends StatelessWidget { - const _LiveHeartRateLine(this.label); - - final String label; - - @override - Widget build(BuildContext context) { - return Row( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.favorite, size: 11, color: Color(0xFFD72638)), - const SizedBox(width: 3), - Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: const Color(0xFFA7ADBA), - fontSize: 10, - ), - ), - ], - ); - } -} - final class _RestContent extends StatelessWidget { const _RestContent({required this.state, required this.onTogglePause}); diff --git a/watch_app/test/presentation/watch_session_screen_test.dart b/watch_app/test/presentation/watch_session_screen_test.dart index 24b7a04..fd250fb 100644 --- a/watch_app/test/presentation/watch_session_screen_test.dart +++ b/watch_app/test/presentation/watch_session_screen_test.dart @@ -212,7 +212,7 @@ void main() { viewModel.dispose(); }); - testWidgets('shows live heart rate on session and telemetry on stats page', ( + testWidgets('shows telemetry only on stats page when available', ( tester, ) async { final client = _FakeNativeWatchBridgeClient(); @@ -245,7 +245,7 @@ void main() { ); await tester.pump(); - expect(find.text('142 bpm'), findsOneWidget); + expect(find.text('142 bpm'), findsNothing); expect(find.byTooltip('Stats'), findsOneWidget); await tester.drag( @@ -261,6 +261,7 @@ void main() { expect(find.text('Stats'), findsOneWidget); expect(find.text('FC'), findsOneWidget); + expect(find.text('142 bpm'), findsOneWidget); expect(find.text('840 m'), findsOneWidget); expect(find.text('186 kcal'), findsOneWidget);