Commit 0a340fd3b6341aa5e7f36d7a38b0f7a7e50d2502
1 parent
c187bd36
feat(ui):修复长时间不回调,触发计算时推送多条通知
(cherry picked from commit 85d12a8b) # Conflicts: # lib/core/services/raw_data_service/platform_ios/apple_health_raw_local_notification.dart # test/core/services/health_raw_data_core_service_test.dart # test/core/services/health_raw_local_notification_test.dart
Showing
3 changed files
with
826 additions
and
50 deletions
| @@ -4,7 +4,7 @@ import 'dart:io'; | @@ -4,7 +4,7 @@ import 'dart:io'; | ||
| 4 | import 'package:path_provider/path_provider.dart'; | 4 | import 'package:path_provider/path_provider.dart'; |
| 5 | 5 | ||
| 6 | import '../../../../l10n/gen/app_localizations.dart'; | 6 | import '../../../../l10n/gen/app_localizations.dart'; |
| 7 | -import '../../../platform/pigeon_api_facade.dart'; | 7 | +import '../../../../pigeon/platform_api.g.dart'; |
| 8 | import '../health_raw_models.dart'; | 8 | import '../health_raw_models.dart'; |
| 9 | 9 | ||
| 10 | const healthRawTodayLink = 'doublefeel://flutter/home?tab=today'; | 10 | const healthRawTodayLink = 'doublefeel://flutter/home?tab=today'; |
| @@ -35,6 +35,8 @@ enum HealthRawLocalNotificationRecordType { | @@ -35,6 +35,8 @@ enum HealthRawLocalNotificationRecordType { | ||
| 35 | realtimeStress, | 35 | realtimeStress, |
| 36 | } | 36 | } |
| 37 | 37 | ||
| 38 | +typedef HealthRawSleepInterval = ({int startTime, int endTime}); | ||
| 39 | + | ||
| 38 | class HealthRawLocalNotificationBuilder { | 40 | class HealthRawLocalNotificationBuilder { |
| 39 | const HealthRawLocalNotificationBuilder(this.l10n); | 41 | const HealthRawLocalNotificationBuilder(this.l10n); |
| 40 | 42 | ||
| @@ -49,24 +51,50 @@ class HealthRawLocalNotificationBuilder { | @@ -49,24 +51,50 @@ class HealthRawLocalNotificationBuilder { | ||
| 49 | required bool hasExistingSleep, | 51 | required bool hasExistingSleep, |
| 50 | required List<HealthRawRealtimeStressPoint> realtimeWindow, | 52 | required List<HealthRawRealtimeStressPoint> realtimeWindow, |
| 51 | required HealthRawLocalNotificationRecord? record, | 53 | required HealthRawLocalNotificationRecord? record, |
| 54 | + List<HealthRawSleepInterval> realtimeSleepIntervals = const [], | ||
| 55 | + int? lastRealtimeStressPushSendTime, | ||
| 56 | + int? notificationBuildTime, | ||
| 52 | }) { | 57 | }) { |
| 53 | - return [ | 58 | + final buildTime = |
| 59 | + notificationBuildTime ?? DateTime.now().millisecondsSinceEpoch ~/ 1000; | ||
| 60 | + final latestRealtimeStressPoint = _latestRealtimeStressPoint( | ||
| 61 | + result.realtimeStressPoints, | ||
| 62 | + ); | ||
| 63 | + final resolvedRealtimeSleepIntervals = | ||
| 64 | + realtimeSleepIntervals.isEmpty && latestRealtimeStressPoint != null | ||
| 65 | + ? [_defaultSleepInterval(latestRealtimeStressPoint.rawEndTime)] | ||
| 66 | + : realtimeSleepIntervals; | ||
| 67 | + return _latestNotificationsByRecordType([ | ||
| 54 | if (_sleepNotification(result.sleepResults, hasExistingSleep) | 68 | if (_sleepNotification(result.sleepResults, hasExistingSleep) |
| 55 | case final notification?) | 69 | case final notification?) |
| 56 | notification, | 70 | notification, |
| 57 | - if (_hrvNotification(result.hrvStressPoints, hasExistingHrv) | ||
| 58 | - case final notification?) | ||
| 59 | - notification, | 71 | + ..._hrvNotifications(result.hrvStressPoints, hasExistingHrv), |
| 60 | if (_realtimeStressNotification( | 72 | if (_realtimeStressNotification( |
| 61 | realtimeWindow, | 73 | realtimeWindow, |
| 62 | record, | 74 | record, |
| 63 | - latestRealtimeStressPoint: _latestRealtimeStressPoint( | ||
| 64 | - result.realtimeStressPoints, | ||
| 65 | - ), | 75 | + latestRealtimeStressPoint: latestRealtimeStressPoint, |
| 76 | + sleepIntervals: resolvedRealtimeSleepIntervals, | ||
| 77 | + lastPushSendTime: lastRealtimeStressPushSendTime, | ||
| 78 | + notificationBuildTime: buildTime, | ||
| 66 | ) | 79 | ) |
| 67 | case final notification?) | 80 | case final notification?) |
| 68 | notification, | 81 | notification, |
| 69 | - ]; | 82 | + ]); |
| 83 | + } | ||
| 84 | + | ||
| 85 | + List<HealthRawLocalNotification> _latestNotificationsByRecordType( | ||
| 86 | + List<HealthRawLocalNotification> notifications, | ||
| 87 | + ) { | ||
| 88 | + if (notifications.length < 2) return notifications; | ||
| 89 | + final latestByType = | ||
| 90 | + <HealthRawLocalNotificationRecordType, HealthRawLocalNotification>{}; | ||
| 91 | + for (final notification in notifications) { | ||
| 92 | + final existing = latestByType[notification.recordType]; | ||
| 93 | + if (existing == null || notification.recordTime > existing.recordTime) { | ||
| 94 | + latestByType[notification.recordType] = notification; | ||
| 95 | + } | ||
| 96 | + } | ||
| 97 | + return latestByType.values.toList(growable: false); | ||
| 70 | } | 98 | } |
| 71 | 99 | ||
| 72 | HealthRawLocalNotification? _sleepNotification( | 100 | HealthRawLocalNotification? _sleepNotification( |
| @@ -89,33 +117,37 @@ class HealthRawLocalNotificationBuilder { | @@ -89,33 +117,37 @@ class HealthRawLocalNotificationBuilder { | ||
| 89 | ); | 117 | ); |
| 90 | } | 118 | } |
| 91 | 119 | ||
| 92 | - HealthRawLocalNotification? _hrvNotification( | 120 | + List<HealthRawLocalNotification> _hrvNotifications( |
| 93 | List<HealthRawHrvStressPoint> hrvPoints, | 121 | List<HealthRawHrvStressPoint> hrvPoints, |
| 94 | bool hasExistingHrv, | 122 | bool hasExistingHrv, |
| 95 | ) { | 123 | ) { |
| 96 | - if (!hasExistingHrv) return null; | ||
| 97 | - if (hrvPoints.isEmpty) return null; | 124 | + if (!hasExistingHrv) return const <HealthRawLocalNotification>[]; |
| 125 | + if (hrvPoints.isEmpty) return const <HealthRawLocalNotification>[]; | ||
| 98 | final sorted = [...hrvPoints] | 126 | final sorted = [...hrvPoints] |
| 99 | ..sort((a, b) => a.rawEndTime.compareTo(b.rawEndTime)); | 127 | ..sort((a, b) => a.rawEndTime.compareTo(b.rawEndTime)); |
| 100 | - final latest = sorted.last; | ||
| 101 | - | ||
| 102 | - return HealthRawLocalNotification( | ||
| 103 | - title: l10n.healthLocalNotificationHrvTitle( | ||
| 104 | - latest.result.floor(), | ||
| 105 | - _stressStateLabel(latest.state), | ||
| 106 | - _timeText(latest.rawEndTime), | ||
| 107 | - ), | ||
| 108 | - content: _hrvContent(latest), | ||
| 109 | - link: healthRawHrvChangeLink, | ||
| 110 | - recordType: HealthRawLocalNotificationRecordType.hrv, | ||
| 111 | - recordTime: latest.rawEndTime, | ||
| 112 | - ); | 128 | + return [ |
| 129 | + for (final point in sorted) | ||
| 130 | + HealthRawLocalNotification( | ||
| 131 | + title: l10n.healthLocalNotificationHrvTitle( | ||
| 132 | + point.result.floor(), | ||
| 133 | + _stressStateLabel(point.state), | ||
| 134 | + _timeText(point.rawEndTime), | ||
| 135 | + ), | ||
| 136 | + content: _hrvContent(point), | ||
| 137 | + link: healthRawHrvChangeLink, | ||
| 138 | + recordType: HealthRawLocalNotificationRecordType.hrv, | ||
| 139 | + recordTime: point.rawEndTime, | ||
| 140 | + ), | ||
| 141 | + ]; | ||
| 113 | } | 142 | } |
| 114 | 143 | ||
| 115 | HealthRawLocalNotification? _realtimeStressNotification( | 144 | HealthRawLocalNotification? _realtimeStressNotification( |
| 116 | List<HealthRawRealtimeStressPoint> realtimeWindow, | 145 | List<HealthRawRealtimeStressPoint> realtimeWindow, |
| 117 | HealthRawLocalNotificationRecord? record, { | 146 | HealthRawLocalNotificationRecord? record, { |
| 118 | required HealthRawRealtimeStressPoint? latestRealtimeStressPoint, | 147 | required HealthRawRealtimeStressPoint? latestRealtimeStressPoint, |
| 148 | + required List<HealthRawSleepInterval> sleepIntervals, | ||
| 149 | + required int? lastPushSendTime, | ||
| 150 | + required int notificationBuildTime, | ||
| 119 | }) { | 151 | }) { |
| 120 | if (latestRealtimeStressPoint == null) return null; | 152 | if (latestRealtimeStressPoint == null) return null; |
| 121 | if (latestRealtimeStressPoint.isWorkout || | 153 | if (latestRealtimeStressPoint.isWorkout || |
| @@ -123,16 +155,20 @@ class HealthRawLocalNotificationBuilder { | @@ -123,16 +155,20 @@ class HealthRawLocalNotificationBuilder { | ||
| 123 | return null; | 155 | return null; |
| 124 | } | 156 | } |
| 125 | 157 | ||
| 158 | + final latest = latestRealtimeStressPoint; | ||
| 159 | + if (_sleepIntervalContaining(latest.rawEndTime, sleepIntervals) != null) { | ||
| 160 | + return null; | ||
| 161 | + } | ||
| 162 | + | ||
| 126 | final valid = realtimeWindow | 163 | final valid = realtimeWindow |
| 127 | .where((e) => e.result >= 1 && e.result <= 100) | 164 | .where((e) => e.result >= 1 && e.result <= 100) |
| 128 | .toList() | 165 | .toList() |
| 129 | ..sort((a, b) => a.rawEndTime.compareTo(b.rawEndTime)); | 166 | ..sort((a, b) => a.rawEndTime.compareTo(b.rawEndTime)); |
| 130 | if (valid.length < _realtimeMinPointCount) return null; | 167 | if (valid.length < _realtimeMinPointCount) return null; |
| 131 | 168 | ||
| 132 | - final latest = latestRealtimeStressPoint; | ||
| 133 | if (latest.isSleepLikely) return null; | 169 | if (latest.isSleepLikely) return null; |
| 134 | - if (record?.lastRealtimeStressTime case final lastPushTime?) { | ||
| 135 | - if (latest.rawEndTime - lastPushTime < _realtimeWindowSeconds) { | 170 | + if (lastPushSendTime case final lastPushTime?) { |
| 171 | + if (notificationBuildTime - lastPushTime < _realtimeWindowSeconds) { | ||
| 136 | return null; | 172 | return null; |
| 137 | } | 173 | } |
| 138 | } | 174 | } |
| @@ -162,6 +198,29 @@ class HealthRawLocalNotificationBuilder { | @@ -162,6 +198,29 @@ class HealthRawLocalNotificationBuilder { | ||
| 162 | return points.reduce((a, b) => a.rawEndTime >= b.rawEndTime ? a : b); | 198 | return points.reduce((a, b) => a.rawEndTime >= b.rawEndTime ? a : b); |
| 163 | } | 199 | } |
| 164 | 200 | ||
| 201 | + HealthRawSleepInterval? _sleepIntervalContaining( | ||
| 202 | + int time, | ||
| 203 | + List<HealthRawSleepInterval> intervals, | ||
| 204 | + ) { | ||
| 205 | + for (final interval in intervals) { | ||
| 206 | + if (time >= interval.startTime && time <= interval.endTime) { | ||
| 207 | + return interval; | ||
| 208 | + } | ||
| 209 | + } | ||
| 210 | + return null; | ||
| 211 | + } | ||
| 212 | + | ||
| 213 | + HealthRawSleepInterval _defaultSleepInterval(int time) { | ||
| 214 | + final day = _localDayStart(time); | ||
| 215 | + return (startTime: day, endTime: day + 6 * 60 * 60); | ||
| 216 | + } | ||
| 217 | + | ||
| 218 | + int _localDayStart(int time) { | ||
| 219 | + final date = DateTime.fromMillisecondsSinceEpoch(time * 1000); | ||
| 220 | + return DateTime(date.year, date.month, date.day).millisecondsSinceEpoch ~/ | ||
| 221 | + 1000; | ||
| 222 | + } | ||
| 223 | + | ||
| 165 | String _sleepDurationText(int minutes) { | 224 | String _sleepDurationText(int minutes) { |
| 166 | final hours = minutes ~/ 60; | 225 | final hours = minutes ~/ 60; |
| 167 | final remainingMinutes = minutes % 60; | 226 | final remainingMinutes = minutes % 60; |
| @@ -227,12 +286,12 @@ class HealthRawLocalNotificationBuilder { | @@ -227,12 +286,12 @@ class HealthRawLocalNotificationBuilder { | ||
| 227 | 286 | ||
| 228 | class HealthRawLocalNotificationDispatcher { | 287 | class HealthRawLocalNotificationDispatcher { |
| 229 | HealthRawLocalNotificationDispatcher({ | 288 | HealthRawLocalNotificationDispatcher({ |
| 230 | - AppPlatformHostApi? platformApi, | 289 | + PlatformHostApi? platformApi, |
| 231 | HealthRawLocalNotificationRecordStore? recordStore, | 290 | HealthRawLocalNotificationRecordStore? recordStore, |
| 232 | - }) : _platformApi = platformApi ?? AppPlatformHostApi(), | 291 | + }) : _platformApi = platformApi ?? PlatformHostApi(), |
| 233 | _recordStore = recordStore ?? HealthRawLocalNotificationRecordStore(); | 292 | _recordStore = recordStore ?? HealthRawLocalNotificationRecordStore(); |
| 234 | 293 | ||
| 235 | - final AppPlatformHostApi _platformApi; | 294 | + final PlatformHostApi _platformApi; |
| 236 | final HealthRawLocalNotificationRecordStore _recordStore; | 295 | final HealthRawLocalNotificationRecordStore _recordStore; |
| 237 | 296 | ||
| 238 | Future<List<HealthRawLocalNotification>> sendAll({ | 297 | Future<List<HealthRawLocalNotification>> sendAll({ |
| @@ -289,6 +348,28 @@ class HealthRawLocalNotificationDispatcher { | @@ -289,6 +348,28 @@ class HealthRawLocalNotificationDispatcher { | ||
| 289 | record.copyWith(lastRealtimeStressTime: recordTime), | 348 | record.copyWith(lastRealtimeStressTime: recordTime), |
| 290 | ); | 349 | ); |
| 291 | } | 350 | } |
| 351 | + | ||
| 352 | + Future<void> updateProcessedDataTimes({ | ||
| 353 | + required int userId, | ||
| 354 | + int? latestHrvDataTime, | ||
| 355 | + int? latestRealtimeStressDataTime, | ||
| 356 | + int? latestSleepDataTime, | ||
| 357 | + }) async { | ||
| 358 | + if (latestHrvDataTime == null && | ||
| 359 | + latestRealtimeStressDataTime == null && | ||
| 360 | + latestSleepDataTime == null) { | ||
| 361 | + return; | ||
| 362 | + } | ||
| 363 | + final record = await _recordStore.read(userId); | ||
| 364 | + await _recordStore.write( | ||
| 365 | + userId, | ||
| 366 | + record.copyWith( | ||
| 367 | + latestHrvDataTime: latestHrvDataTime, | ||
| 368 | + latestRealtimeStressDataTime: latestRealtimeStressDataTime, | ||
| 369 | + latestSleepDataTime: latestSleepDataTime, | ||
| 370 | + ), | ||
| 371 | + ); | ||
| 372 | + } | ||
| 292 | } | 373 | } |
| 293 | 374 | ||
| 294 | class HealthRawLocalNotificationRecord { | 375 | class HealthRawLocalNotificationRecord { |
| @@ -296,11 +377,17 @@ class HealthRawLocalNotificationRecord { | @@ -296,11 +377,17 @@ class HealthRawLocalNotificationRecord { | ||
| 296 | this.lastSleepTime, | 377 | this.lastSleepTime, |
| 297 | this.lastHrvTime, | 378 | this.lastHrvTime, |
| 298 | this.lastRealtimeStressTime, | 379 | this.lastRealtimeStressTime, |
| 380 | + this.latestHrvDataTime, | ||
| 381 | + this.latestRealtimeStressDataTime, | ||
| 382 | + this.latestSleepDataTime, | ||
| 299 | }); | 383 | }); |
| 300 | 384 | ||
| 301 | final int? lastSleepTime; | 385 | final int? lastSleepTime; |
| 302 | final int? lastHrvTime; | 386 | final int? lastHrvTime; |
| 303 | final int? lastRealtimeStressTime; | 387 | final int? lastRealtimeStressTime; |
| 388 | + final int? latestHrvDataTime; | ||
| 389 | + final int? latestRealtimeStressDataTime; | ||
| 390 | + final int? latestSleepDataTime; | ||
| 304 | 391 | ||
| 305 | factory HealthRawLocalNotificationRecord.fromJson(Map<String, Object?> json) { | 392 | factory HealthRawLocalNotificationRecord.fromJson(Map<String, Object?> json) { |
| 306 | return HealthRawLocalNotificationRecord( | 393 | return HealthRawLocalNotificationRecord( |
| @@ -308,6 +395,10 @@ class HealthRawLocalNotificationRecord { | @@ -308,6 +395,10 @@ class HealthRawLocalNotificationRecord { | ||
| 308 | lastHrvTime: (json['last_hrv_time'] as num?)?.toInt(), | 395 | lastHrvTime: (json['last_hrv_time'] as num?)?.toInt(), |
| 309 | lastRealtimeStressTime: | 396 | lastRealtimeStressTime: |
| 310 | (json['last_realtime_stress_time'] as num?)?.toInt(), | 397 | (json['last_realtime_stress_time'] as num?)?.toInt(), |
| 398 | + latestHrvDataTime: (json['latest_hrv_data_time'] as num?)?.toInt(), | ||
| 399 | + latestRealtimeStressDataTime: | ||
| 400 | + (json['latest_realtime_stress_data_time'] as num?)?.toInt(), | ||
| 401 | + latestSleepDataTime: (json['latest_sleep_data_time'] as num?)?.toInt(), | ||
| 311 | ); | 402 | ); |
| 312 | } | 403 | } |
| 313 | 404 | ||
| @@ -317,6 +408,11 @@ class HealthRawLocalNotificationRecord { | @@ -317,6 +408,11 @@ class HealthRawLocalNotificationRecord { | ||
| 317 | if (lastHrvTime != null) 'last_hrv_time': lastHrvTime, | 408 | if (lastHrvTime != null) 'last_hrv_time': lastHrvTime, |
| 318 | if (lastRealtimeStressTime != null) | 409 | if (lastRealtimeStressTime != null) |
| 319 | 'last_realtime_stress_time': lastRealtimeStressTime, | 410 | 'last_realtime_stress_time': lastRealtimeStressTime, |
| 411 | + if (latestHrvDataTime != null) 'latest_hrv_data_time': latestHrvDataTime, | ||
| 412 | + if (latestRealtimeStressDataTime != null) | ||
| 413 | + 'latest_realtime_stress_data_time': latestRealtimeStressDataTime, | ||
| 414 | + if (latestSleepDataTime != null) | ||
| 415 | + 'latest_sleep_data_time': latestSleepDataTime, | ||
| 320 | }; | 416 | }; |
| 321 | } | 417 | } |
| 322 | 418 | ||
| @@ -340,12 +436,19 @@ class HealthRawLocalNotificationRecord { | @@ -340,12 +436,19 @@ class HealthRawLocalNotificationRecord { | ||
| 340 | int? lastSleepTime, | 436 | int? lastSleepTime, |
| 341 | int? lastHrvTime, | 437 | int? lastHrvTime, |
| 342 | int? lastRealtimeStressTime, | 438 | int? lastRealtimeStressTime, |
| 439 | + int? latestHrvDataTime, | ||
| 440 | + int? latestRealtimeStressDataTime, | ||
| 441 | + int? latestSleepDataTime, | ||
| 343 | }) { | 442 | }) { |
| 344 | return HealthRawLocalNotificationRecord( | 443 | return HealthRawLocalNotificationRecord( |
| 345 | lastSleepTime: lastSleepTime ?? this.lastSleepTime, | 444 | lastSleepTime: lastSleepTime ?? this.lastSleepTime, |
| 346 | lastHrvTime: lastHrvTime ?? this.lastHrvTime, | 445 | lastHrvTime: lastHrvTime ?? this.lastHrvTime, |
| 347 | lastRealtimeStressTime: | 446 | lastRealtimeStressTime: |
| 348 | lastRealtimeStressTime ?? this.lastRealtimeStressTime, | 447 | lastRealtimeStressTime ?? this.lastRealtimeStressTime, |
| 448 | + latestHrvDataTime: latestHrvDataTime ?? this.latestHrvDataTime, | ||
| 449 | + latestRealtimeStressDataTime: | ||
| 450 | + latestRealtimeStressDataTime ?? this.latestRealtimeStressDataTime, | ||
| 451 | + latestSleepDataTime: latestSleepDataTime ?? this.latestSleepDataTime, | ||
| 349 | ); | 452 | ); |
| 350 | } | 453 | } |
| 351 | } | 454 | } |
| @@ -439,6 +439,10 @@ void main() { | @@ -439,6 +439,10 @@ void main() { | ||
| 439 | expect(firstRows.sleepRows.single['update_time'], greaterThan(0)); | 439 | expect(firstRows.sleepRows.single['update_time'], greaterThan(0)); |
| 440 | 440 | ||
| 441 | await store.markHrvStressUploaded(rawEndTimes: [base + 10], userId: 42); | 441 | await store.markHrvStressUploaded(rawEndTimes: [base + 10], userId: 42); |
| 442 | + final uploadedRows = await store.debugQueryAllRows(42); | ||
| 443 | + final hrvUploadTime = uploadedRows.hrvRows.single['upload_time'] as int; | ||
| 444 | + expect(hrvUploadTime, greaterThan(0)); | ||
| 445 | + | ||
| 442 | await store.upsertResult( | 446 | await store.upsertResult( |
| 443 | HealthRawStressCalculationResult( | 447 | HealthRawStressCalculationResult( |
| 444 | userId: 42, | 448 | userId: 42, |
| @@ -454,6 +458,7 @@ void main() { | @@ -454,6 +458,7 @@ void main() { | ||
| 454 | greaterThan(firstHrvUpdateTime), | 458 | greaterThan(firstHrvUpdateTime), |
| 455 | ); | 459 | ); |
| 456 | expect(secondRows.hrvRows.single['uploaded'], 1); | 460 | expect(secondRows.hrvRows.single['uploaded'], 1); |
| 461 | + expect(secondRows.hrvRows.single['upload_time'], hrvUploadTime); | ||
| 457 | }); | 462 | }); |
| 458 | 463 | ||
| 459 | test( | 464 | test( |
| @@ -567,13 +572,14 @@ void main() { | @@ -567,13 +572,14 @@ void main() { | ||
| 567 | ); | 572 | ); |
| 568 | final eventFuture = service.healthDataUpdatedStream.first; | 573 | final eventFuture = service.healthDataUpdatedStream.first; |
| 569 | 574 | ||
| 570 | - await service.onHealthDataUpdated( | 575 | + final hasNewResult = await service.onHealthDataUpdated( |
| 571 | dataTypes: [ | 576 | dataTypes: [ |
| 572 | HealthDataUploadType.hrv.type, | 577 | HealthDataUploadType.hrv.type, |
| 573 | HealthDataUploadType.heartRate.type, | 578 | HealthDataUploadType.heartRate.type, |
| 574 | ], | 579 | ], |
| 575 | ); | 580 | ); |
| 576 | 581 | ||
| 582 | + expect(hasNewResult, isTrue); | ||
| 577 | final event = await eventFuture; | 583 | final event = await eventFuture; |
| 578 | expect( | 584 | expect( |
| 579 | event.dataTypes, | 585 | event.dataTypes, |
| @@ -581,6 +587,24 @@ void main() { | @@ -581,6 +587,24 @@ void main() { | ||
| 581 | ); | 587 | ); |
| 582 | }); | 588 | }); |
| 583 | 589 | ||
| 590 | + test('onHealthDataUpdated returns false after an empty calculation', | ||
| 591 | + () async { | ||
| 592 | + final api = _FakeHealthKitRawDataHostApi(); | ||
| 593 | + final service = AppleHealthRawDataCoreService( | ||
| 594 | + healthApi: _FakeHealthKitHostApi(status: 0), | ||
| 595 | + rawDataApi: api, | ||
| 596 | + localStore: _MemoryHealthRawStressLocalStore(), | ||
| 597 | + userIdProvider: () => 42, | ||
| 598 | + uploadResultsAfterCalculation: false, | ||
| 599 | + ); | ||
| 600 | + | ||
| 601 | + final hasNewResult = await service.onHealthDataUpdated( | ||
| 602 | + dataTypes: [HealthDataUploadType.hrv.type], | ||
| 603 | + ); | ||
| 604 | + | ||
| 605 | + expect(hasNewResult, isFalse); | ||
| 606 | + }); | ||
| 607 | + | ||
| 584 | test('startCoreCaculate calculates and stores sleep results', () async { | 608 | test('startCoreCaculate calculates and stores sleep results', () async { |
| 585 | final now = DateTime.now(); | 609 | final now = DateTime.now(); |
| 586 | final day = DateTime(now.year, now.month, now.day); | 610 | final day = DateTime(now.year, now.month, now.day); |
| @@ -670,7 +694,7 @@ void main() { | @@ -670,7 +694,7 @@ void main() { | ||
| 670 | }); | 694 | }); |
| 671 | 695 | ||
| 672 | test( | 696 | test( |
| 673 | - 'startCoreCaculate sends hrv and sleep notifications for new non-first results', | 697 | + 'startCoreCaculate sends only latest hrv notification for new non-first results', |
| 674 | () async { | 698 | () async { |
| 675 | final now = DateTime.now(); | 699 | final now = DateTime.now(); |
| 676 | final day = DateTime(now.year, now.month, now.day); | 700 | final day = DateTime(now.year, now.month, now.day); |
| @@ -727,7 +751,11 @@ void main() { | @@ -727,7 +751,11 @@ void main() { | ||
| 727 | ), | 751 | ), |
| 728 | ], | 752 | ], |
| 729 | ); | 753 | ); |
| 730 | - final notificationDispatcher = _FakeHealthRawLocalNotificationDispatcher(); | 754 | + final notificationDispatcher = _FakeHealthRawLocalNotificationDispatcher() |
| 755 | + ..record = HealthRawLocalNotificationRecord( | ||
| 756 | + latestHrvDataTime: base + 10, | ||
| 757 | + latestSleepDataTime: base - Duration.secondsPerDay, | ||
| 758 | + ); | ||
| 731 | final service = AppleHealthRawDataCoreService( | 759 | final service = AppleHealthRawDataCoreService( |
| 732 | healthApi: _FakeHealthKitHostApi(), | 760 | healthApi: _FakeHealthKitHostApi(), |
| 733 | rawDataApi: api, | 761 | rawDataApi: api, |
| @@ -742,17 +770,44 @@ void main() { | @@ -742,17 +770,44 @@ void main() { | ||
| 742 | readChunkDays: 1, | 770 | readChunkDays: 1, |
| 743 | ); | 771 | ); |
| 744 | 772 | ||
| 745 | - expect( | ||
| 746 | - notificationDispatcher.sentNotifications.where( | ||
| 747 | - (e) => e.recordType == HealthRawLocalNotificationRecordType.hrv), | ||
| 748 | - hasLength(1), | ||
| 749 | - ); | 773 | + final hrvNotifications = notificationDispatcher.sentNotifications |
| 774 | + .where((e) => e.recordType == HealthRawLocalNotificationRecordType.hrv) | ||
| 775 | + .toList(); | ||
| 776 | + expect(hrvNotifications, hasLength(1)); | ||
| 777 | + expect(hrvNotifications.single.recordTime, base + 720); | ||
| 750 | expect( | 778 | expect( |
| 751 | notificationDispatcher.sentNotifications.where( | 779 | notificationDispatcher.sentNotifications.where( |
| 752 | (e) => e.recordType == HealthRawLocalNotificationRecordType.sleep, | 780 | (e) => e.recordType == HealthRawLocalNotificationRecordType.sleep, |
| 753 | ), | 781 | ), |
| 754 | hasLength(1), | 782 | hasLength(1), |
| 755 | ); | 783 | ); |
| 784 | + expect(notificationDispatcher.record.latestHrvDataTime, base + 720); | ||
| 785 | + expect(notificationDispatcher.record.latestSleepDataTime, | ||
| 786 | + LocalHealthDataConvert.unixSeconds(sleepEnd)); | ||
| 787 | + final rows = await store.debugQueryAllRows(42); | ||
| 788 | + expect( | ||
| 789 | + rows.hrvRows.singleWhere( | ||
| 790 | + (row) => row['raw_end_time'] == base + 10)['push_send_time'], | ||
| 791 | + isNull, | ||
| 792 | + ); | ||
| 793 | + expect( | ||
| 794 | + rows.hrvRows | ||
| 795 | + .where( | ||
| 796 | + (row) => [base + 120, base + 420].contains(row['raw_end_time'])) | ||
| 797 | + .every((row) => row['push_send_time'] == null), | ||
| 798 | + isTrue, | ||
| 799 | + ); | ||
| 800 | + expect( | ||
| 801 | + rows.hrvRows.singleWhere( | ||
| 802 | + (row) => row['raw_end_time'] == base + 720)['push_send_time'], | ||
| 803 | + isNotNull, | ||
| 804 | + ); | ||
| 805 | + expect( | ||
| 806 | + rows.sleepRows.singleWhere( | ||
| 807 | + (row) => row['date'] == LocalHealthDataConvert.unixSeconds(sleepEnd), | ||
| 808 | + )['push_send_time'], | ||
| 809 | + isNotNull, | ||
| 810 | + ); | ||
| 756 | }); | 811 | }); |
| 757 | 812 | ||
| 758 | test( | 813 | test( |
| @@ -784,7 +839,11 @@ void main() { | @@ -784,7 +839,11 @@ void main() { | ||
| 784 | ), | 839 | ), |
| 785 | ], | 840 | ], |
| 786 | ); | 841 | ); |
| 787 | - final notificationDispatcher = _FakeHealthRawLocalNotificationDispatcher(); | 842 | + final notificationDispatcher = _FakeHealthRawLocalNotificationDispatcher() |
| 843 | + ..record = const HealthRawLocalNotificationRecord( | ||
| 844 | + latestHrvDataTime: base + 100, | ||
| 845 | + latestSleepDataTime: base + 100, | ||
| 846 | + ); | ||
| 788 | final service = AppleHealthRawDataCoreService( | 847 | final service = AppleHealthRawDataCoreService( |
| 789 | healthApi: _FakeHealthKitHostApi(), | 848 | healthApi: _FakeHealthKitHostApi(), |
| 790 | rawDataApi: api, | 849 | rawDataApi: api, |
| @@ -807,6 +866,177 @@ void main() { | @@ -807,6 +866,177 @@ void main() { | ||
| 807 | ), | 866 | ), |
| 808 | isEmpty, | 867 | isEmpty, |
| 809 | ); | 868 | ); |
| 869 | + expect(notificationDispatcher.record.latestHrvDataTime, base + 100); | ||
| 870 | + expect(notificationDispatcher.record.latestSleepDataTime, base + 100); | ||
| 871 | + }); | ||
| 872 | + | ||
| 873 | + test( | ||
| 874 | + 'startCoreCaculate sends realtime notification when database latest is newer than latest data time', | ||
| 875 | + () async { | ||
| 876 | + const base = 1800000000; | ||
| 877 | + final api = _FakeHealthKitRawDataHostApi(); | ||
| 878 | + final store = _MemoryHealthRawStressLocalStore(); | ||
| 879 | + await store.upsertResult( | ||
| 880 | + HealthRawStressCalculationResult( | ||
| 881 | + userId: 42, | ||
| 882 | + hrvStressPoints: const <HealthRawHrvStressPoint>[], | ||
| 883 | + realtimeStressPoints: [ | ||
| 884 | + for (var i = 0; i < 10; i += 1) | ||
| 885 | + _realtimeStressPoint(base + i * 300, result: 70), | ||
| 886 | + ], | ||
| 887 | + dailyStressPoints: const <HealthRawDailyStressPoint>[], | ||
| 888 | + ), | ||
| 889 | + ); | ||
| 890 | + final notificationDispatcher = _FakeHealthRawLocalNotificationDispatcher() | ||
| 891 | + ..record = const HealthRawLocalNotificationRecord( | ||
| 892 | + latestRealtimeStressDataTime: base, | ||
| 893 | + ); | ||
| 894 | + final service = AppleHealthRawDataCoreService( | ||
| 895 | + healthApi: _FakeHealthKitHostApi(), | ||
| 896 | + rawDataApi: api, | ||
| 897 | + localStore: store, | ||
| 898 | + userIdProvider: () => 42, | ||
| 899 | + uploadResultsAfterCalculation: false, | ||
| 900 | + localNotificationDispatcher: notificationDispatcher, | ||
| 901 | + ); | ||
| 902 | + | ||
| 903 | + await service.startCoreCaculate( | ||
| 904 | + endTime: base + 3000, | ||
| 905 | + readChunkDays: 1, | ||
| 906 | + ); | ||
| 907 | + | ||
| 908 | + expect( | ||
| 909 | + notificationDispatcher.sentNotifications.where( | ||
| 910 | + (e) => | ||
| 911 | + e.recordType == HealthRawLocalNotificationRecordType.realtimeStress, | ||
| 912 | + ), | ||
| 913 | + hasLength(1), | ||
| 914 | + ); | ||
| 915 | + expect( | ||
| 916 | + notificationDispatcher.record.latestRealtimeStressDataTime, | ||
| 917 | + base + 2700, | ||
| 918 | + ); | ||
| 919 | + final rows = await store.debugQueryAllRows(42); | ||
| 920 | + expect(rows.realtimeRows.last['push_send_time'], isNotNull); | ||
| 921 | + }); | ||
| 922 | + | ||
| 923 | + test('startCoreCaculate suppresses realtime notification inside sleep result', | ||
| 924 | + () async { | ||
| 925 | + final now = DateTime.now(); | ||
| 926 | + final day = DateTime(now.year, now.month, now.day).subtract( | ||
| 927 | + const Duration(days: 7), | ||
| 928 | + ); | ||
| 929 | + final dayStart = LocalHealthDataConvert.unixSeconds(day); | ||
| 930 | + final latestTime = dayStart + 5 * 3600 + 30 * 60; | ||
| 931 | + final firstTime = latestTime - 9 * 300; | ||
| 932 | + final api = _FakeHealthKitRawDataHostApi(); | ||
| 933 | + final store = _MemoryHealthRawStressLocalStore(); | ||
| 934 | + await store.upsertResult( | ||
| 935 | + HealthRawStressCalculationResult( | ||
| 936 | + userId: 42, | ||
| 937 | + hrvStressPoints: const <HealthRawHrvStressPoint>[], | ||
| 938 | + realtimeStressPoints: [ | ||
| 939 | + for (var i = 0; i < 10; i += 1) | ||
| 940 | + _realtimeStressPoint(firstTime + i * 300, result: 70), | ||
| 941 | + ], | ||
| 942 | + dailyStressPoints: const <HealthRawDailyStressPoint>[], | ||
| 943 | + ), | ||
| 944 | + ); | ||
| 945 | + await store.upsertSleepResults( | ||
| 946 | + userId: 42, | ||
| 947 | + results: [ | ||
| 948 | + HealthRawSleepResult( | ||
| 949 | + userId: 42, | ||
| 950 | + date: dayStart + 6 * 3600, | ||
| 951 | + startDate: dayStart + 5 * 3600, | ||
| 952 | + sleepScore: 80, | ||
| 953 | + sleepState: 2, | ||
| 954 | + inBedMinutes: 60, | ||
| 955 | + awakMinutes: 0, | ||
| 956 | + sleepMinutes: 60, | ||
| 957 | + ), | ||
| 958 | + ], | ||
| 959 | + ); | ||
| 960 | + final notificationDispatcher = _FakeHealthRawLocalNotificationDispatcher() | ||
| 961 | + ..record = HealthRawLocalNotificationRecord( | ||
| 962 | + latestRealtimeStressDataTime: firstTime - 1, | ||
| 963 | + latestSleepDataTime: dayStart + 6 * 3600, | ||
| 964 | + ); | ||
| 965 | + final service = AppleHealthRawDataCoreService( | ||
| 966 | + healthApi: _FakeHealthKitHostApi(), | ||
| 967 | + rawDataApi: api, | ||
| 968 | + localStore: store, | ||
| 969 | + userIdProvider: () => 42, | ||
| 970 | + uploadResultsAfterCalculation: false, | ||
| 971 | + localNotificationDispatcher: notificationDispatcher, | ||
| 972 | + ); | ||
| 973 | + | ||
| 974 | + await service.startCoreCaculate( | ||
| 975 | + endTime: latestTime, | ||
| 976 | + readChunkDays: 1, | ||
| 977 | + ); | ||
| 978 | + | ||
| 979 | + expect( | ||
| 980 | + notificationDispatcher.sentNotifications.where( | ||
| 981 | + (e) => | ||
| 982 | + e.recordType == HealthRawLocalNotificationRecordType.realtimeStress, | ||
| 983 | + ), | ||
| 984 | + isEmpty, | ||
| 985 | + ); | ||
| 986 | + final rows = await store.debugQueryAllRows(42); | ||
| 987 | + expect(rows.realtimeRows.last['push_send_time'], isNull); | ||
| 988 | + }); | ||
| 989 | + | ||
| 990 | + test( | ||
| 991 | + 'startCoreCaculate uses default sleep interval for realtime notification', | ||
| 992 | + () async { | ||
| 993 | + final now = DateTime.now(); | ||
| 994 | + final day = DateTime(now.year, now.month, now.day).subtract( | ||
| 995 | + const Duration(days: 7), | ||
| 996 | + ); | ||
| 997 | + final dayStart = LocalHealthDataConvert.unixSeconds(day); | ||
| 998 | + final latestTime = dayStart + 5 * 3600 + 30 * 60; | ||
| 999 | + final firstTime = latestTime - 9 * 300; | ||
| 1000 | + final api = _FakeHealthKitRawDataHostApi(); | ||
| 1001 | + final store = _MemoryHealthRawStressLocalStore(); | ||
| 1002 | + await store.upsertResult( | ||
| 1003 | + HealthRawStressCalculationResult( | ||
| 1004 | + userId: 42, | ||
| 1005 | + hrvStressPoints: const <HealthRawHrvStressPoint>[], | ||
| 1006 | + realtimeStressPoints: [ | ||
| 1007 | + for (var i = 0; i < 10; i += 1) | ||
| 1008 | + _realtimeStressPoint(firstTime + i * 300, result: 70), | ||
| 1009 | + ], | ||
| 1010 | + dailyStressPoints: const <HealthRawDailyStressPoint>[], | ||
| 1011 | + ), | ||
| 1012 | + ); | ||
| 1013 | + final notificationDispatcher = _FakeHealthRawLocalNotificationDispatcher() | ||
| 1014 | + ..record = HealthRawLocalNotificationRecord( | ||
| 1015 | + latestRealtimeStressDataTime: firstTime - 1, | ||
| 1016 | + ); | ||
| 1017 | + final service = AppleHealthRawDataCoreService( | ||
| 1018 | + healthApi: _FakeHealthKitHostApi(), | ||
| 1019 | + rawDataApi: api, | ||
| 1020 | + localStore: store, | ||
| 1021 | + userIdProvider: () => 42, | ||
| 1022 | + uploadResultsAfterCalculation: false, | ||
| 1023 | + localNotificationDispatcher: notificationDispatcher, | ||
| 1024 | + ); | ||
| 1025 | + | ||
| 1026 | + await service.startCoreCaculate( | ||
| 1027 | + endTime: latestTime, | ||
| 1028 | + readChunkDays: 1, | ||
| 1029 | + ); | ||
| 1030 | + | ||
| 1031 | + expect( | ||
| 1032 | + notificationDispatcher.sentNotifications.where( | ||
| 1033 | + (e) => | ||
| 1034 | + e.recordType == HealthRawLocalNotificationRecordType.realtimeStress, | ||
| 1035 | + ), | ||
| 1036 | + isEmpty, | ||
| 1037 | + ); | ||
| 1038 | + final rows = await store.debugQueryAllRows(42); | ||
| 1039 | + expect(rows.realtimeRows.last['push_send_time'], isNull); | ||
| 810 | }); | 1040 | }); |
| 811 | 1041 | ||
| 812 | test( | 1042 | test( |
| @@ -862,6 +1092,9 @@ void main() { | @@ -862,6 +1092,9 @@ void main() { | ||
| 862 | ), | 1092 | ), |
| 863 | isEmpty, | 1093 | isEmpty, |
| 864 | ); | 1094 | ); |
| 1095 | + expect(notificationDispatcher.record.latestHrvDataTime, base + 420); | ||
| 1096 | + expect(notificationDispatcher.record.latestSleepDataTime, | ||
| 1097 | + LocalHealthDataConvert.unixSeconds(sleepEnd)); | ||
| 865 | }); | 1098 | }); |
| 866 | 1099 | ||
| 867 | test( | 1100 | test( |
| @@ -934,6 +1167,10 @@ void main() { | @@ -934,6 +1167,10 @@ void main() { | ||
| 934 | expect(rows.realtimeRows.single['uploaded'], 1); | 1167 | expect(rows.realtimeRows.single['uploaded'], 1); |
| 935 | expect(rows.dailyStressRows.single['uploaded'], 1); | 1168 | expect(rows.dailyStressRows.single['uploaded'], 1); |
| 936 | expect(rows.sleepRows.single['uploaded'], 1); | 1169 | expect(rows.sleepRows.single['uploaded'], 1); |
| 1170 | + expect(rows.hrvRows.single['upload_time'], isNotNull); | ||
| 1171 | + expect(rows.realtimeRows.single['upload_time'], isNotNull); | ||
| 1172 | + expect(rows.dailyStressRows.single['upload_time'], isNotNull); | ||
| 1173 | + expect(rows.sleepRows.single['upload_time'], isNotNull); | ||
| 937 | }); | 1174 | }); |
| 938 | 1175 | ||
| 939 | test('startCoreCaculate does not wait for pending uploads', () async { | 1176 | test('startCoreCaculate does not wait for pending uploads', () async { |
| @@ -972,6 +1209,8 @@ void main() { | @@ -972,6 +1209,8 @@ void main() { | ||
| 972 | var rows = await store.debugQueryAllRows(42); | 1209 | var rows = await store.debugQueryAllRows(42); |
| 973 | expect(rows.hrvRows.single['uploaded'], 0); | 1210 | expect(rows.hrvRows.single['uploaded'], 0); |
| 974 | expect(rows.realtimeRows.single['uploaded'], 0); | 1211 | expect(rows.realtimeRows.single['uploaded'], 0); |
| 1212 | + expect(rows.hrvRows.single['upload_time'], isNull); | ||
| 1213 | + expect(rows.realtimeRows.single['upload_time'], isNull); | ||
| 975 | 1214 | ||
| 976 | api.hrvUploadCompleter!.complete(base + 10); | 1215 | api.hrvUploadCompleter!.complete(base + 10); |
| 977 | api.hrUploadCompleter!.complete(base + 20); | 1216 | api.hrUploadCompleter!.complete(base + 20); |
| @@ -980,6 +1219,8 @@ void main() { | @@ -980,6 +1219,8 @@ void main() { | ||
| 980 | 1219 | ||
| 981 | expect(rows.hrvRows.single['uploaded'], 1); | 1220 | expect(rows.hrvRows.single['uploaded'], 1); |
| 982 | expect(rows.realtimeRows.single['uploaded'], 1); | 1221 | expect(rows.realtimeRows.single['uploaded'], 1); |
| 1222 | + expect(rows.hrvRows.single['upload_time'], isNotNull); | ||
| 1223 | + expect(rows.realtimeRows.single['upload_time'], isNotNull); | ||
| 983 | }); | 1224 | }); |
| 984 | 1225 | ||
| 985 | test('local data source returns earliest local hr start time', () async { | 1226 | test('local data source returns earliest local hr start time', () async { |
| @@ -1033,12 +1274,15 @@ HealthKitRawDataPoint _point( | @@ -1033,12 +1274,15 @@ HealthKitRawDataPoint _point( | ||
| 1033 | ); | 1274 | ); |
| 1034 | } | 1275 | } |
| 1035 | 1276 | ||
| 1036 | -HealthRawRealtimeStressPoint _realtimeStressPoint(int time) { | 1277 | +HealthRawRealtimeStressPoint _realtimeStressPoint( |
| 1278 | + int time, { | ||
| 1279 | + double result = 30, | ||
| 1280 | +}) { | ||
| 1037 | return HealthRawRealtimeStressPoint( | 1281 | return HealthRawRealtimeStressPoint( |
| 1038 | userId: 42, | 1282 | userId: 42, |
| 1039 | rawEndTime: time, | 1283 | rawEndTime: time, |
| 1040 | rawHr: 70, | 1284 | rawHr: 70, |
| 1041 | - result: 30, | 1285 | + result: result, |
| 1042 | sourceStartTime: time, | 1286 | sourceStartTime: time, |
| 1043 | sourceEndTime: time, | 1287 | sourceEndTime: time, |
| 1044 | ); | 1288 | ); |
| @@ -1232,6 +1476,20 @@ class _FakeHealthRawLocalNotificationDispatcher | @@ -1232,6 +1476,20 @@ class _FakeHealthRawLocalNotificationDispatcher | ||
| 1232 | }) async { | 1476 | }) async { |
| 1233 | record = record.copyWith(lastRealtimeStressTime: recordTime); | 1477 | record = record.copyWith(lastRealtimeStressTime: recordTime); |
| 1234 | } | 1478 | } |
| 1479 | + | ||
| 1480 | + @override | ||
| 1481 | + Future<void> updateProcessedDataTimes({ | ||
| 1482 | + required int userId, | ||
| 1483 | + int? latestHrvDataTime, | ||
| 1484 | + int? latestRealtimeStressDataTime, | ||
| 1485 | + int? latestSleepDataTime, | ||
| 1486 | + }) async { | ||
| 1487 | + record = record.copyWith( | ||
| 1488 | + latestHrvDataTime: latestHrvDataTime, | ||
| 1489 | + latestRealtimeStressDataTime: latestRealtimeStressDataTime, | ||
| 1490 | + latestSleepDataTime: latestSleepDataTime, | ||
| 1491 | + ); | ||
| 1492 | + } | ||
| 1235 | } | 1493 | } |
| 1236 | 1494 | ||
| 1237 | class _ReadCall { | 1495 | class _ReadCall { |
| @@ -1254,8 +1512,16 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | @@ -1254,8 +1512,16 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | ||
| 1254 | final Map<int, Map<int, int>> _realtimeUpdateTimes = {}; | 1512 | final Map<int, Map<int, int>> _realtimeUpdateTimes = {}; |
| 1255 | final Map<int, Map<int, int>> _dailyUpdateTimes = {}; | 1513 | final Map<int, Map<int, int>> _dailyUpdateTimes = {}; |
| 1256 | final Map<int, Map<int, int>> _sleepUpdateTimes = {}; | 1514 | final Map<int, Map<int, int>> _sleepUpdateTimes = {}; |
| 1515 | + final Map<int, Map<int, int>> _hrvPushSendTimes = {}; | ||
| 1516 | + final Map<int, Map<int, int>> _realtimePushSendTimes = {}; | ||
| 1517 | + final Map<int, Map<int, int>> _sleepPushSendTimes = {}; | ||
| 1518 | + final Map<int, Map<int, int>> _hrvUploadTimes = {}; | ||
| 1519 | + final Map<int, Map<int, int>> _realtimeUploadTimes = {}; | ||
| 1520 | + final Map<int, Map<int, int>> _dailyUploadTimes = {}; | ||
| 1521 | + final Map<int, Map<int, int>> _sleepUploadTimes = {}; | ||
| 1257 | final List<String> operationLog; | 1522 | final List<String> operationLog; |
| 1258 | var _updateTimeClock = 1; | 1523 | var _updateTimeClock = 1; |
| 1524 | + var _uploadTimeClock = 1000; | ||
| 1259 | var realtimeNotificationWindowQueryCount = 0; | 1525 | var realtimeNotificationWindowQueryCount = 0; |
| 1260 | 1526 | ||
| 1261 | void insertDailyStress(HealthRawDailyStressPoint point) { | 1527 | void insertDailyStress(HealthRawDailyStressPoint point) { |
| @@ -1282,6 +1548,7 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | @@ -1282,6 +1548,7 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | ||
| 1282 | @override | 1548 | @override |
| 1283 | Future<void> upsertResult(HealthRawStressCalculationResult result) async { | 1549 | Future<void> upsertResult(HealthRawStressCalculationResult result) async { |
| 1284 | operationLog.add('upsertResult'); | 1550 | operationLog.add('upsertResult'); |
| 1551 | + final updateTime = _nextUpdateTime(); | ||
| 1285 | final hrvByTime = <int, HealthRawHrvStressPoint>{ | 1552 | final hrvByTime = <int, HealthRawHrvStressPoint>{ |
| 1286 | for (final point in _hrv[result.userId] ?? <HealthRawHrvStressPoint>[]) | 1553 | for (final point in _hrv[result.userId] ?? <HealthRawHrvStressPoint>[]) |
| 1287 | point.rawEndTime: point, | 1554 | point.rawEndTime: point, |
| @@ -1303,10 +1570,14 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | @@ -1303,10 +1570,14 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | ||
| 1303 | baselineRestingHr: point.baselineRestingHr, | 1570 | baselineRestingHr: point.baselineRestingHr, |
| 1304 | flags: point.flags, | 1571 | flags: point.flags, |
| 1305 | uploaded: same ? existing.uploaded : false, | 1572 | uploaded: same ? existing.uploaded : false, |
| 1573 | + pushSendTime: existing?.pushSendTime, | ||
| 1574 | + uploadTime: same ? existing.uploadTime : null, | ||
| 1306 | ); | 1575 | ); |
| 1307 | _hrvUpdateTimes.putIfAbsent( | 1576 | _hrvUpdateTimes.putIfAbsent( |
| 1308 | - point.userId, () => <int, int>{})[point.rawEndTime] = | ||
| 1309 | - _nextUpdateTime(); | 1577 | + point.userId, () => <int, int>{})[point.rawEndTime] = updateTime; |
| 1578 | + if (!same) { | ||
| 1579 | + _hrvUploadTimes[point.userId]?.remove(point.rawEndTime); | ||
| 1580 | + } | ||
| 1310 | } | 1581 | } |
| 1311 | _hrv[result.userId] = hrvByTime.values.toList() | 1582 | _hrv[result.userId] = hrvByTime.values.toList() |
| 1312 | ..sort((a, b) => a.rawEndTime.compareTo(b.rawEndTime)); | 1583 | ..sort((a, b) => a.rawEndTime.compareTo(b.rawEndTime)); |
| @@ -1329,10 +1600,14 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | @@ -1329,10 +1600,14 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | ||
| 1329 | sourceEndTime: point.sourceEndTime, | 1600 | sourceEndTime: point.sourceEndTime, |
| 1330 | flags: point.flags, | 1601 | flags: point.flags, |
| 1331 | uploaded: same ? existing.uploaded : false, | 1602 | uploaded: same ? existing.uploaded : false, |
| 1603 | + pushSendTime: existing?.pushSendTime, | ||
| 1604 | + uploadTime: same ? existing.uploadTime : null, | ||
| 1332 | ); | 1605 | ); |
| 1333 | _realtimeUpdateTimes.putIfAbsent( | 1606 | _realtimeUpdateTimes.putIfAbsent( |
| 1334 | - point.userId, () => <int, int>{})[point.rawEndTime] = | ||
| 1335 | - _nextUpdateTime(); | 1607 | + point.userId, () => <int, int>{})[point.rawEndTime] = updateTime; |
| 1608 | + if (!same) { | ||
| 1609 | + _realtimeUploadTimes[point.userId]?.remove(point.rawEndTime); | ||
| 1610 | + } | ||
| 1336 | } | 1611 | } |
| 1337 | _realtime[result.userId] = realtimeByTime.values.toList() | 1612 | _realtime[result.userId] = realtimeByTime.values.toList() |
| 1338 | ..sort((a, b) => a.rawEndTime.compareTo(b.rawEndTime)); | 1613 | ..sort((a, b) => a.rawEndTime.compareTo(b.rawEndTime)); |
| @@ -1344,6 +1619,7 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | @@ -1344,6 +1619,7 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | ||
| 1344 | required Iterable<HealthRawDailyStressPoint> points, | 1619 | required Iterable<HealthRawDailyStressPoint> points, |
| 1345 | }) async { | 1620 | }) async { |
| 1346 | operationLog.add('upsertDailyStressPoints'); | 1621 | operationLog.add('upsertDailyStressPoints'); |
| 1622 | + final updateTime = _nextUpdateTime(); | ||
| 1347 | final byDate = <int, HealthRawDailyStressPoint>{ | 1623 | final byDate = <int, HealthRawDailyStressPoint>{ |
| 1348 | for (final point in _daily[userId] ?? <HealthRawDailyStressPoint>[]) | 1624 | for (final point in _daily[userId] ?? <HealthRawDailyStressPoint>[]) |
| 1349 | point.date: point, | 1625 | point.date: point, |
| @@ -1364,9 +1640,13 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | @@ -1364,9 +1640,13 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | ||
| 1364 | state: point.state, | 1640 | state: point.state, |
| 1365 | dataTime: point.dataTime, | 1641 | dataTime: point.dataTime, |
| 1366 | uploaded: isToday ? false : (same ? existing.uploaded : false), | 1642 | uploaded: isToday ? false : (same ? existing.uploaded : false), |
| 1643 | + uploadTime: isToday || !same ? null : existing.uploadTime, | ||
| 1367 | ); | 1644 | ); |
| 1368 | _dailyUpdateTimes.putIfAbsent( | 1645 | _dailyUpdateTimes.putIfAbsent( |
| 1369 | - point.userId, () => <int, int>{})[point.date] = _nextUpdateTime(); | 1646 | + point.userId, () => <int, int>{})[point.date] = updateTime; |
| 1647 | + if (isToday || !same) { | ||
| 1648 | + _dailyUploadTimes[point.userId]?.remove(point.date); | ||
| 1649 | + } | ||
| 1370 | } | 1650 | } |
| 1371 | _daily[userId] = byDate.values.toList() | 1651 | _daily[userId] = byDate.values.toList() |
| 1372 | ..sort((a, b) => a.date.compareTo(b.date)); | 1652 | ..sort((a, b) => a.date.compareTo(b.date)); |
| @@ -1377,14 +1657,39 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | @@ -1377,14 +1657,39 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | ||
| 1377 | required int userId, | 1657 | required int userId, |
| 1378 | required Iterable<HealthRawSleepResult> results, | 1658 | required Iterable<HealthRawSleepResult> results, |
| 1379 | }) async { | 1659 | }) async { |
| 1660 | + final updateTime = _nextUpdateTime(); | ||
| 1380 | final byDate = <int, HealthRawSleepResult>{ | 1661 | final byDate = <int, HealthRawSleepResult>{ |
| 1381 | for (final result in _sleep[userId] ?? <HealthRawSleepResult>[]) | 1662 | for (final result in _sleep[userId] ?? <HealthRawSleepResult>[]) |
| 1382 | result.date: result, | 1663 | result.date: result, |
| 1383 | }; | 1664 | }; |
| 1384 | for (final result in results) { | 1665 | for (final result in results) { |
| 1385 | - byDate[result.date] = result; | 1666 | + final existing = byDate[result.date]; |
| 1667 | + final same = existing != null && | ||
| 1668 | + existing.userId == result.userId && | ||
| 1669 | + existing.startDate == result.startDate && | ||
| 1670 | + existing.sleepScore == result.sleepScore && | ||
| 1671 | + existing.sleepState == result.sleepState && | ||
| 1672 | + existing.inBedMinutes == result.inBedMinutes && | ||
| 1673 | + existing.awakMinutes == result.awakMinutes && | ||
| 1674 | + existing.sleepMinutes == result.sleepMinutes; | ||
| 1675 | + byDate[result.date] = HealthRawSleepResult( | ||
| 1676 | + userId: result.userId, | ||
| 1677 | + date: result.date, | ||
| 1678 | + startDate: result.startDate, | ||
| 1679 | + sleepScore: result.sleepScore, | ||
| 1680 | + sleepState: result.sleepState, | ||
| 1681 | + inBedMinutes: result.inBedMinutes, | ||
| 1682 | + awakMinutes: result.awakMinutes, | ||
| 1683 | + sleepMinutes: result.sleepMinutes, | ||
| 1684 | + uploaded: same ? existing.uploaded : result.uploaded, | ||
| 1685 | + pushSendTime: existing?.pushSendTime, | ||
| 1686 | + uploadTime: same ? existing.uploadTime : result.uploadTime, | ||
| 1687 | + ); | ||
| 1386 | _sleepUpdateTimes.putIfAbsent( | 1688 | _sleepUpdateTimes.putIfAbsent( |
| 1387 | - result.userId, () => <int, int>{})[result.date] = _nextUpdateTime(); | 1689 | + result.userId, () => <int, int>{})[result.date] = updateTime; |
| 1690 | + if (!same) { | ||
| 1691 | + _sleepUploadTimes[result.userId]?.remove(result.date); | ||
| 1692 | + } | ||
| 1388 | } | 1693 | } |
| 1389 | _sleep[userId] = byDate.values.toList() | 1694 | _sleep[userId] = byDate.values.toList() |
| 1390 | ..sort((a, b) => a.date.compareTo(b.date)); | 1695 | ..sort((a, b) => a.date.compareTo(b.date)); |
| @@ -1514,6 +1819,88 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | @@ -1514,6 +1819,88 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | ||
| 1514 | } | 1819 | } |
| 1515 | 1820 | ||
| 1516 | @override | 1821 | @override |
| 1822 | + Future<int?> latestRealtimeStressPushSendTime(int userId) async { | ||
| 1823 | + final times = (_realtimePushSendTimes[userId] ?? <int, int>{}).values; | ||
| 1824 | + if (times.isEmpty) return null; | ||
| 1825 | + return times.reduce((a, b) => a >= b ? a : b); | ||
| 1826 | + } | ||
| 1827 | + | ||
| 1828 | + @override | ||
| 1829 | + Future<void> markLocalNotificationPushed({ | ||
| 1830 | + required int userId, | ||
| 1831 | + required HealthRawLocalNotificationRecordType recordType, | ||
| 1832 | + required int recordTime, | ||
| 1833 | + required int pushSendTime, | ||
| 1834 | + }) async { | ||
| 1835 | + switch (recordType) { | ||
| 1836 | + case HealthRawLocalNotificationRecordType.hrv: | ||
| 1837 | + _hrvPushSendTimes.putIfAbsent(userId, () => <int, int>{})[recordTime] = | ||
| 1838 | + pushSendTime; | ||
| 1839 | + _hrv[userId] = (_hrv[userId] ?? <HealthRawHrvStressPoint>[]) | ||
| 1840 | + .map((point) => point.rawEndTime == recordTime | ||
| 1841 | + ? HealthRawHrvStressPoint( | ||
| 1842 | + userId: point.userId, | ||
| 1843 | + rawEndTime: point.rawEndTime, | ||
| 1844 | + rawHrv: point.rawHrv, | ||
| 1845 | + result: point.result, | ||
| 1846 | + sourceStartTime: point.sourceStartTime, | ||
| 1847 | + sourceEndTime: point.sourceEndTime, | ||
| 1848 | + state: point.state, | ||
| 1849 | + baselineHrv: point.baselineHrv, | ||
| 1850 | + baselineAwakeHrv: point.baselineAwakeHrv, | ||
| 1851 | + baselineSleepHrv: point.baselineSleepHrv, | ||
| 1852 | + baselineRestingHr: point.baselineRestingHr, | ||
| 1853 | + flags: point.flags, | ||
| 1854 | + uploaded: point.uploaded, | ||
| 1855 | + pushSendTime: pushSendTime, | ||
| 1856 | + uploadTime: point.uploadTime, | ||
| 1857 | + ) | ||
| 1858 | + : point) | ||
| 1859 | + .toList(); | ||
| 1860 | + case HealthRawLocalNotificationRecordType.realtimeStress: | ||
| 1861 | + _realtimePushSendTimes.putIfAbsent( | ||
| 1862 | + userId, () => <int, int>{})[recordTime] = pushSendTime; | ||
| 1863 | + _realtime[userId] = | ||
| 1864 | + (_realtime[userId] ?? <HealthRawRealtimeStressPoint>[]) | ||
| 1865 | + .map((point) => point.rawEndTime == recordTime | ||
| 1866 | + ? HealthRawRealtimeStressPoint( | ||
| 1867 | + userId: point.userId, | ||
| 1868 | + rawEndTime: point.rawEndTime, | ||
| 1869 | + rawHr: point.rawHr, | ||
| 1870 | + result: point.result, | ||
| 1871 | + sourceStartTime: point.sourceStartTime, | ||
| 1872 | + sourceEndTime: point.sourceEndTime, | ||
| 1873 | + flags: point.flags, | ||
| 1874 | + uploaded: point.uploaded, | ||
| 1875 | + pushSendTime: pushSendTime, | ||
| 1876 | + uploadTime: point.uploadTime, | ||
| 1877 | + ) | ||
| 1878 | + : point) | ||
| 1879 | + .toList(); | ||
| 1880 | + case HealthRawLocalNotificationRecordType.sleep: | ||
| 1881 | + _sleepPushSendTimes.putIfAbsent( | ||
| 1882 | + userId, () => <int, int>{})[recordTime] = pushSendTime; | ||
| 1883 | + _sleep[userId] = (_sleep[userId] ?? <HealthRawSleepResult>[]) | ||
| 1884 | + .map((result) => result.date == recordTime | ||
| 1885 | + ? HealthRawSleepResult( | ||
| 1886 | + userId: result.userId, | ||
| 1887 | + date: result.date, | ||
| 1888 | + startDate: result.startDate, | ||
| 1889 | + sleepScore: result.sleepScore, | ||
| 1890 | + sleepState: result.sleepState, | ||
| 1891 | + inBedMinutes: result.inBedMinutes, | ||
| 1892 | + awakMinutes: result.awakMinutes, | ||
| 1893 | + sleepMinutes: result.sleepMinutes, | ||
| 1894 | + uploaded: result.uploaded, | ||
| 1895 | + pushSendTime: pushSendTime, | ||
| 1896 | + uploadTime: result.uploadTime, | ||
| 1897 | + ) | ||
| 1898 | + : result) | ||
| 1899 | + .toList(); | ||
| 1900 | + } | ||
| 1901 | + } | ||
| 1902 | + | ||
| 1903 | + @override | ||
| 1517 | Future<int?> earliestRealtimeRawEndTime(int userId) async { | 1904 | Future<int?> earliestRealtimeRawEndTime(int userId) async { |
| 1518 | final points = _realtime[userId]; | 1905 | final points = _realtime[userId]; |
| 1519 | if (points == null || points.isEmpty) return null; | 1906 | if (points == null || points.isEmpty) return null; |
| @@ -1533,8 +1920,11 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | @@ -1533,8 +1920,11 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | ||
| 1533 | required Iterable<int> rawEndTimes, | 1920 | required Iterable<int> rawEndTimes, |
| 1534 | }) async { | 1921 | }) async { |
| 1535 | final timeSet = rawEndTimes.toSet(); | 1922 | final timeSet = rawEndTimes.toSet(); |
| 1923 | + final uploadTime = _nextUploadTime(); | ||
| 1536 | _hrv[userId] = (_hrv[userId] ?? <HealthRawHrvStressPoint>[]).map((point) { | 1924 | _hrv[userId] = (_hrv[userId] ?? <HealthRawHrvStressPoint>[]).map((point) { |
| 1537 | if (!timeSet.contains(point.rawEndTime)) return point; | 1925 | if (!timeSet.contains(point.rawEndTime)) return point; |
| 1926 | + _hrvUploadTimes.putIfAbsent( | ||
| 1927 | + userId, () => <int, int>{})[point.rawEndTime] = uploadTime; | ||
| 1538 | return HealthRawHrvStressPoint( | 1928 | return HealthRawHrvStressPoint( |
| 1539 | userId: point.userId, | 1929 | userId: point.userId, |
| 1540 | rawEndTime: point.rawEndTime, | 1930 | rawEndTime: point.rawEndTime, |
| @@ -1549,6 +1939,8 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | @@ -1549,6 +1939,8 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | ||
| 1549 | baselineRestingHr: point.baselineRestingHr, | 1939 | baselineRestingHr: point.baselineRestingHr, |
| 1550 | flags: point.flags, | 1940 | flags: point.flags, |
| 1551 | uploaded: true, | 1941 | uploaded: true, |
| 1942 | + pushSendTime: point.pushSendTime, | ||
| 1943 | + uploadTime: uploadTime, | ||
| 1552 | ); | 1944 | ); |
| 1553 | }).toList(); | 1945 | }).toList(); |
| 1554 | } | 1946 | } |
| @@ -1570,9 +1962,12 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | @@ -1570,9 +1962,12 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | ||
| 1570 | required Iterable<int> rawEndTimes, | 1962 | required Iterable<int> rawEndTimes, |
| 1571 | }) async { | 1963 | }) async { |
| 1572 | final timeSet = rawEndTimes.toSet(); | 1964 | final timeSet = rawEndTimes.toSet(); |
| 1965 | + final uploadTime = _nextUploadTime(); | ||
| 1573 | _realtime[userId] = | 1966 | _realtime[userId] = |
| 1574 | (_realtime[userId] ?? <HealthRawRealtimeStressPoint>[]).map((point) { | 1967 | (_realtime[userId] ?? <HealthRawRealtimeStressPoint>[]).map((point) { |
| 1575 | if (!timeSet.contains(point.rawEndTime)) return point; | 1968 | if (!timeSet.contains(point.rawEndTime)) return point; |
| 1969 | + _realtimeUploadTimes.putIfAbsent( | ||
| 1970 | + userId, () => <int, int>{})[point.rawEndTime] = uploadTime; | ||
| 1576 | return HealthRawRealtimeStressPoint( | 1971 | return HealthRawRealtimeStressPoint( |
| 1577 | userId: point.userId, | 1972 | userId: point.userId, |
| 1578 | rawEndTime: point.rawEndTime, | 1973 | rawEndTime: point.rawEndTime, |
| @@ -1582,6 +1977,8 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | @@ -1582,6 +1977,8 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | ||
| 1582 | sourceEndTime: point.sourceEndTime, | 1977 | sourceEndTime: point.sourceEndTime, |
| 1583 | flags: point.flags, | 1978 | flags: point.flags, |
| 1584 | uploaded: true, | 1979 | uploaded: true, |
| 1980 | + pushSendTime: point.pushSendTime, | ||
| 1981 | + uploadTime: uploadTime, | ||
| 1585 | ); | 1982 | ); |
| 1586 | }).toList(); | 1983 | }).toList(); |
| 1587 | } | 1984 | } |
| @@ -1602,9 +1999,12 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | @@ -1602,9 +1999,12 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | ||
| 1602 | required int userId, | 1999 | required int userId, |
| 1603 | required int date, | 2000 | required int date, |
| 1604 | }) async { | 2001 | }) async { |
| 2002 | + final uploadTime = _nextUploadTime(); | ||
| 1605 | _daily[userId] = | 2003 | _daily[userId] = |
| 1606 | (_daily[userId] ?? <HealthRawDailyStressPoint>[]).map((point) { | 2004 | (_daily[userId] ?? <HealthRawDailyStressPoint>[]).map((point) { |
| 1607 | if (point.date > date) return point; | 2005 | if (point.date > date) return point; |
| 2006 | + _dailyUploadTimes.putIfAbsent(userId, () => <int, int>{})[point.date] = | ||
| 2007 | + uploadTime; | ||
| 1608 | return HealthRawDailyStressPoint( | 2008 | return HealthRawDailyStressPoint( |
| 1609 | userId: point.userId, | 2009 | userId: point.userId, |
| 1610 | date: point.date, | 2010 | date: point.date, |
| @@ -1613,6 +2013,7 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | @@ -1613,6 +2013,7 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | ||
| 1613 | state: point.state, | 2013 | state: point.state, |
| 1614 | dataTime: point.dataTime, | 2014 | dataTime: point.dataTime, |
| 1615 | uploaded: true, | 2015 | uploaded: true, |
| 2016 | + uploadTime: uploadTime, | ||
| 1616 | ); | 2017 | ); |
| 1617 | }).toList(); | 2018 | }).toList(); |
| 1618 | } | 2019 | } |
| @@ -1622,8 +2023,11 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | @@ -1622,8 +2023,11 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | ||
| 1622 | required int userId, | 2023 | required int userId, |
| 1623 | required int date, | 2024 | required int date, |
| 1624 | }) async { | 2025 | }) async { |
| 2026 | + final uploadTime = _nextUploadTime(); | ||
| 1625 | _sleep[userId] = (_sleep[userId] ?? <HealthRawSleepResult>[]).map((result) { | 2027 | _sleep[userId] = (_sleep[userId] ?? <HealthRawSleepResult>[]).map((result) { |
| 1626 | if (result.date > date) return result; | 2028 | if (result.date > date) return result; |
| 2029 | + _sleepUploadTimes.putIfAbsent(userId, () => <int, int>{})[result.date] = | ||
| 2030 | + uploadTime; | ||
| 1627 | return HealthRawSleepResult( | 2031 | return HealthRawSleepResult( |
| 1628 | userId: result.userId, | 2032 | userId: result.userId, |
| 1629 | date: result.date, | 2033 | date: result.date, |
| @@ -1634,6 +2038,8 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | @@ -1634,6 +2038,8 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | ||
| 1634 | awakMinutes: result.awakMinutes, | 2038 | awakMinutes: result.awakMinutes, |
| 1635 | sleepMinutes: result.sleepMinutes, | 2039 | sleepMinutes: result.sleepMinutes, |
| 1636 | uploaded: true, | 2040 | uploaded: true, |
| 2041 | + pushSendTime: result.pushSendTime, | ||
| 2042 | + uploadTime: uploadTime, | ||
| 1637 | ); | 2043 | ); |
| 1638 | }).toList(); | 2044 | }).toList(); |
| 1639 | } | 2045 | } |
| @@ -1713,6 +2119,9 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | @@ -1713,6 +2119,9 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | ||
| 1713 | 'is_suspected_activity': point.flags.isSuspectedActivity ? 1 : 0, | 2119 | 'is_suspected_activity': point.flags.isSuspectedActivity ? 1 : 0, |
| 1714 | 'uploaded': point.uploaded ? 1 : 0, | 2120 | 'uploaded': point.uploaded ? 1 : 0, |
| 1715 | 'update_time': _hrvUpdateTimes[point.userId]?[point.rawEndTime] ?? 0, | 2121 | 'update_time': _hrvUpdateTimes[point.userId]?[point.rawEndTime] ?? 0, |
| 2122 | + 'push_send_time': _hrvPushSendTimes[point.userId]?[point.rawEndTime], | ||
| 2123 | + 'upload_time': | ||
| 2124 | + _hrvUploadTimes[point.userId]?[point.rawEndTime] ?? point.uploadTime, | ||
| 1716 | }; | 2125 | }; |
| 1717 | } | 2126 | } |
| 1718 | 2127 | ||
| @@ -1730,6 +2139,9 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | @@ -1730,6 +2139,9 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | ||
| 1730 | 'is_suspected_activity': point.flags.isSuspectedActivity ? 1 : 0, | 2139 | 'is_suspected_activity': point.flags.isSuspectedActivity ? 1 : 0, |
| 1731 | 'uploaded': point.uploaded ? 1 : 0, | 2140 | 'uploaded': point.uploaded ? 1 : 0, |
| 1732 | 'update_time': _realtimeUpdateTimes[point.userId]?[point.rawEndTime] ?? 0, | 2141 | 'update_time': _realtimeUpdateTimes[point.userId]?[point.rawEndTime] ?? 0, |
| 2142 | + 'push_send_time': _realtimePushSendTimes[point.userId]?[point.rawEndTime], | ||
| 2143 | + 'upload_time': _realtimeUploadTimes[point.userId]?[point.rawEndTime] ?? | ||
| 2144 | + point.uploadTime, | ||
| 1733 | }; | 2145 | }; |
| 1734 | } | 2146 | } |
| 1735 | 2147 | ||
| @@ -1743,6 +2155,8 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | @@ -1743,6 +2155,8 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | ||
| 1743 | 'data_time': point.dataTime, | 2155 | 'data_time': point.dataTime, |
| 1744 | 'uploaded': point.uploaded ? 1 : 0, | 2156 | 'uploaded': point.uploaded ? 1 : 0, |
| 1745 | 'update_time': _dailyUpdateTimes[point.userId]?[point.date] ?? 0, | 2157 | 'update_time': _dailyUpdateTimes[point.userId]?[point.date] ?? 0, |
| 2158 | + 'upload_time': | ||
| 2159 | + _dailyUploadTimes[point.userId]?[point.date] ?? point.uploadTime, | ||
| 1746 | }; | 2160 | }; |
| 1747 | } | 2161 | } |
| 1748 | 2162 | ||
| @@ -1758,11 +2172,16 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | @@ -1758,11 +2172,16 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | ||
| 1758 | 'sleep_minutes': result.sleepMinutes, | 2172 | 'sleep_minutes': result.sleepMinutes, |
| 1759 | 'uploaded': result.uploaded ? 1 : 0, | 2173 | 'uploaded': result.uploaded ? 1 : 0, |
| 1760 | 'update_time': _sleepUpdateTimes[result.userId]?[result.date] ?? 0, | 2174 | 'update_time': _sleepUpdateTimes[result.userId]?[result.date] ?? 0, |
| 2175 | + 'push_send_time': _sleepPushSendTimes[result.userId]?[result.date], | ||
| 2176 | + 'upload_time': | ||
| 2177 | + _sleepUploadTimes[result.userId]?[result.date] ?? result.uploadTime, | ||
| 1761 | }; | 2178 | }; |
| 1762 | } | 2179 | } |
| 1763 | 2180 | ||
| 1764 | int _nextUpdateTime() => _updateTimeClock++; | 2181 | int _nextUpdateTime() => _updateTimeClock++; |
| 1765 | 2182 | ||
| 2183 | + int _nextUploadTime() => _uploadTimeClock++; | ||
| 2184 | + | ||
| 1766 | int _todayDateKey() { | 2185 | int _todayDateKey() { |
| 1767 | final now = DateTime.now(); | 2186 | final now = DateTime.now(); |
| 1768 | return now.year * 10000 + now.month * 100 + now.day; | 2187 | return now.year * 10000 + now.month * 100 + now.day; |
| @@ -153,6 +153,76 @@ void main() { | @@ -153,6 +153,76 @@ void main() { | ||
| 153 | expect(notifications.single.recordTime, latestTime); | 153 | expect(notifications.single.recordTime, latestTime); |
| 154 | }); | 154 | }); |
| 155 | 155 | ||
| 156 | + test('builds only the latest hrv notification for multiple candidates', () { | ||
| 157 | + final firstTime = _seconds(DateTime(2026, 1, 1, 9)); | ||
| 158 | + final secondTime = _seconds(DateTime(2026, 1, 1, 10)); | ||
| 159 | + final notifications = builder.build( | ||
| 160 | + result: HealthRawStressCalculationResult( | ||
| 161 | + userId: 1, | ||
| 162 | + hrvStressPoints: [ | ||
| 163 | + _hrvPoint(secondTime, isSleepLikely: true), | ||
| 164 | + _hrvPoint(firstTime), | ||
| 165 | + ], | ||
| 166 | + realtimeStressPoints: const [], | ||
| 167 | + dailyStressPoints: const [], | ||
| 168 | + ), | ||
| 169 | + hasExistingHrv: true, | ||
| 170 | + hasExistingSleep: false, | ||
| 171 | + realtimeWindow: const [], | ||
| 172 | + record: const HealthRawLocalNotificationRecord(), | ||
| 173 | + ); | ||
| 174 | + | ||
| 175 | + expect(notifications, hasLength(1)); | ||
| 176 | + expect(notifications.single.recordType, | ||
| 177 | + HealthRawLocalNotificationRecordType.hrv); | ||
| 178 | + expect(notifications.single.recordTime, secondTime); | ||
| 179 | + }); | ||
| 180 | + | ||
| 181 | + test('keeps the latest notification per type without dropping other types', | ||
| 182 | + () { | ||
| 183 | + final hrvFirstTime = _seconds(DateTime(2026, 1, 1, 9)); | ||
| 184 | + final hrvSecondTime = _seconds(DateTime(2026, 1, 1, 10)); | ||
| 185 | + final realtimeBase = _seconds(DateTime(2026, 1, 1, 11)); | ||
| 186 | + final notifications = builder.build( | ||
| 187 | + result: HealthRawStressCalculationResult( | ||
| 188 | + userId: 1, | ||
| 189 | + hrvStressPoints: [ | ||
| 190 | + _hrvPoint(hrvFirstTime), | ||
| 191 | + _hrvPoint(hrvSecondTime), | ||
| 192 | + ], | ||
| 193 | + realtimeStressPoints: [ | ||
| 194 | + _realtimePoint(realtimeBase + 9 * 300, 70), | ||
| 195 | + ], | ||
| 196 | + dailyStressPoints: const [], | ||
| 197 | + ), | ||
| 198 | + hasExistingHrv: true, | ||
| 199 | + hasExistingSleep: false, | ||
| 200 | + realtimeWindow: [ | ||
| 201 | + for (var i = 0; i < 10; i++) _realtimePoint(realtimeBase + i * 300, 70), | ||
| 202 | + ], | ||
| 203 | + record: const HealthRawLocalNotificationRecord(), | ||
| 204 | + ); | ||
| 205 | + | ||
| 206 | + expect(notifications, hasLength(2)); | ||
| 207 | + expect( | ||
| 208 | + notifications | ||
| 209 | + .where( | ||
| 210 | + (e) => e.recordType == HealthRawLocalNotificationRecordType.hrv) | ||
| 211 | + .single | ||
| 212 | + .recordTime, | ||
| 213 | + hrvSecondTime, | ||
| 214 | + ); | ||
| 215 | + expect( | ||
| 216 | + notifications | ||
| 217 | + .where((e) => | ||
| 218 | + e.recordType == | ||
| 219 | + HealthRawLocalNotificationRecordType.realtimeStress) | ||
| 220 | + .single | ||
| 221 | + .recordTime, | ||
| 222 | + realtimeBase + 9 * 300, | ||
| 223 | + ); | ||
| 224 | + }); | ||
| 225 | + | ||
| 156 | test('builds realtime stress notification from latest 60 minute window', () { | 226 | test('builds realtime stress notification from latest 60 minute window', () { |
| 157 | final base = _seconds(DateTime(2026, 1, 1, 9)); | 227 | final base = _seconds(DateTime(2026, 1, 1, 9)); |
| 158 | final notifications = builder.build( | 228 | final notifications = builder.build( |
| @@ -208,6 +278,138 @@ void main() { | @@ -208,6 +278,138 @@ void main() { | ||
| 208 | ); | 278 | ); |
| 209 | }); | 279 | }); |
| 210 | 280 | ||
| 281 | + test('skips realtime stress notification within push send interval', () { | ||
| 282 | + final base = _seconds(DateTime(2026, 1, 1, 9)); | ||
| 283 | + final notifications = builder.build( | ||
| 284 | + result: HealthRawStressCalculationResult( | ||
| 285 | + userId: 1, | ||
| 286 | + hrvStressPoints: const [], | ||
| 287 | + realtimeStressPoints: [_realtimePoint(base + 9 * 300, 70)], | ||
| 288 | + dailyStressPoints: const [], | ||
| 289 | + ), | ||
| 290 | + hasExistingHrv: false, | ||
| 291 | + hasExistingSleep: false, | ||
| 292 | + realtimeWindow: [ | ||
| 293 | + for (var i = 0; i < 10; i++) _realtimePoint(base + i * 300, 70), | ||
| 294 | + ], | ||
| 295 | + record: const HealthRawLocalNotificationRecord(), | ||
| 296 | + lastRealtimeStressPushSendTime: base + 100, | ||
| 297 | + notificationBuildTime: base + 100 + 3599, | ||
| 298 | + ); | ||
| 299 | + | ||
| 300 | + expect(notifications, isEmpty); | ||
| 301 | + }); | ||
| 302 | + | ||
| 303 | + test('skips realtime stress notification inside supplied sleep interval', () { | ||
| 304 | + final base = _seconds(DateTime(2026, 1, 1, 9)); | ||
| 305 | + final notifications = builder.build( | ||
| 306 | + result: HealthRawStressCalculationResult( | ||
| 307 | + userId: 1, | ||
| 308 | + hrvStressPoints: const [], | ||
| 309 | + realtimeStressPoints: [_realtimePoint(base + 9 * 300, 70)], | ||
| 310 | + dailyStressPoints: const [], | ||
| 311 | + ), | ||
| 312 | + hasExistingHrv: false, | ||
| 313 | + hasExistingSleep: false, | ||
| 314 | + realtimeWindow: [ | ||
| 315 | + for (var i = 0; i < 10; i++) _realtimePoint(base + i * 300, 70), | ||
| 316 | + ], | ||
| 317 | + record: const HealthRawLocalNotificationRecord(), | ||
| 318 | + realtimeSleepIntervals: [ | ||
| 319 | + (startTime: base + 8 * 300, endTime: base + 10 * 300), | ||
| 320 | + ], | ||
| 321 | + ); | ||
| 322 | + | ||
| 323 | + expect(notifications, isEmpty); | ||
| 324 | + }); | ||
| 325 | + | ||
| 326 | + test('builds realtime stress notification outside supplied sleep interval', | ||
| 327 | + () { | ||
| 328 | + final base = _seconds(DateTime(2026, 1, 1, 9)); | ||
| 329 | + final notifications = builder.build( | ||
| 330 | + result: HealthRawStressCalculationResult( | ||
| 331 | + userId: 1, | ||
| 332 | + hrvStressPoints: const [], | ||
| 333 | + realtimeStressPoints: [_realtimePoint(base + 9 * 300, 70)], | ||
| 334 | + dailyStressPoints: const [], | ||
| 335 | + ), | ||
| 336 | + hasExistingHrv: false, | ||
| 337 | + hasExistingSleep: false, | ||
| 338 | + realtimeWindow: [ | ||
| 339 | + for (var i = 0; i < 10; i++) _realtimePoint(base + i * 300, 70), | ||
| 340 | + ], | ||
| 341 | + record: const HealthRawLocalNotificationRecord(), | ||
| 342 | + realtimeSleepIntervals: [ | ||
| 343 | + (startTime: base - 3 * 3600, endTime: base - 2 * 3600), | ||
| 344 | + ], | ||
| 345 | + ); | ||
| 346 | + | ||
| 347 | + expect(notifications, hasLength(1)); | ||
| 348 | + }); | ||
| 349 | + | ||
| 350 | + test('uses midnight to six default realtime sleep interval', () { | ||
| 351 | + final dayStart = _seconds(DateTime(2026, 1, 1)); | ||
| 352 | + final sleepTime = dayStart + 5 * 3600 + 30 * 60; | ||
| 353 | + final awakeTime = dayStart + 6 * 3600 + 60; | ||
| 354 | + | ||
| 355 | + final sleepNotifications = builder.build( | ||
| 356 | + result: HealthRawStressCalculationResult( | ||
| 357 | + userId: 1, | ||
| 358 | + hrvStressPoints: const [], | ||
| 359 | + realtimeStressPoints: [_realtimePoint(sleepTime, 70)], | ||
| 360 | + dailyStressPoints: const [], | ||
| 361 | + ), | ||
| 362 | + hasExistingHrv: false, | ||
| 363 | + hasExistingSleep: false, | ||
| 364 | + realtimeWindow: [ | ||
| 365 | + for (var i = 0; i < 10; i++) | ||
| 366 | + _realtimePoint(sleepTime - (9 - i) * 300, 70), | ||
| 367 | + ], | ||
| 368 | + record: const HealthRawLocalNotificationRecord(), | ||
| 369 | + ); | ||
| 370 | + final awakeNotifications = builder.build( | ||
| 371 | + result: HealthRawStressCalculationResult( | ||
| 372 | + userId: 1, | ||
| 373 | + hrvStressPoints: const [], | ||
| 374 | + realtimeStressPoints: [_realtimePoint(awakeTime, 70)], | ||
| 375 | + dailyStressPoints: const [], | ||
| 376 | + ), | ||
| 377 | + hasExistingHrv: false, | ||
| 378 | + hasExistingSleep: false, | ||
| 379 | + realtimeWindow: [ | ||
| 380 | + for (var i = 0; i < 10; i++) | ||
| 381 | + _realtimePoint(awakeTime - (9 - i) * 300, 70), | ||
| 382 | + ], | ||
| 383 | + record: const HealthRawLocalNotificationRecord(), | ||
| 384 | + ); | ||
| 385 | + | ||
| 386 | + expect(sleepNotifications, isEmpty); | ||
| 387 | + expect(awakeNotifications, hasLength(1)); | ||
| 388 | + }); | ||
| 389 | + | ||
| 390 | + test('ignores legacy realtime record time for push send interval', () { | ||
| 391 | + final base = _seconds(DateTime(2026, 1, 1, 9)); | ||
| 392 | + final notifications = builder.build( | ||
| 393 | + result: HealthRawStressCalculationResult( | ||
| 394 | + userId: 1, | ||
| 395 | + hrvStressPoints: const [], | ||
| 396 | + realtimeStressPoints: [_realtimePoint(base + 9 * 300, 70)], | ||
| 397 | + dailyStressPoints: const [], | ||
| 398 | + ), | ||
| 399 | + hasExistingHrv: false, | ||
| 400 | + hasExistingSleep: false, | ||
| 401 | + realtimeWindow: [ | ||
| 402 | + for (var i = 0; i < 10; i++) _realtimePoint(base + i * 300, 70), | ||
| 403 | + ], | ||
| 404 | + record: HealthRawLocalNotificationRecord( | ||
| 405 | + lastRealtimeStressTime: base + 9 * 300, | ||
| 406 | + ), | ||
| 407 | + notificationBuildTime: base + 9 * 300, | ||
| 408 | + ); | ||
| 409 | + | ||
| 410 | + expect(notifications, hasLength(1)); | ||
| 411 | + }); | ||
| 412 | + | ||
| 211 | test('skips realtime stress notification during likely sleep', () { | 413 | test('skips realtime stress notification during likely sleep', () { |
| 212 | final base = _seconds(DateTime(2026, 1, 1, 9)); | 414 | final base = _seconds(DateTime(2026, 1, 1, 9)); |
| 213 | final notifications = builder.build( | 415 | final notifications = builder.build( |
| @@ -292,11 +494,57 @@ void main() { | @@ -292,11 +494,57 @@ void main() { | ||
| 292 | final record = await dispatcher.readRecord(1); | 494 | final record = await dispatcher.readRecord(1); |
| 293 | expect(record.lastRealtimeStressTime, 1234); | 495 | expect(record.lastRealtimeStressTime, 1234); |
| 294 | }); | 496 | }); |
| 497 | + | ||
| 498 | + test('reads old notification record json without latest data times', () { | ||
| 499 | + final record = HealthRawLocalNotificationRecord.fromJson( | ||
| 500 | + const <String, Object?>{ | ||
| 501 | + 'last_sleep_time': 100, | ||
| 502 | + 'last_hrv_time': 200, | ||
| 503 | + 'last_realtime_stress_time': 300, | ||
| 504 | + }, | ||
| 505 | + ); | ||
| 506 | + | ||
| 507 | + expect(record.lastSleepTime, 100); | ||
| 508 | + expect(record.lastHrvTime, 200); | ||
| 509 | + expect(record.lastRealtimeStressTime, 300); | ||
| 510 | + expect(record.latestHrvDataTime, isNull); | ||
| 511 | + expect(record.latestRealtimeStressDataTime, isNull); | ||
| 512 | + expect(record.latestSleepDataTime, isNull); | ||
| 513 | + }); | ||
| 514 | + | ||
| 515 | + test('stores processed latest data times independently', () async { | ||
| 516 | + final dir = await Directory.systemTemp.createTemp( | ||
| 517 | + 'health_raw_notification_test_', | ||
| 518 | + ); | ||
| 519 | + addTearDown(() => dir.delete(recursive: true)); | ||
| 520 | + final dispatcher = HealthRawLocalNotificationDispatcher( | ||
| 521 | + recordStore: HealthRawLocalNotificationRecordStore(rootDirectory: dir), | ||
| 522 | + ); | ||
| 523 | + | ||
| 524 | + await dispatcher.updateProcessedDataTimes( | ||
| 525 | + userId: 1, | ||
| 526 | + latestHrvDataTime: 100, | ||
| 527 | + latestRealtimeStressDataTime: 200, | ||
| 528 | + latestSleepDataTime: 300, | ||
| 529 | + ); | ||
| 530 | + await dispatcher.updateProcessedDataTimes( | ||
| 531 | + userId: 1, | ||
| 532 | + latestRealtimeStressDataTime: 250, | ||
| 533 | + ); | ||
| 534 | + | ||
| 535 | + final record = await dispatcher.readRecord(1); | ||
| 536 | + expect(record.latestHrvDataTime, 100); | ||
| 537 | + expect(record.latestRealtimeStressDataTime, 250); | ||
| 538 | + expect(record.latestSleepDataTime, 300); | ||
| 539 | + }); | ||
| 295 | } | 540 | } |
| 296 | 541 | ||
| 297 | int _seconds(DateTime time) => time.millisecondsSinceEpoch ~/ 1000; | 542 | int _seconds(DateTime time) => time.millisecondsSinceEpoch ~/ 1000; |
| 298 | 543 | ||
| 299 | -HealthRawHrvStressPoint _hrvPoint(int rawEndTime) { | 544 | +HealthRawHrvStressPoint _hrvPoint( |
| 545 | + int rawEndTime, { | ||
| 546 | + bool isSleepLikely = false, | ||
| 547 | +}) { | ||
| 300 | return HealthRawHrvStressPoint( | 548 | return HealthRawHrvStressPoint( |
| 301 | userId: 1, | 549 | userId: 1, |
| 302 | rawEndTime: rawEndTime, | 550 | rawEndTime: rawEndTime, |
| @@ -309,6 +557,12 @@ HealthRawHrvStressPoint _hrvPoint(int rawEndTime) { | @@ -309,6 +557,12 @@ HealthRawHrvStressPoint _hrvPoint(int rawEndTime) { | ||
| 309 | baselineAwakeHrv: 30, | 557 | baselineAwakeHrv: 30, |
| 310 | baselineSleepHrv: null, | 558 | baselineSleepHrv: null, |
| 311 | baselineRestingHr: 60, | 559 | baselineRestingHr: 60, |
| 560 | + flags: HealthRawPointFlags( | ||
| 561 | + isSleepLikely: isSleepLikely, | ||
| 562 | + isWorkout: false, | ||
| 563 | + isWorkoutRecovery: false, | ||
| 564 | + isSuspectedActivity: false, | ||
| 565 | + ), | ||
| 312 | ); | 566 | ); |
| 313 | } | 567 | } |
| 314 | 568 |
-
Please register or login to post a comment