merge(develop): corrige applicationId et signature partagée de l'APK montre

Intègre le correctif de packaging/signature de watch_app (applicationId
com.gametime.watch, signing partagé nettoyé) qui répare la détection du
bridge côté montre après le reset/remerge de develop. QA verte.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-28 12:18:55 +02:00
6 changed files with 192 additions and 60 deletions

View File

@ -1,3 +1,5 @@
import java.util.Properties
plugins { plugins {
id("com.android.application") id("com.android.application")
id("org.jetbrains.kotlin.android") id("org.jetbrains.kotlin.android")
@ -5,6 +7,26 @@ plugins {
id("dev.flutter.flutter-gradle-plugin") 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 { android {
namespace = "com.gametime.app" namespace = "com.gametime.app"
compileSdk = flutter.compileSdkVersion compileSdk = flutter.compileSdkVersion
@ -23,11 +45,18 @@ android {
versionName = flutter.versionName versionName = flutter.versionName
} }
signingConfigs {
create("sharedLocal") {
storeFile = sharedSigningStoreFile
storePassword = requiredSharedSigningProperty("storePassword")
keyAlias = requiredSharedSigningProperty("keyAlias")
keyPassword = requiredSharedSigningProperty("keyPassword")
}
}
buildTypes { buildTypes {
release { release {
// TODO: Add your own signing config for the release build. signingConfig = signingConfigs.getByName("sharedLocal")
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
} }
} }
} }

View File

@ -5647,41 +5647,68 @@ double _estimatedCaloriesKcal({
} }
final class WorkoutHistoryUseCases { final class WorkoutHistoryUseCases {
const WorkoutHistoryUseCases({required this.repository, required this.clock}); WorkoutHistoryUseCases({required this.repository, required this.clock});
final WorkoutHistoryRepository repository; final WorkoutHistoryRepository repository;
final Clock clock; final Clock clock;
final _pendingHeartRateSummaries = <String, WatchSensorSummary>{};
Future<WorkoutHistory?> findById(String id) => repository.findById(id); Future<WorkoutHistory?> 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<List<WorkoutHistory>> listActive() => repository.listActive(); Future<List<WorkoutHistory>> listActive() async {
final histories = await repository.listActive();
final changed = await _applyPendingHeartRateSummaries(histories);
return changed ? repository.listActive() : histories;
}
Future<void> delete(String id) => repository.delete(id, clock.now()); Future<void> delete(String id) => repository.delete(id, clock.now());
Future<void> updateHeartRateSummary(WatchSensorSummary summary) async { Future<void> updateHeartRateSummary(WatchSensorSummary summary) async {
if (summary.sampleCount < 3 || if (!_isUsableHeartRateSummary(summary)) {
summary.averageHeartRateBpm == null ||
summary.maxHeartRateBpm == null) {
return;
}
if (summary.sessionId.trim().isEmpty ||
summary.averageHeartRateBpm! <= 0 ||
summary.maxHeartRateBpm! <= 0) {
return; return;
} }
final sessionId = summary.sessionId.trim();
final histories = await repository.listActive(); final histories = await repository.listActive();
WorkoutHistory? history; final applied = await _applyHeartRateSummary(histories, summary);
for (final candidate in histories) { if (applied) {
if (candidate.metadata.id == summary.sessionId || _pendingHeartRateSummaries.remove(sessionId);
candidate.sourceActiveWorkoutSessionId == summary.sessionId) { return;
history = candidate; }
break; if (!_hasHistoryForSummary(histories, summary)) {
_pendingHeartRateSummaries.putIfAbsent(sessionId, () => summary);
}
}
Future<bool> _applyPendingHeartRateSummaries(
List<WorkoutHistory> 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<bool> _applyHeartRateSummary(
List<WorkoutHistory> histories,
WatchSensorSummary summary,
) async {
final history = _findHistoryForSummary(histories, summary);
if (history == null || if (history == null ||
history.averageHeartRateBpm != null || history.averageHeartRateBpm != null ||
history.maxHeartRateBpm != null) { history.maxHeartRateBpm != null) {
return; return false;
} }
await repository.patchHeartRateSummary( await repository.patchHeartRateSummary(
historyId: history.metadata.id, historyId: history.metadata.id,
@ -5689,6 +5716,37 @@ final class WorkoutHistoryUseCases {
maxHeartRateBpm: summary.maxHeartRateBpm!, maxHeartRateBpm: summary.maxHeartRateBpm!,
patchedAt: clock.now(), patchedAt: clock.now(),
); );
return true;
}
bool _hasHistoryForSummary(
List<WorkoutHistory> histories,
WatchSensorSummary summary,
) {
return _findHistoryForSummary(histories, summary) != null;
}
WorkoutHistory? _findHistoryForSummary(
List<WorkoutHistory> 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;
} }
} }

View File

@ -2864,6 +2864,53 @@ void main() {
expect(repository.histories.single.maxHeartRateBpm, 171); 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( test(
'ActiveWorkoutSensorUseCases tracks live heart rate and calories', 'ActiveWorkoutSensorUseCases tracks live heart rate and calories',
() async { () async {

View File

@ -1,9 +1,32 @@
import java.util.Properties
plugins { plugins {
id("com.android.application") id("com.android.application")
id("org.jetbrains.kotlin.android") id("org.jetbrains.kotlin.android")
id("dev.flutter.flutter-gradle-plugin") 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 { android {
namespace = "com.gametime.watch" namespace = "com.gametime.watch"
compileSdk = flutter.compileSdkVersion compileSdk = flutter.compileSdkVersion
@ -15,16 +38,25 @@ android {
} }
defaultConfig { defaultConfig {
applicationId = "com.gametime.app" applicationId = "com.gametime.watch"
minSdk = 30 minSdk = 30
targetSdk = flutter.targetSdkVersion targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode versionCode = flutter.versionCode
versionName = flutter.versionName versionName = flutter.versionName
} }
signingConfigs {
create("sharedLocal") {
storeFile = sharedSigningStoreFile
storePassword = requiredSharedSigningProperty("storePassword")
keyAlias = requiredSharedSigningProperty("keyAlias")
keyPassword = requiredSharedSigningProperty("keyPassword")
}
}
buildTypes { buildTypes {
release { release {
signingConfig = signingConfigs.getByName("debug") signingConfig = signingConfigs.getByName("sharedLocal")
} }
} }
} }

View File

@ -679,10 +679,6 @@ final class _ActiveContent extends StatelessWidget {
_SmallLabel(dominantLabel), _SmallLabel(dominantLabel),
const SizedBox(height: 2), const SizedBox(height: 2),
_DominantValue(dominantValue), _DominantValue(dominantValue),
if (_heartRateLabel(state.sensorSample) case final heartRate?) ...[
const SizedBox(height: 1),
_LiveHeartRateLine(heartRate),
],
if (timer != null) ...[ if (timer != null) ...[
const SizedBox(height: 3), const SizedBox(height: 3),
_TimerToggleButton( _TimerToggleButton(
@ -796,10 +792,6 @@ final class _ManualScoreContent extends StatelessWidget {
? const _PendingDot() ? const _PendingDot()
: const SizedBox.shrink(), : const SizedBox.shrink(),
), ),
if (_heartRateLabel(state.sensorSample) case final heartRate?) ...[
const SizedBox(height: 1),
_LiveHeartRateLine(heartRate),
],
const SizedBox(height: 5), const SizedBox(height: 5),
if (timer != null) if (timer != null)
_CompactTimerLine( _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 { final class _RestContent extends StatelessWidget {
const _RestContent({required this.state, required this.onTogglePause}); const _RestContent({required this.state, required this.onTogglePause});

View File

@ -212,7 +212,7 @@ void main() {
viewModel.dispose(); viewModel.dispose();
}); });
testWidgets('shows live heart rate on session and telemetry on stats page', ( testWidgets('shows telemetry only on stats page when available', (
tester, tester,
) async { ) async {
final client = _FakeNativeWatchBridgeClient(); final client = _FakeNativeWatchBridgeClient();
@ -245,7 +245,7 @@ void main() {
); );
await tester.pump(); await tester.pump();
expect(find.text('142 bpm'), findsOneWidget); expect(find.text('142 bpm'), findsNothing);
expect(find.byTooltip('Stats'), findsOneWidget); expect(find.byTooltip('Stats'), findsOneWidget);
await tester.drag( await tester.drag(
@ -261,6 +261,7 @@ void main() {
expect(find.text('Stats'), findsOneWidget); expect(find.text('Stats'), findsOneWidget);
expect(find.text('FC'), findsOneWidget); expect(find.text('FC'), findsOneWidget);
expect(find.text('142 bpm'), findsOneWidget);
expect(find.text('840 m'), findsOneWidget); expect(find.text('840 m'), findsOneWidget);
expect(find.text('186 kcal'), findsOneWidget); expect(find.text('186 kcal'), findsOneWidget);