Showing
6 changed files
with
882 additions
and
228 deletions
| @@ -160,6 +160,8 @@ class HealthRawHrvStressPoint { | @@ -160,6 +160,8 @@ class HealthRawHrvStressPoint { | ||
| 160 | required this.baselineRestingHr, | 160 | required this.baselineRestingHr, |
| 161 | this.flags = const HealthRawPointFlags.none(), | 161 | this.flags = const HealthRawPointFlags.none(), |
| 162 | this.uploaded = false, | 162 | this.uploaded = false, |
| 163 | + this.pushSendTime, | ||
| 164 | + this.uploadTime, | ||
| 163 | }); | 165 | }); |
| 164 | 166 | ||
| 165 | final int userId; | 167 | final int userId; |
| @@ -175,6 +177,8 @@ class HealthRawHrvStressPoint { | @@ -175,6 +177,8 @@ class HealthRawHrvStressPoint { | ||
| 175 | final double baselineRestingHr; | 177 | final double baselineRestingHr; |
| 176 | final HealthRawPointFlags flags; | 178 | final HealthRawPointFlags flags; |
| 177 | final bool uploaded; | 179 | final bool uploaded; |
| 180 | + final int? pushSendTime; | ||
| 181 | + final int? uploadTime; | ||
| 178 | 182 | ||
| 179 | factory HealthRawHrvStressPoint.fromDb(Map<String, Object?> row) { | 183 | factory HealthRawHrvStressPoint.fromDb(Map<String, Object?> row) { |
| 180 | final result = (row['result'] as num).toDouble(); | 184 | final result = (row['result'] as num).toDouble(); |
| @@ -196,6 +200,8 @@ class HealthRawHrvStressPoint { | @@ -196,6 +200,8 @@ class HealthRawHrvStressPoint { | ||
| 196 | baselineRestingHr: _positiveDouble(row['baseline_resting_hr']) ?? 65, | 200 | baselineRestingHr: _positiveDouble(row['baseline_resting_hr']) ?? 65, |
| 197 | flags: _flagsFromDb(row), | 201 | flags: _flagsFromDb(row), |
| 198 | uploaded: (row['uploaded'] as int) == 1, | 202 | uploaded: (row['uploaded'] as int) == 1, |
| 203 | + pushSendTime: (row['push_send_time'] as num?)?.toInt(), | ||
| 204 | + uploadTime: (row['upload_time'] as num?)?.toInt(), | ||
| 199 | ); | 205 | ); |
| 200 | } | 206 | } |
| 201 | } | 207 | } |
| @@ -210,6 +216,8 @@ class HealthRawRealtimeStressPoint { | @@ -210,6 +216,8 @@ class HealthRawRealtimeStressPoint { | ||
| 210 | required this.sourceEndTime, | 216 | required this.sourceEndTime, |
| 211 | this.flags = const HealthRawPointFlags.none(), | 217 | this.flags = const HealthRawPointFlags.none(), |
| 212 | this.uploaded = false, | 218 | this.uploaded = false, |
| 219 | + this.pushSendTime, | ||
| 220 | + this.uploadTime, | ||
| 213 | }); | 221 | }); |
| 214 | 222 | ||
| 215 | final int userId; | 223 | final int userId; |
| @@ -220,6 +228,8 @@ class HealthRawRealtimeStressPoint { | @@ -220,6 +228,8 @@ class HealthRawRealtimeStressPoint { | ||
| 220 | final int sourceEndTime; | 228 | final int sourceEndTime; |
| 221 | final HealthRawPointFlags flags; | 229 | final HealthRawPointFlags flags; |
| 222 | final bool uploaded; | 230 | final bool uploaded; |
| 231 | + final int? pushSendTime; | ||
| 232 | + final int? uploadTime; | ||
| 223 | 233 | ||
| 224 | HealthRawStressState get state => healthRawRealtimeStressState(result); | 234 | HealthRawStressState get state => healthRawRealtimeStressState(result); |
| 225 | bool get isWorkout => flags.isWorkout; | 235 | bool get isWorkout => flags.isWorkout; |
| @@ -237,6 +247,8 @@ class HealthRawRealtimeStressPoint { | @@ -237,6 +247,8 @@ class HealthRawRealtimeStressPoint { | ||
| 237 | sourceEndTime: row['source_end_time'] as int, | 247 | sourceEndTime: row['source_end_time'] as int, |
| 238 | flags: _flagsFromDb(row), | 248 | flags: _flagsFromDb(row), |
| 239 | uploaded: (row['uploaded'] as int) == 1, | 249 | uploaded: (row['uploaded'] as int) == 1, |
| 250 | + pushSendTime: (row['push_send_time'] as num?)?.toInt(), | ||
| 251 | + uploadTime: (row['upload_time'] as num?)?.toInt(), | ||
| 240 | ); | 252 | ); |
| 241 | } | 253 | } |
| 242 | } | 254 | } |
| @@ -250,6 +262,7 @@ class HealthRawDailyStressPoint { | @@ -250,6 +262,7 @@ class HealthRawDailyStressPoint { | ||
| 250 | required this.state, | 262 | required this.state, |
| 251 | required this.dataTime, | 263 | required this.dataTime, |
| 252 | this.uploaded = false, | 264 | this.uploaded = false, |
| 265 | + this.uploadTime, | ||
| 253 | }); | 266 | }); |
| 254 | 267 | ||
| 255 | final int userId; | 268 | final int userId; |
| @@ -259,6 +272,7 @@ class HealthRawDailyStressPoint { | @@ -259,6 +272,7 @@ class HealthRawDailyStressPoint { | ||
| 259 | final HealthRawStressState state; | 272 | final HealthRawStressState state; |
| 260 | final int dataTime; | 273 | final int dataTime; |
| 261 | final bool uploaded; | 274 | final bool uploaded; |
| 275 | + final int? uploadTime; | ||
| 262 | 276 | ||
| 263 | factory HealthRawDailyStressPoint.fromDb(Map<String, Object?> row) { | 277 | factory HealthRawDailyStressPoint.fromDb(Map<String, Object?> row) { |
| 264 | final stressScore = row['stress_score'] as int; | 278 | final stressScore = row['stress_score'] as int; |
| @@ -271,6 +285,7 @@ class HealthRawDailyStressPoint { | @@ -271,6 +285,7 @@ class HealthRawDailyStressPoint { | ||
| 271 | healthRawRealtimeStressState(stressScore), | 285 | healthRawRealtimeStressState(stressScore), |
| 272 | dataTime: row['data_time'] as int, | 286 | dataTime: row['data_time'] as int, |
| 273 | uploaded: (row['uploaded'] as int) == 1, | 287 | uploaded: (row['uploaded'] as int) == 1, |
| 288 | + uploadTime: (row['upload_time'] as num?)?.toInt(), | ||
| 274 | ); | 289 | ); |
| 275 | } | 290 | } |
| 276 | } | 291 | } |
| @@ -286,6 +301,8 @@ class HealthRawSleepResult { | @@ -286,6 +301,8 @@ class HealthRawSleepResult { | ||
| 286 | required this.awakMinutes, | 301 | required this.awakMinutes, |
| 287 | required this.sleepMinutes, | 302 | required this.sleepMinutes, |
| 288 | this.uploaded = false, | 303 | this.uploaded = false, |
| 304 | + this.pushSendTime, | ||
| 305 | + this.uploadTime, | ||
| 289 | }); | 306 | }); |
| 290 | 307 | ||
| 291 | final int userId; | 308 | final int userId; |
| @@ -297,6 +314,8 @@ class HealthRawSleepResult { | @@ -297,6 +314,8 @@ class HealthRawSleepResult { | ||
| 297 | final int awakMinutes; | 314 | final int awakMinutes; |
| 298 | final int sleepMinutes; | 315 | final int sleepMinutes; |
| 299 | final bool uploaded; | 316 | final bool uploaded; |
| 317 | + final int? pushSendTime; | ||
| 318 | + final int? uploadTime; | ||
| 300 | 319 | ||
| 301 | factory HealthRawSleepResult.fromDb(Map<String, Object?> row) { | 320 | factory HealthRawSleepResult.fromDb(Map<String, Object?> row) { |
| 302 | return HealthRawSleepResult( | 321 | return HealthRawSleepResult( |
| @@ -309,6 +328,8 @@ class HealthRawSleepResult { | @@ -309,6 +328,8 @@ class HealthRawSleepResult { | ||
| 309 | awakMinutes: row['awak_minutes'] as int, | 328 | awakMinutes: row['awak_minutes'] as int, |
| 310 | sleepMinutes: row['sleep_minutes'] as int, | 329 | sleepMinutes: row['sleep_minutes'] as int, |
| 311 | uploaded: (row['uploaded'] as int? ?? 0) == 1, | 330 | uploaded: (row['uploaded'] as int? ?? 0) == 1, |
| 331 | + pushSendTime: (row['push_send_time'] as num?)?.toInt(), | ||
| 332 | + uploadTime: (row['upload_time'] as num?)?.toInt(), | ||
| 312 | ); | 333 | ); |
| 313 | } | 334 | } |
| 314 | 335 |
| @@ -17,7 +17,6 @@ import '../../../logging/app_logger.dart'; | @@ -17,7 +17,6 @@ import '../../../logging/app_logger.dart'; | ||
| 17 | import '../../../network/api/health_api.dart'; | 17 | import '../../../network/api/health_api.dart'; |
| 18 | import '../../../result/app_result.dart'; | 18 | import '../../../result/app_result.dart'; |
| 19 | import '../health_raw_models.dart'; | 19 | import '../health_raw_models.dart'; |
| 20 | -import 'apple_health_raw_local_notification_debug_store.dart'; | ||
| 21 | import 'apple_health_raw_local_notification.dart'; | 20 | import 'apple_health_raw_local_notification.dart'; |
| 22 | import '../health_raw_stress_calculator.dart'; | 21 | import '../health_raw_stress_calculator.dart'; |
| 23 | import '../health_sleep_calculator.dart'; | 22 | import '../health_sleep_calculator.dart'; |
| @@ -39,7 +38,6 @@ class AppleHealthRawDataCoreService { | @@ -39,7 +38,6 @@ class AppleHealthRawDataCoreService { | ||
| 39 | int Function()? userIdProvider, | 38 | int Function()? userIdProvider, |
| 40 | bool uploadResultsAfterCalculation = true, | 39 | bool uploadResultsAfterCalculation = true, |
| 41 | HealthRawLocalNotificationDispatcher? localNotificationDispatcher, | 40 | HealthRawLocalNotificationDispatcher? localNotificationDispatcher, |
| 42 | - HealthRawLocalNotificationDebugStore? localNotificationDebugStore, | ||
| 43 | HealthApi? serverHealthApi, | 41 | HealthApi? serverHealthApi, |
| 44 | }) : _healthApi = healthApi ?? HealthKitHostApi(), | 42 | }) : _healthApi = healthApi ?? HealthKitHostApi(), |
| 45 | _rawDataApi = rawDataApi ?? HealthKitRawDataHostApi(), | 43 | _rawDataApi = rawDataApi ?? HealthKitRawDataHostApi(), |
| @@ -49,8 +47,6 @@ class AppleHealthRawDataCoreService { | @@ -49,8 +47,6 @@ class AppleHealthRawDataCoreService { | ||
| 49 | _uploadResultsAfterCalculation = uploadResultsAfterCalculation, | 47 | _uploadResultsAfterCalculation = uploadResultsAfterCalculation, |
| 50 | _localNotificationDispatcher = localNotificationDispatcher ?? | 48 | _localNotificationDispatcher = localNotificationDispatcher ?? |
| 51 | HealthRawLocalNotificationDispatcher(), | 49 | HealthRawLocalNotificationDispatcher(), |
| 52 | - _localNotificationDebugStore = localNotificationDebugStore ?? | ||
| 53 | - HealthRawLocalNotificationDebugStore(), | ||
| 54 | _serverHealthApi = serverHealthApi; | 50 | _serverHealthApi = serverHealthApi; |
| 55 | 51 | ||
| 56 | final HealthKitHostApi _healthApi; | 52 | final HealthKitHostApi _healthApi; |
| @@ -60,7 +56,6 @@ class AppleHealthRawDataCoreService { | @@ -60,7 +56,6 @@ class AppleHealthRawDataCoreService { | ||
| 60 | final int Function()? _userIdProvider; | 56 | final int Function()? _userIdProvider; |
| 61 | final bool _uploadResultsAfterCalculation; | 57 | final bool _uploadResultsAfterCalculation; |
| 62 | final HealthRawLocalNotificationDispatcher _localNotificationDispatcher; | 58 | final HealthRawLocalNotificationDispatcher _localNotificationDispatcher; |
| 63 | - final HealthRawLocalNotificationDebugStore _localNotificationDebugStore; | ||
| 64 | final HealthApi? _serverHealthApi; | 59 | final HealthApi? _serverHealthApi; |
| 65 | final StreamController<HealthRawDataUpdatedEvent> | 60 | final StreamController<HealthRawDataUpdatedEvent> |
| 66 | _healthDataUpdatedController = | 61 | _healthDataUpdatedController = |
| @@ -219,7 +214,7 @@ class AppleHealthRawDataCoreService { | @@ -219,7 +214,7 @@ class AppleHealthRawDataCoreService { | ||
| 219 | } | 214 | } |
| 220 | 215 | ||
| 221 | Future<String> readLocalNotificationDebugLogText() { | 216 | Future<String> readLocalNotificationDebugLogText() { |
| 222 | - return _localNotificationDebugStore.readText(); | 217 | + return Future.value(''); |
| 223 | } | 218 | } |
| 224 | 219 | ||
| 225 | Future<bool> shareLocalNotificationDebugRecord() async { | 220 | Future<bool> shareLocalNotificationDebugRecord() async { |
| @@ -537,6 +532,12 @@ class AppleHealthRawDataCoreService { | @@ -537,6 +532,12 @@ class AppleHealthRawDataCoreService { | ||
| 537 | ); | 532 | ); |
| 538 | var notificationResult = candidateResult; | 533 | var notificationResult = candidateResult; |
| 539 | var realtimeWindow = const <HealthRawRealtimeStressPoint>[]; | 534 | var realtimeWindow = const <HealthRawRealtimeStressPoint>[]; |
| 535 | + final lastRealtimeStressPushSendTime = | ||
| 536 | + await _localStore.latestRealtimeStressPushSendTime( | ||
| 537 | + candidateResult.userId, | ||
| 538 | + ); | ||
| 539 | + final notificationBuildTime = _unixSecondsNow(); | ||
| 540 | + var realtimeSleepIntervals = const <HealthRawSleepInterval>[]; | ||
| 540 | if (latestRealtimeStressPoint != null) { | 541 | if (latestRealtimeStressPoint != null) { |
| 541 | final shouldResetRealtimeStressPushTime = | 542 | final shouldResetRealtimeStressPushTime = |
| 542 | latestRealtimeStressPoint.isWorkout || | 543 | latestRealtimeStressPoint.isWorkout || |
| @@ -551,10 +552,6 @@ class AppleHealthRawDataCoreService { | @@ -551,10 +552,6 @@ class AppleHealthRawDataCoreService { | ||
| 551 | _realtimeStressPointPayload(latestRealtimeStressPoint), | 552 | _realtimeStressPointPayload(latestRealtimeStressPoint), |
| 552 | }, | 553 | }, |
| 553 | ); | 554 | ); |
| 554 | - await _recordRealtimeStressTimeSafely( | ||
| 555 | - userId: candidateResult.userId, | ||
| 556 | - recordTime: latestRealtimeStressPoint.rawEndTime, | ||
| 557 | - ); | ||
| 558 | notificationResult = candidateResult.copyWith( | 555 | notificationResult = candidateResult.copyWith( |
| 559 | realtimeStressPoints: const <HealthRawRealtimeStressPoint>[], | 556 | realtimeStressPoints: const <HealthRawRealtimeStressPoint>[], |
| 560 | ); | 557 | ); |
| @@ -563,6 +560,11 @@ class AppleHealthRawDataCoreService { | @@ -563,6 +560,11 @@ class AppleHealthRawDataCoreService { | ||
| 563 | userId: candidateResult.userId, | 560 | userId: candidateResult.userId, |
| 564 | latestRawEndTime: latestRealtimeStressPoint.rawEndTime, | 561 | latestRawEndTime: latestRealtimeStressPoint.rawEndTime, |
| 565 | ); | 562 | ); |
| 563 | + realtimeSleepIntervals = | ||
| 564 | + await _queryRealtimeStressSleepIntervalsForNotificationSafely( | ||
| 565 | + userId: candidateResult.userId, | ||
| 566 | + latestRawEndTime: latestRealtimeStressPoint.rawEndTime, | ||
| 567 | + ); | ||
| 566 | } | 568 | } |
| 567 | } | 569 | } |
| 568 | 570 | ||
| @@ -576,7 +578,10 @@ class AppleHealthRawDataCoreService { | @@ -576,7 +578,10 @@ class AppleHealthRawDataCoreService { | ||
| 576 | hasExistingHrv: candidateData.hasExistingHrvDataTime, | 578 | hasExistingHrv: candidateData.hasExistingHrvDataTime, |
| 577 | hasExistingSleep: candidateData.hasExistingSleepDataTime, | 579 | hasExistingSleep: candidateData.hasExistingSleepDataTime, |
| 578 | realtimeWindow: realtimeWindow, | 580 | realtimeWindow: realtimeWindow, |
| 581 | + realtimeSleepIntervals: realtimeSleepIntervals, | ||
| 579 | record: record, | 582 | record: record, |
| 583 | + lastRealtimeStressPushSendTime: lastRealtimeStressPushSendTime, | ||
| 584 | + notificationBuildTime: notificationBuildTime, | ||
| 580 | ), | 585 | ), |
| 581 | }, | 586 | }, |
| 582 | ); | 587 | ); |
| @@ -585,7 +590,10 @@ class AppleHealthRawDataCoreService { | @@ -585,7 +590,10 @@ class AppleHealthRawDataCoreService { | ||
| 585 | hasExistingHrv: candidateData.hasExistingHrvDataTime, | 590 | hasExistingHrv: candidateData.hasExistingHrvDataTime, |
| 586 | hasExistingSleep: candidateData.hasExistingSleepDataTime, | 591 | hasExistingSleep: candidateData.hasExistingSleepDataTime, |
| 587 | realtimeWindow: realtimeWindow, | 592 | realtimeWindow: realtimeWindow, |
| 593 | + realtimeSleepIntervals: realtimeSleepIntervals, | ||
| 588 | record: record, | 594 | record: record, |
| 595 | + lastRealtimeStressPushSendTime: lastRealtimeStressPushSendTime, | ||
| 596 | + notificationBuildTime: notificationBuildTime, | ||
| 589 | ); | 597 | ); |
| 590 | await _saveLocalNotificationDebugEvent( | 598 | await _saveLocalNotificationDebugEvent( |
| 591 | <String, Object?>{ | 599 | <String, Object?>{ |
| @@ -634,6 +642,10 @@ class AppleHealthRawDataCoreService { | @@ -634,6 +642,10 @@ class AppleHealthRawDataCoreService { | ||
| 634 | ); | 642 | ); |
| 635 | if (sent) { | 643 | if (sent) { |
| 636 | sentNotifications.add(notification); | 644 | sentNotifications.add(notification); |
| 645 | + await _recordLocalNotificationPushSendTimeSafely( | ||
| 646 | + userId: candidateResult.userId, | ||
| 647 | + notification: notification, | ||
| 648 | + ); | ||
| 637 | } | 649 | } |
| 638 | } | 650 | } |
| 639 | _scheduleRealtimeStressServerPush(sentNotifications); | 651 | _scheduleRealtimeStressServerPush(sentNotifications); |
| @@ -759,17 +771,20 @@ class AppleHealthRawDataCoreService { | @@ -759,17 +771,20 @@ class AppleHealthRawDataCoreService { | ||
| 759 | } | 771 | } |
| 760 | } | 772 | } |
| 761 | 773 | ||
| 762 | - Future<void> _recordRealtimeStressTimeSafely({ | 774 | + Future<void> _recordLocalNotificationPushSendTimeSafely({ |
| 763 | required int userId, | 775 | required int userId, |
| 764 | - required int recordTime, | 776 | + required HealthRawLocalNotification notification, |
| 765 | }) async { | 777 | }) async { |
| 766 | try { | 778 | try { |
| 767 | - await _localNotificationDispatcher.recordRealtimeStressTime( | 779 | + await _localStore.markLocalNotificationPushed( |
| 768 | userId: userId, | 780 | userId: userId, |
| 769 | - recordTime: recordTime, | 781 | + recordType: notification.recordType, |
| 782 | + recordTime: notification.recordTime, | ||
| 783 | + pushSendTime: _unixSecondsNow(), | ||
| 770 | ); | 784 | ); |
| 771 | } catch (error, stackTrace) { | 785 | } catch (error, stackTrace) { |
| 772 | - _logError('record realtime stress push time failed', error, stackTrace); | 786 | + _logError( |
| 787 | + 'record local notification push send time failed', error, stackTrace); | ||
| 773 | } | 788 | } |
| 774 | } | 789 | } |
| 775 | 790 | ||
| @@ -815,6 +830,73 @@ class AppleHealthRawDataCoreService { | @@ -815,6 +830,73 @@ class AppleHealthRawDataCoreService { | ||
| 815 | } | 830 | } |
| 816 | } | 831 | } |
| 817 | 832 | ||
| 833 | + Future<List<HealthRawSleepInterval>> | ||
| 834 | + _queryRealtimeStressSleepIntervalsForNotificationSafely({ | ||
| 835 | + required int userId, | ||
| 836 | + required int latestRawEndTime, | ||
| 837 | + }) async { | ||
| 838 | + try { | ||
| 839 | + final intervals = await _queryRealtimeStressSleepIntervalsForNotification( | ||
| 840 | + userId: userId, | ||
| 841 | + latestRawEndTime: latestRawEndTime, | ||
| 842 | + ); | ||
| 843 | + await _saveLocalNotificationDebugEvent( | ||
| 844 | + <String, Object?>{ | ||
| 845 | + 'event': 'flutterRealtimeStressSleepIntervalsQueried', | ||
| 846 | + 'user_id': userId, | ||
| 847 | + 'latest_raw_end_time': latestRawEndTime, | ||
| 848 | + 'sleep_intervals': intervals.map(_sleepIntervalPayload).toList(), | ||
| 849 | + }, | ||
| 850 | + ); | ||
| 851 | + return intervals; | ||
| 852 | + } catch (error, stackTrace) { | ||
| 853 | + _logError( | ||
| 854 | + 'query realtime stress sleep intervals failed', | ||
| 855 | + error, | ||
| 856 | + stackTrace, | ||
| 857 | + ); | ||
| 858 | + final fallback = [_defaultSleepIntervalForUnixSeconds(latestRawEndTime)]; | ||
| 859 | + await _saveLocalNotificationDebugEvent( | ||
| 860 | + <String, Object?>{ | ||
| 861 | + 'event': 'flutterRealtimeStressSleepIntervalsQueryFailed', | ||
| 862 | + 'user_id': userId, | ||
| 863 | + 'latest_raw_end_time': latestRawEndTime, | ||
| 864 | + 'sleep_intervals': fallback.map(_sleepIntervalPayload).toList(), | ||
| 865 | + 'error': error.toString(), | ||
| 866 | + 'stack_trace': stackTrace.toString(), | ||
| 867 | + }, | ||
| 868 | + ); | ||
| 869 | + return fallback; | ||
| 870 | + } | ||
| 871 | + } | ||
| 872 | + | ||
| 873 | + Future<List<HealthRawSleepInterval>> | ||
| 874 | + _queryRealtimeStressSleepIntervalsForNotification({ | ||
| 875 | + required int userId, | ||
| 876 | + required int latestRawEndTime, | ||
| 877 | + }) async { | ||
| 878 | + final dayRange = _dayRangeFromUnixSeconds(latestRawEndTime); | ||
| 879 | + final sleepResults = await _localStore.querySleepResults( | ||
| 880 | + userId: userId, | ||
| 881 | + startTime: dayRange.$1, | ||
| 882 | + endTime: dayRange.$2, | ||
| 883 | + ); | ||
| 884 | + final intervals = sleepResults | ||
| 885 | + .where((result) => result.startDate <= result.date) | ||
| 886 | + .map( | ||
| 887 | + (result) => ( | ||
| 888 | + startTime: result.startDate, | ||
| 889 | + endTime: result.date, | ||
| 890 | + ), | ||
| 891 | + ) | ||
| 892 | + .toList() | ||
| 893 | + ..sort((a, b) => a.startTime.compareTo(b.startTime)); | ||
| 894 | + if (intervals.isNotEmpty) { | ||
| 895 | + return intervals; | ||
| 896 | + } | ||
| 897 | + return [_defaultSleepIntervalForUnixSeconds(latestRawEndTime)]; | ||
| 898 | + } | ||
| 899 | + | ||
| 818 | Future<bool> _sendLocalNotificationSafely({ | 900 | Future<bool> _sendLocalNotificationSafely({ |
| 819 | required int userId, | 901 | required int userId, |
| 820 | required HealthRawLocalNotification notification, | 902 | required HealthRawLocalNotification notification, |
| @@ -844,7 +926,10 @@ class AppleHealthRawDataCoreService { | @@ -844,7 +926,10 @@ class AppleHealthRawDataCoreService { | ||
| 844 | required bool hasExistingHrv, | 926 | required bool hasExistingHrv, |
| 845 | required bool hasExistingSleep, | 927 | required bool hasExistingSleep, |
| 846 | required List<HealthRawRealtimeStressPoint> realtimeWindow, | 928 | required List<HealthRawRealtimeStressPoint> realtimeWindow, |
| 929 | + required List<HealthRawSleepInterval> realtimeSleepIntervals, | ||
| 847 | required HealthRawLocalNotificationRecord record, | 930 | required HealthRawLocalNotificationRecord record, |
| 931 | + required int? lastRealtimeStressPushSendTime, | ||
| 932 | + required int notificationBuildTime, | ||
| 848 | }) { | 933 | }) { |
| 849 | return <Map<String, Object?>>[ | 934 | return <Map<String, Object?>>[ |
| 850 | _sleepNotificationDecisionPayload( | 935 | _sleepNotificationDecisionPayload( |
| @@ -859,7 +944,10 @@ class AppleHealthRawDataCoreService { | @@ -859,7 +944,10 @@ class AppleHealthRawDataCoreService { | ||
| 859 | _realtimeStressNotificationDecisionPayload( | 944 | _realtimeStressNotificationDecisionPayload( |
| 860 | result.realtimeStressPoints, | 945 | result.realtimeStressPoints, |
| 861 | realtimeWindow: realtimeWindow, | 946 | realtimeWindow: realtimeWindow, |
| 947 | + realtimeSleepIntervals: realtimeSleepIntervals, | ||
| 862 | record: record, | 948 | record: record, |
| 949 | + lastPushSendTime: lastRealtimeStressPushSendTime, | ||
| 950 | + notificationBuildTime: notificationBuildTime, | ||
| 863 | ), | 951 | ), |
| 864 | ]; | 952 | ]; |
| 865 | } | 953 | } |
| @@ -937,20 +1025,23 @@ class AppleHealthRawDataCoreService { | @@ -937,20 +1025,23 @@ class AppleHealthRawDataCoreService { | ||
| 937 | } | 1025 | } |
| 938 | final sorted = [...hrvPoints] | 1026 | final sorted = [...hrvPoints] |
| 939 | ..sort((a, b) => a.rawEndTime.compareTo(b.rawEndTime)); | 1027 | ..sort((a, b) => a.rawEndTime.compareTo(b.rawEndTime)); |
| 940 | - final latest = sorted.last; | ||
| 941 | return <String, Object?>{ | 1028 | return <String, Object?>{ |
| 942 | 'type': HealthRawLocalNotificationRecordType.hrv.name, | 1029 | 'type': HealthRawLocalNotificationRecordType.hrv.name, |
| 943 | 'will_build': true, | 1030 | 'will_build': true, |
| 944 | 'reason': 'candidate', | 1031 | 'reason': 'candidate', |
| 945 | 'last_hrv_time': record.lastHrvTime, | 1032 | 'last_hrv_time': record.lastHrvTime, |
| 946 | - 'latest_hrv': _hrvStressPointPayload(latest), | 1033 | + 'candidate_count': sorted.length, |
| 1034 | + 'latest_hrv': _hrvStressPointPayload(sorted.last), | ||
| 947 | }; | 1035 | }; |
| 948 | } | 1036 | } |
| 949 | 1037 | ||
| 950 | Map<String, Object?> _realtimeStressNotificationDecisionPayload( | 1038 | Map<String, Object?> _realtimeStressNotificationDecisionPayload( |
| 951 | List<HealthRawRealtimeStressPoint> realtimePoints, { | 1039 | List<HealthRawRealtimeStressPoint> realtimePoints, { |
| 952 | required List<HealthRawRealtimeStressPoint> realtimeWindow, | 1040 | required List<HealthRawRealtimeStressPoint> realtimeWindow, |
| 1041 | + required List<HealthRawSleepInterval> realtimeSleepIntervals, | ||
| 953 | required HealthRawLocalNotificationRecord record, | 1042 | required HealthRawLocalNotificationRecord record, |
| 1043 | + required int? lastPushSendTime, | ||
| 1044 | + required int notificationBuildTime, | ||
| 954 | }) { | 1045 | }) { |
| 955 | if (realtimePoints.isEmpty) { | 1046 | if (realtimePoints.isEmpty) { |
| 956 | return <String, Object?>{ | 1047 | return <String, Object?>{ |
| @@ -970,6 +1061,19 @@ class AppleHealthRawDataCoreService { | @@ -970,6 +1061,19 @@ class AppleHealthRawDataCoreService { | ||
| 970 | 'latest_realtime': _realtimeStressPointPayload(latest), | 1061 | 'latest_realtime': _realtimeStressPointPayload(latest), |
| 971 | }; | 1062 | }; |
| 972 | } | 1063 | } |
| 1064 | + final matchedSleepInterval = | ||
| 1065 | + _sleepIntervalContaining(latest.rawEndTime, realtimeSleepIntervals); | ||
| 1066 | + if (matchedSleepInterval != null) { | ||
| 1067 | + return <String, Object?>{ | ||
| 1068 | + 'type': HealthRawLocalNotificationRecordType.realtimeStress.name, | ||
| 1069 | + 'will_build': false, | ||
| 1070 | + 'reason': 'sleep_interval', | ||
| 1071 | + 'matched_sleep_interval': _sleepIntervalPayload(matchedSleepInterval), | ||
| 1072 | + 'sleep_intervals': | ||
| 1073 | + realtimeSleepIntervals.map(_sleepIntervalPayload).toList(), | ||
| 1074 | + 'latest_realtime': _realtimeStressPointPayload(latest), | ||
| 1075 | + }; | ||
| 1076 | + } | ||
| 973 | final valid = realtimeWindow | 1077 | final valid = realtimeWindow |
| 974 | .where((e) => e.result >= 1 && e.result <= 100) | 1078 | .where((e) => e.result >= 1 && e.result <= 100) |
| 975 | .toList() | 1079 | .toList() |
| @@ -991,14 +1095,15 @@ class AppleHealthRawDataCoreService { | @@ -991,14 +1095,15 @@ class AppleHealthRawDataCoreService { | ||
| 991 | 'latest_realtime': _realtimeStressPointPayload(latest), | 1095 | 'latest_realtime': _realtimeStressPointPayload(latest), |
| 992 | }; | 1096 | }; |
| 993 | } | 1097 | } |
| 994 | - final lastPushTime = record.lastRealtimeStressTime; | 1098 | + final lastPushTime = lastPushSendTime; |
| 995 | if (lastPushTime != null && | 1099 | if (lastPushTime != null && |
| 996 | - latest.rawEndTime - lastPushTime < Duration.secondsPerHour) { | 1100 | + notificationBuildTime - lastPushTime < Duration.secondsPerHour) { |
| 997 | return <String, Object?>{ | 1101 | return <String, Object?>{ |
| 998 | 'type': HealthRawLocalNotificationRecordType.realtimeStress.name, | 1102 | 'type': HealthRawLocalNotificationRecordType.realtimeStress.name, |
| 999 | 'will_build': false, | 1103 | 'will_build': false, |
| 1000 | 'reason': 'within_realtime_interval', | 1104 | 'reason': 'within_realtime_interval', |
| 1001 | - 'last_realtime_stress_time': lastPushTime, | 1105 | + 'last_realtime_stress_push_send_time': lastPushTime, |
| 1106 | + 'notification_build_time': notificationBuildTime, | ||
| 1002 | 'latest_realtime': _realtimeStressPointPayload(latest), | 1107 | 'latest_realtime': _realtimeStressPointPayload(latest), |
| 1003 | }; | 1108 | }; |
| 1004 | } | 1109 | } |
| @@ -1006,12 +1111,36 @@ class AppleHealthRawDataCoreService { | @@ -1006,12 +1111,36 @@ class AppleHealthRawDataCoreService { | ||
| 1006 | 'type': HealthRawLocalNotificationRecordType.realtimeStress.name, | 1111 | 'type': HealthRawLocalNotificationRecordType.realtimeStress.name, |
| 1007 | 'will_build': true, | 1112 | 'will_build': true, |
| 1008 | 'reason': 'candidate', | 1113 | 'reason': 'candidate', |
| 1009 | - 'last_realtime_stress_time': lastPushTime, | 1114 | + 'last_realtime_stress_push_send_time': lastPushTime, |
| 1115 | + 'notification_build_time': notificationBuildTime, | ||
| 1010 | 'valid_window_count': valid.length, | 1116 | 'valid_window_count': valid.length, |
| 1117 | + 'sleep_intervals': | ||
| 1118 | + realtimeSleepIntervals.map(_sleepIntervalPayload).toList(), | ||
| 1011 | 'latest_realtime': _realtimeStressPointPayload(latest), | 1119 | 'latest_realtime': _realtimeStressPointPayload(latest), |
| 1012 | }; | 1120 | }; |
| 1013 | } | 1121 | } |
| 1014 | 1122 | ||
| 1123 | + HealthRawSleepInterval? _sleepIntervalContaining( | ||
| 1124 | + int time, | ||
| 1125 | + List<HealthRawSleepInterval> intervals, | ||
| 1126 | + ) { | ||
| 1127 | + for (final interval in intervals) { | ||
| 1128 | + if (time >= interval.startTime && time <= interval.endTime) { | ||
| 1129 | + return interval; | ||
| 1130 | + } | ||
| 1131 | + } | ||
| 1132 | + return null; | ||
| 1133 | + } | ||
| 1134 | + | ||
| 1135 | + Map<String, Object?> _sleepIntervalPayload( | ||
| 1136 | + HealthRawSleepInterval interval, | ||
| 1137 | + ) { | ||
| 1138 | + return <String, Object?>{ | ||
| 1139 | + 'start_time': interval.startTime, | ||
| 1140 | + 'end_time': interval.endTime, | ||
| 1141 | + }; | ||
| 1142 | + } | ||
| 1143 | + | ||
| 1015 | Map<String, Object?> _localNotificationPayload( | 1144 | Map<String, Object?> _localNotificationPayload( |
| 1016 | HealthRawLocalNotification notification, | 1145 | HealthRawLocalNotification notification, |
| 1017 | ) { | 1146 | ) { |
| @@ -1802,6 +1931,10 @@ class AppleHealthRawDataCoreService { | @@ -1802,6 +1931,10 @@ class AppleHealthRawDataCoreService { | ||
| 1802 | return date.year * 10000 + date.month * 100 + date.day; | 1931 | return date.year * 10000 + date.month * 100 + date.day; |
| 1803 | } | 1932 | } |
| 1804 | 1933 | ||
| 1934 | + static (int startTime, int endTime) _dayRangeFromUnixSeconds(int seconds) { | ||
| 1935 | + return _dayRangeFromDateKey(_dateKeyFromUnixSeconds(seconds)); | ||
| 1936 | + } | ||
| 1937 | + | ||
| 1805 | static (int startTime, int endTime) _dayRangeFromDateKey(int dateKey) { | 1938 | static (int startTime, int endTime) _dayRangeFromDateKey(int dateKey) { |
| 1806 | final year = dateKey ~/ 10000; | 1939 | final year = dateKey ~/ 10000; |
| 1807 | final month = (dateKey ~/ 100) % 100; | 1940 | final month = (dateKey ~/ 100) % 100; |
| @@ -1813,6 +1946,16 @@ class AppleHealthRawDataCoreService { | @@ -1813,6 +1946,16 @@ class AppleHealthRawDataCoreService { | ||
| 1813 | ); | 1946 | ); |
| 1814 | } | 1947 | } |
| 1815 | 1948 | ||
| 1949 | + static HealthRawSleepInterval _defaultSleepIntervalForUnixSeconds( | ||
| 1950 | + int seconds, | ||
| 1951 | + ) { | ||
| 1952 | + final dayRange = _dayRangeFromUnixSeconds(seconds); | ||
| 1953 | + return ( | ||
| 1954 | + startTime: dayRange.$1, | ||
| 1955 | + endTime: dayRange.$1 + 6 * 60 * 60, | ||
| 1956 | + ); | ||
| 1957 | + } | ||
| 1958 | + | ||
| 1816 | static String _formatDateTimeMilliseconds(DateTime dateTime) { | 1959 | static String _formatDateTimeMilliseconds(DateTime dateTime) { |
| 1817 | String two(int value) => value.toString().padLeft(2, '0'); | 1960 | String two(int value) => value.toString().padLeft(2, '0'); |
| 1818 | String three(int value) => value.toString().padLeft(3, '0'); | 1961 | String three(int value) => value.toString().padLeft(3, '0'); |
| @@ -1911,6 +2054,8 @@ class _HealthRawLocalNotificationCandidateData { | @@ -1911,6 +2054,8 @@ class _HealthRawLocalNotificationCandidateData { | ||
| 1911 | 2054 | ||
| 1912 | double _integerDouble(num value) => value.toInt().toDouble(); | 2055 | double _integerDouble(num value) => value.toInt().toDouble(); |
| 1913 | 2056 | ||
| 2057 | +int _unixSecondsNow() => DateTime.now().millisecondsSinceEpoch ~/ 1000; | ||
| 2058 | + | ||
| 1914 | class HealthRawStressLocalStore { | 2059 | class HealthRawStressLocalStore { |
| 1915 | HealthRawStressLocalStore({ | 2060 | HealthRawStressLocalStore({ |
| 1916 | Directory? rootDirectory, | 2061 | Directory? rootDirectory, |
| @@ -1932,19 +2077,20 @@ class HealthRawStressLocalStore { | @@ -1932,19 +2077,20 @@ class HealthRawStressLocalStore { | ||
| 1932 | 2077 | ||
| 1933 | Future<void> upsertResult(HealthRawStressCalculationResult result) async { | 2078 | Future<void> upsertResult(HealthRawStressCalculationResult result) async { |
| 1934 | final db = await _database(result.userId); | 2079 | final db = await _database(result.userId); |
| 2080 | + final updateTime = _currentUnixSeconds(); | ||
| 1935 | await db.transaction((txn) async { | 2081 | await db.transaction((txn) async { |
| 1936 | for (final point in result.hrvStressPoints) { | 2082 | for (final point in result.hrvStressPoints) { |
| 1937 | await _upsertResettingUploaded( | 2083 | await _upsertResettingUploaded( |
| 1938 | txn, | 2084 | txn, |
| 1939 | hrvResultsTable, | 2085 | hrvResultsTable, |
| 1940 | - _hrvRow(point), | 2086 | + _hrvRow(point, updateTime), |
| 1941 | ); | 2087 | ); |
| 1942 | } | 2088 | } |
| 1943 | for (final point in result.realtimeStressPoints) { | 2089 | for (final point in result.realtimeStressPoints) { |
| 1944 | await _upsertResettingUploaded( | 2090 | await _upsertResettingUploaded( |
| 1945 | txn, | 2091 | txn, |
| 1946 | realtimeStressResultsTable, | 2092 | realtimeStressResultsTable, |
| 1947 | - _realtimeRow(point), | 2093 | + _realtimeRow(point, updateTime), |
| 1948 | ); | 2094 | ); |
| 1949 | } | 2095 | } |
| 1950 | }); | 2096 | }); |
| @@ -1955,9 +2101,13 @@ class HealthRawStressLocalStore { | @@ -1955,9 +2101,13 @@ class HealthRawStressLocalStore { | ||
| 1955 | required Iterable<HealthRawDailyStressPoint> points, | 2101 | required Iterable<HealthRawDailyStressPoint> points, |
| 1956 | }) async { | 2102 | }) async { |
| 1957 | final db = await _database(userId); | 2103 | final db = await _database(userId); |
| 2104 | + final updateTime = _currentUnixSeconds(); | ||
| 1958 | await db.transaction((txn) async { | 2105 | await db.transaction((txn) async { |
| 1959 | for (final point in points) { | 2106 | for (final point in points) { |
| 1960 | - await _upsertDailyStressResettingUploaded(txn, _dailyStressRow(point)); | 2107 | + await _upsertDailyStressResettingUploaded( |
| 2108 | + txn, | ||
| 2109 | + _dailyStressRow(point, updateTime), | ||
| 2110 | + ); | ||
| 1961 | } | 2111 | } |
| 1962 | }); | 2112 | }); |
| 1963 | } | 2113 | } |
| @@ -1967,9 +2117,13 @@ class HealthRawStressLocalStore { | @@ -1967,9 +2117,13 @@ class HealthRawStressLocalStore { | ||
| 1967 | required Iterable<HealthRawSleepResult> results, | 2117 | required Iterable<HealthRawSleepResult> results, |
| 1968 | }) async { | 2118 | }) async { |
| 1969 | final db = await _database(userId); | 2119 | final db = await _database(userId); |
| 2120 | + final updateTime = _currentUnixSeconds(); | ||
| 1970 | await db.transaction((txn) async { | 2121 | await db.transaction((txn) async { |
| 1971 | for (final result in results) { | 2122 | for (final result in results) { |
| 1972 | - await _upsertSleepResultResettingUploaded(txn, _sleepRow(result)); | 2123 | + await _upsertSleepResultResettingUploaded( |
| 2124 | + txn, | ||
| 2125 | + _sleepRow(result, updateTime), | ||
| 2126 | + ); | ||
| 1973 | } | 2127 | } |
| 1974 | }); | 2128 | }); |
| 1975 | } | 2129 | } |
| @@ -2145,10 +2299,43 @@ class HealthRawStressLocalStore { | @@ -2145,10 +2299,43 @@ class HealthRawStressLocalStore { | ||
| 2145 | return _latestRawEndTime(userId, realtimeStressResultsTable); | 2299 | return _latestRawEndTime(userId, realtimeStressResultsTable); |
| 2146 | } | 2300 | } |
| 2147 | 2301 | ||
| 2302 | + Future<int?> latestRealtimeStressPushSendTime(int userId) { | ||
| 2303 | + return _latestPushSendTime(userId, realtimeStressResultsTable); | ||
| 2304 | + } | ||
| 2305 | + | ||
| 2148 | Future<int?> earliestRealtimeRawEndTime(int userId) { | 2306 | Future<int?> earliestRealtimeRawEndTime(int userId) { |
| 2149 | return _earliestRawEndTime(userId, realtimeStressResultsTable); | 2307 | return _earliestRawEndTime(userId, realtimeStressResultsTable); |
| 2150 | } | 2308 | } |
| 2151 | 2309 | ||
| 2310 | + Future<void> markLocalNotificationPushed({ | ||
| 2311 | + required int userId, | ||
| 2312 | + required HealthRawLocalNotificationRecordType recordType, | ||
| 2313 | + required int recordTime, | ||
| 2314 | + required int pushSendTime, | ||
| 2315 | + }) async { | ||
| 2316 | + final (table, timeColumn) = switch (recordType) { | ||
| 2317 | + HealthRawLocalNotificationRecordType.hrv => ( | ||
| 2318 | + hrvResultsTable, | ||
| 2319 | + 'raw_end_time', | ||
| 2320 | + ), | ||
| 2321 | + HealthRawLocalNotificationRecordType.realtimeStress => ( | ||
| 2322 | + realtimeStressResultsTable, | ||
| 2323 | + 'raw_end_time', | ||
| 2324 | + ), | ||
| 2325 | + HealthRawLocalNotificationRecordType.sleep => ( | ||
| 2326 | + sleepResultsTable, | ||
| 2327 | + 'date', | ||
| 2328 | + ), | ||
| 2329 | + }; | ||
| 2330 | + final db = await _database(userId); | ||
| 2331 | + await db.update( | ||
| 2332 | + table, | ||
| 2333 | + <String, Object?>{'push_send_time': pushSendTime}, | ||
| 2334 | + where: '$timeColumn = ?', | ||
| 2335 | + whereArgs: [recordTime], | ||
| 2336 | + ); | ||
| 2337 | + } | ||
| 2338 | + | ||
| 2152 | Future<int?> latestSleepResultTime(int userId) async { | 2339 | Future<int?> latestSleepResultTime(int userId) async { |
| 2153 | final db = await _database(userId); | 2340 | final db = await _database(userId); |
| 2154 | final rows = await db.query( | 2341 | final rows = await db.query( |
| @@ -2288,7 +2475,7 @@ class HealthRawStressLocalStore { | @@ -2288,7 +2475,7 @@ class HealthRawStressLocalStore { | ||
| 2288 | final db = await factory.openDatabase( | 2475 | final db = await factory.openDatabase( |
| 2289 | path, | 2476 | path, |
| 2290 | options: OpenDatabaseOptions( | 2477 | options: OpenDatabaseOptions( |
| 2291 | - version: 8, | 2478 | + version: 10, |
| 2292 | onCreate: (db, version) async { | 2479 | onCreate: (db, version) async { |
| 2293 | await _createTables(db); | 2480 | await _createTables(db); |
| 2294 | }, | 2481 | }, |
| @@ -2315,6 +2502,12 @@ class HealthRawStressLocalStore { | @@ -2315,6 +2502,12 @@ class HealthRawStressLocalStore { | ||
| 2315 | if (oldVersion < 8) { | 2502 | if (oldVersion < 8) { |
| 2316 | await _addUpdateTimeColumns(db); | 2503 | await _addUpdateTimeColumns(db); |
| 2317 | } | 2504 | } |
| 2505 | + if (oldVersion < 9) { | ||
| 2506 | + await _addPushSendTimeColumns(db); | ||
| 2507 | + } | ||
| 2508 | + if (oldVersion < 10) { | ||
| 2509 | + await _addUploadTimeColumns(db); | ||
| 2510 | + } | ||
| 2318 | }, | 2511 | }, |
| 2319 | ), | 2512 | ), |
| 2320 | ); | 2513 | ); |
| @@ -2341,7 +2534,9 @@ CREATE TABLE IF NOT EXISTS $hrvResultsTable ( | @@ -2341,7 +2534,9 @@ CREATE TABLE IF NOT EXISTS $hrvResultsTable ( | ||
| 2341 | is_sleep_likely INTEGER NOT NULL DEFAULT 0, | 2534 | is_sleep_likely INTEGER NOT NULL DEFAULT 0, |
| 2342 | is_suspected_activity INTEGER NOT NULL DEFAULT 0, | 2535 | is_suspected_activity INTEGER NOT NULL DEFAULT 0, |
| 2343 | uploaded INTEGER NOT NULL DEFAULT 0, | 2536 | uploaded INTEGER NOT NULL DEFAULT 0, |
| 2344 | - update_time INTEGER NOT NULL DEFAULT 0 | 2537 | + update_time INTEGER NOT NULL DEFAULT 0, |
| 2538 | + push_send_time INTEGER, | ||
| 2539 | + upload_time INTEGER | ||
| 2345 | ) | 2540 | ) |
| 2346 | '''); | 2541 | '''); |
| 2347 | await db.execute(''' | 2542 | await db.execute(''' |
| @@ -2357,7 +2552,9 @@ CREATE TABLE IF NOT EXISTS $realtimeStressResultsTable ( | @@ -2357,7 +2552,9 @@ CREATE TABLE IF NOT EXISTS $realtimeStressResultsTable ( | ||
| 2357 | is_sleep_likely INTEGER NOT NULL DEFAULT 0, | 2552 | is_sleep_likely INTEGER NOT NULL DEFAULT 0, |
| 2358 | is_suspected_activity INTEGER NOT NULL DEFAULT 0, | 2553 | is_suspected_activity INTEGER NOT NULL DEFAULT 0, |
| 2359 | uploaded INTEGER NOT NULL DEFAULT 0, | 2554 | uploaded INTEGER NOT NULL DEFAULT 0, |
| 2360 | - update_time INTEGER NOT NULL DEFAULT 0 | 2555 | + update_time INTEGER NOT NULL DEFAULT 0, |
| 2556 | + push_send_time INTEGER, | ||
| 2557 | + upload_time INTEGER | ||
| 2361 | ) | 2558 | ) |
| 2362 | '''); | 2559 | '''); |
| 2363 | await _createDailyStressTable(db); | 2560 | await _createDailyStressTable(db); |
| @@ -2374,7 +2571,8 @@ CREATE TABLE IF NOT EXISTS $dailyStressResultsTable ( | @@ -2374,7 +2571,8 @@ CREATE TABLE IF NOT EXISTS $dailyStressResultsTable ( | ||
| 2374 | state INTEGER NOT NULL, | 2571 | state INTEGER NOT NULL, |
| 2375 | data_time INTEGER NOT NULL, | 2572 | data_time INTEGER NOT NULL, |
| 2376 | uploaded INTEGER NOT NULL DEFAULT 0, | 2573 | uploaded INTEGER NOT NULL DEFAULT 0, |
| 2377 | - update_time INTEGER NOT NULL DEFAULT 0 | 2574 | + update_time INTEGER NOT NULL DEFAULT 0, |
| 2575 | + upload_time INTEGER | ||
| 2378 | ) | 2576 | ) |
| 2379 | '''); | 2577 | '''); |
| 2380 | } | 2578 | } |
| @@ -2391,7 +2589,9 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable ( | @@ -2391,7 +2589,9 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable ( | ||
| 2391 | awak_minutes INTEGER NOT NULL, | 2589 | awak_minutes INTEGER NOT NULL, |
| 2392 | sleep_minutes INTEGER NOT NULL, | 2590 | sleep_minutes INTEGER NOT NULL, |
| 2393 | uploaded INTEGER NOT NULL DEFAULT 0, | 2591 | uploaded INTEGER NOT NULL DEFAULT 0, |
| 2394 | - update_time INTEGER NOT NULL DEFAULT 0 | 2592 | + update_time INTEGER NOT NULL DEFAULT 0, |
| 2593 | + push_send_time INTEGER, | ||
| 2594 | + upload_time INTEGER | ||
| 2395 | ) | 2595 | ) |
| 2396 | '''); | 2596 | '''); |
| 2397 | } | 2597 | } |
| @@ -2413,6 +2613,37 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable ( | @@ -2413,6 +2613,37 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable ( | ||
| 2413 | } | 2613 | } |
| 2414 | } | 2614 | } |
| 2415 | 2615 | ||
| 2616 | + Future<void> _addPushSendTimeColumns(DatabaseExecutor db) async { | ||
| 2617 | + for (final table in const [ | ||
| 2618 | + hrvResultsTable, | ||
| 2619 | + realtimeStressResultsTable, | ||
| 2620 | + sleepResultsTable, | ||
| 2621 | + ]) { | ||
| 2622 | + try { | ||
| 2623 | + await db.execute( | ||
| 2624 | + 'ALTER TABLE $table ADD COLUMN push_send_time INTEGER', | ||
| 2625 | + ); | ||
| 2626 | + } on DatabaseException catch (error) { | ||
| 2627 | + if (!error.isDuplicateColumnError()) rethrow; | ||
| 2628 | + } | ||
| 2629 | + } | ||
| 2630 | + } | ||
| 2631 | + | ||
| 2632 | + Future<void> _addUploadTimeColumns(DatabaseExecutor db) async { | ||
| 2633 | + for (final table in const [ | ||
| 2634 | + hrvResultsTable, | ||
| 2635 | + realtimeStressResultsTable, | ||
| 2636 | + dailyStressResultsTable, | ||
| 2637 | + sleepResultsTable, | ||
| 2638 | + ]) { | ||
| 2639 | + try { | ||
| 2640 | + await db.execute('ALTER TABLE $table ADD COLUMN upload_time INTEGER'); | ||
| 2641 | + } on DatabaseException catch (error) { | ||
| 2642 | + if (!error.isDuplicateColumnError()) rethrow; | ||
| 2643 | + } | ||
| 2644 | + } | ||
| 2645 | + } | ||
| 2646 | + | ||
| 2416 | Future<void> _addFlagColumns(DatabaseExecutor db, String table) async { | 2647 | Future<void> _addFlagColumns(DatabaseExecutor db, String table) async { |
| 2417 | for (final column in const [ | 2648 | for (final column in const [ |
| 2418 | 'is_workout', | 2649 | 'is_workout', |
| @@ -2496,6 +2727,19 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable ( | @@ -2496,6 +2727,19 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable ( | ||
| 2496 | return rows.first['raw_end_time'] as int; | 2727 | return rows.first['raw_end_time'] as int; |
| 2497 | } | 2728 | } |
| 2498 | 2729 | ||
| 2730 | + Future<int?> _latestPushSendTime(int userId, String table) async { | ||
| 2731 | + final db = await _database(userId); | ||
| 2732 | + final rows = await db.query( | ||
| 2733 | + table, | ||
| 2734 | + columns: ['push_send_time'], | ||
| 2735 | + where: 'push_send_time IS NOT NULL', | ||
| 2736 | + orderBy: 'push_send_time DESC', | ||
| 2737 | + limit: 1, | ||
| 2738 | + ); | ||
| 2739 | + if (rows.isEmpty) return null; | ||
| 2740 | + return rows.first['push_send_time'] as int?; | ||
| 2741 | + } | ||
| 2742 | + | ||
| 2499 | Future<int?> _earliestRawEndTime(int userId, String table) async { | 2743 | Future<int?> _earliestRawEndTime(int userId, String table) async { |
| 2500 | final db = await _database(userId); | 2744 | final db = await _database(userId); |
| 2501 | final rows = await db.query( | 2745 | final rows = await db.query( |
| @@ -2516,9 +2760,10 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable ( | @@ -2516,9 +2760,10 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable ( | ||
| 2516 | final times = rawEndTimes.toList(); | 2760 | final times = rawEndTimes.toList(); |
| 2517 | if (times.isEmpty) return; | 2761 | if (times.isEmpty) return; |
| 2518 | final db = await _database(userId); | 2762 | final db = await _database(userId); |
| 2763 | + final uploadTime = _currentUnixSeconds(); | ||
| 2519 | await db.update( | 2764 | await db.update( |
| 2520 | table, | 2765 | table, |
| 2521 | - {'uploaded': 1}, | 2766 | + {'uploaded': 1, 'upload_time': uploadTime}, |
| 2522 | where: 'raw_end_time IN (${List.filled(times.length, '?').join(',')})', | 2767 | where: 'raw_end_time IN (${List.filled(times.length, '?').join(',')})', |
| 2523 | whereArgs: times, | 2768 | whereArgs: times, |
| 2524 | ); | 2769 | ); |
| @@ -2532,9 +2777,10 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable ( | @@ -2532,9 +2777,10 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable ( | ||
| 2532 | }) async { | 2777 | }) async { |
| 2533 | if (time <= 0) return; | 2778 | if (time <= 0) return; |
| 2534 | final db = await _database(userId); | 2779 | final db = await _database(userId); |
| 2780 | + final uploadTime = _currentUnixSeconds(); | ||
| 2535 | await db.update( | 2781 | await db.update( |
| 2536 | table, | 2782 | table, |
| 2537 | - {'uploaded': 1}, | 2783 | + {'uploaded': 1, 'upload_time': uploadTime}, |
| 2538 | where: '$timeColumn <= ? AND uploaded != 1', | 2784 | where: '$timeColumn <= ? AND uploaded != 1', |
| 2539 | whereArgs: [time], | 2785 | whereArgs: [time], |
| 2540 | ); | 2786 | ); |
| @@ -2559,7 +2805,10 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable ( | @@ -2559,7 +2805,10 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable ( | ||
| 2559 | return now.year * 10000 + now.month * 100 + now.day; | 2805 | return now.year * 10000 + now.month * 100 + now.day; |
| 2560 | } | 2806 | } |
| 2561 | 2807 | ||
| 2562 | - Map<String, Object?> _hrvRow(HealthRawHrvStressPoint point) { | 2808 | + Map<String, Object?> _hrvRow( |
| 2809 | + HealthRawHrvStressPoint point, | ||
| 2810 | + int updateTime, | ||
| 2811 | + ) { | ||
| 2563 | return <String, Object?>{ | 2812 | return <String, Object?>{ |
| 2564 | 'raw_end_time': point.rawEndTime, | 2813 | 'raw_end_time': point.rawEndTime, |
| 2565 | 'user_id': point.userId, | 2814 | 'user_id': point.userId, |
| @@ -2574,11 +2823,15 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable ( | @@ -2574,11 +2823,15 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable ( | ||
| 2574 | 'baseline_resting_hr': point.baselineRestingHr, | 2823 | 'baseline_resting_hr': point.baselineRestingHr, |
| 2575 | ..._flagsRow(point.flags), | 2824 | ..._flagsRow(point.flags), |
| 2576 | 'uploaded': point.uploaded ? 1 : 0, | 2825 | 'uploaded': point.uploaded ? 1 : 0, |
| 2577 | - 'update_time': _currentUnixSeconds(), | 2826 | + 'update_time': updateTime, |
| 2827 | + 'upload_time': point.uploadTime, | ||
| 2578 | }; | 2828 | }; |
| 2579 | } | 2829 | } |
| 2580 | 2830 | ||
| 2581 | - Map<String, Object?> _realtimeRow(HealthRawRealtimeStressPoint point) { | 2831 | + Map<String, Object?> _realtimeRow( |
| 2832 | + HealthRawRealtimeStressPoint point, | ||
| 2833 | + int updateTime, | ||
| 2834 | + ) { | ||
| 2582 | return <String, Object?>{ | 2835 | return <String, Object?>{ |
| 2583 | 'raw_end_time': point.rawEndTime, | 2836 | 'raw_end_time': point.rawEndTime, |
| 2584 | 'user_id': point.userId, | 2837 | 'user_id': point.userId, |
| @@ -2588,11 +2841,15 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable ( | @@ -2588,11 +2841,15 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable ( | ||
| 2588 | 'source_end_time': point.sourceEndTime, | 2841 | 'source_end_time': point.sourceEndTime, |
| 2589 | ..._flagsRow(point.flags), | 2842 | ..._flagsRow(point.flags), |
| 2590 | 'uploaded': point.uploaded ? 1 : 0, | 2843 | 'uploaded': point.uploaded ? 1 : 0, |
| 2591 | - 'update_time': _currentUnixSeconds(), | 2844 | + 'update_time': updateTime, |
| 2845 | + 'upload_time': point.uploadTime, | ||
| 2592 | }; | 2846 | }; |
| 2593 | } | 2847 | } |
| 2594 | 2848 | ||
| 2595 | - Map<String, Object?> _dailyStressRow(HealthRawDailyStressPoint point) { | 2849 | + Map<String, Object?> _dailyStressRow( |
| 2850 | + HealthRawDailyStressPoint point, | ||
| 2851 | + int updateTime, | ||
| 2852 | + ) { | ||
| 2596 | return <String, Object?>{ | 2853 | return <String, Object?>{ |
| 2597 | 'date': point.date, | 2854 | 'date': point.date, |
| 2598 | 'user_id': point.userId, | 2855 | 'user_id': point.userId, |
| @@ -2601,11 +2858,12 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable ( | @@ -2601,11 +2858,12 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable ( | ||
| 2601 | 'state': point.state.value, | 2858 | 'state': point.state.value, |
| 2602 | 'data_time': point.dataTime, | 2859 | 'data_time': point.dataTime, |
| 2603 | 'uploaded': point.uploaded ? 1 : 0, | 2860 | 'uploaded': point.uploaded ? 1 : 0, |
| 2604 | - 'update_time': _currentUnixSeconds(), | 2861 | + 'update_time': updateTime, |
| 2862 | + 'upload_time': point.uploadTime, | ||
| 2605 | }; | 2863 | }; |
| 2606 | } | 2864 | } |
| 2607 | 2865 | ||
| 2608 | - Map<String, Object?> _sleepRow(HealthRawSleepResult result) { | 2866 | + Map<String, Object?> _sleepRow(HealthRawSleepResult result, int updateTime) { |
| 2609 | return <String, Object?>{ | 2867 | return <String, Object?>{ |
| 2610 | 'date': result.date, | 2868 | 'date': result.date, |
| 2611 | 'user_id': result.userId, | 2869 | 'user_id': result.userId, |
| @@ -2616,7 +2874,8 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable ( | @@ -2616,7 +2874,8 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable ( | ||
| 2616 | 'awak_minutes': result.awakMinutes, | 2874 | 'awak_minutes': result.awakMinutes, |
| 2617 | 'sleep_minutes': result.sleepMinutes, | 2875 | 'sleep_minutes': result.sleepMinutes, |
| 2618 | 'uploaded': result.uploaded ? 1 : 0, | 2876 | 'uploaded': result.uploaded ? 1 : 0, |
| 2619 | - 'update_time': _currentUnixSeconds(), | 2877 | + 'update_time': updateTime, |
| 2878 | + 'upload_time': result.uploadTime, | ||
| 2620 | }; | 2879 | }; |
| 2621 | } | 2880 | } |
| 2622 | 2881 | ||
| @@ -2683,6 +2942,7 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable ( | @@ -2683,6 +2942,7 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable ( | ||
| 2683 | 'is_sleep_likely': row['is_sleep_likely'], | 2942 | 'is_sleep_likely': row['is_sleep_likely'], |
| 2684 | 'is_suspected_activity': row['is_suspected_activity'], | 2943 | 'is_suspected_activity': row['is_suspected_activity'], |
| 2685 | 'uploaded': uploadPayloadChanged ? 0 : existingRow['uploaded'], | 2944 | 'uploaded': uploadPayloadChanged ? 0 : existingRow['uploaded'], |
| 2945 | + 'upload_time': uploadPayloadChanged ? null : existingRow['upload_time'], | ||
| 2686 | 'update_time': row['update_time'], | 2946 | 'update_time': row['update_time'], |
| 2687 | }, | 2947 | }, |
| 2688 | where: 'raw_end_time = ?', | 2948 | where: 'raw_end_time = ?', |
| @@ -2697,6 +2957,7 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable ( | @@ -2697,6 +2957,7 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable ( | ||
| 2697 | final isToday = row['date'] == _todayDateKey(); | 2957 | final isToday = row['date'] == _todayDateKey(); |
| 2698 | if (isToday) { | 2958 | if (isToday) { |
| 2699 | row['uploaded'] = 0; | 2959 | row['uploaded'] = 0; |
| 2960 | + row['upload_time'] = null; | ||
| 2700 | } | 2961 | } |
| 2701 | final existing = await db.query( | 2962 | final existing = await db.query( |
| 2702 | dailyStressResultsTable, | 2963 | dailyStressResultsTable, |
| @@ -2723,6 +2984,8 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable ( | @@ -2723,6 +2984,8 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable ( | ||
| 2723 | 'state': row['state'], | 2984 | 'state': row['state'], |
| 2724 | 'data_time': row['data_time'], | 2985 | 'data_time': row['data_time'], |
| 2725 | 'uploaded': isToday ? 0 : (valueChanged ? 0 : existingRow['uploaded']), | 2986 | 'uploaded': isToday ? 0 : (valueChanged ? 0 : existingRow['uploaded']), |
| 2987 | + 'upload_time': | ||
| 2988 | + isToday || valueChanged ? null : existingRow['upload_time'], | ||
| 2726 | 'update_time': row['update_time'], | 2989 | 'update_time': row['update_time'], |
| 2727 | }, | 2990 | }, |
| 2728 | where: 'date = ?', | 2991 | where: 'date = ?', |
| @@ -2769,6 +3032,7 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable ( | @@ -2769,6 +3032,7 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable ( | ||
| 2769 | 'awak_minutes': row['awak_minutes'], | 3032 | 'awak_minutes': row['awak_minutes'], |
| 2770 | 'sleep_minutes': row['sleep_minutes'], | 3033 | 'sleep_minutes': row['sleep_minutes'], |
| 2771 | 'uploaded': valueChanged ? 0 : existingRow['uploaded'], | 3034 | 'uploaded': valueChanged ? 0 : existingRow['uploaded'], |
| 3035 | + 'upload_time': valueChanged ? null : existingRow['upload_time'], | ||
| 2772 | 'update_time': row['update_time'], | 3036 | 'update_time': row['update_time'], |
| 2773 | }, | 3037 | }, |
| 2774 | where: 'date = ?', | 3038 | where: 'date = ?', |
| @@ -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,20 +51,31 @@ class HealthRawLocalNotificationBuilder { | @@ -49,20 +51,31 @@ 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 | }) { |
| 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; | ||
| 53 | return [ | 67 | return [ |
| 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, |
| @@ -89,33 +102,37 @@ class HealthRawLocalNotificationBuilder { | @@ -89,33 +102,37 @@ class HealthRawLocalNotificationBuilder { | ||
| 89 | ); | 102 | ); |
| 90 | } | 103 | } |
| 91 | 104 | ||
| 92 | - HealthRawLocalNotification? _hrvNotification( | 105 | + List<HealthRawLocalNotification> _hrvNotifications( |
| 93 | List<HealthRawHrvStressPoint> hrvPoints, | 106 | List<HealthRawHrvStressPoint> hrvPoints, |
| 94 | bool hasExistingHrv, | 107 | bool hasExistingHrv, |
| 95 | ) { | 108 | ) { |
| 96 | - if (!hasExistingHrv) return null; | ||
| 97 | - if (hrvPoints.isEmpty) return null; | 109 | + if (!hasExistingHrv) return const <HealthRawLocalNotification>[]; |
| 110 | + if (hrvPoints.isEmpty) return const <HealthRawLocalNotification>[]; | ||
| 98 | final sorted = [...hrvPoints] | 111 | final sorted = [...hrvPoints] |
| 99 | ..sort((a, b) => a.rawEndTime.compareTo(b.rawEndTime)); | 112 | ..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 | - ); | 113 | + return [ |
| 114 | + for (final point in sorted) | ||
| 115 | + HealthRawLocalNotification( | ||
| 116 | + title: l10n.healthLocalNotificationHrvTitle( | ||
| 117 | + point.result.floor(), | ||
| 118 | + _stressStateLabel(point.state), | ||
| 119 | + _timeText(point.rawEndTime), | ||
| 120 | + ), | ||
| 121 | + content: _hrvContent(point), | ||
| 122 | + link: healthRawHrvChangeLink, | ||
| 123 | + recordType: HealthRawLocalNotificationRecordType.hrv, | ||
| 124 | + recordTime: point.rawEndTime, | ||
| 125 | + ), | ||
| 126 | + ]; | ||
| 113 | } | 127 | } |
| 114 | 128 | ||
| 115 | HealthRawLocalNotification? _realtimeStressNotification( | 129 | HealthRawLocalNotification? _realtimeStressNotification( |
| 116 | List<HealthRawRealtimeStressPoint> realtimeWindow, | 130 | List<HealthRawRealtimeStressPoint> realtimeWindow, |
| 117 | HealthRawLocalNotificationRecord? record, { | 131 | HealthRawLocalNotificationRecord? record, { |
| 118 | required HealthRawRealtimeStressPoint? latestRealtimeStressPoint, | 132 | required HealthRawRealtimeStressPoint? latestRealtimeStressPoint, |
| 133 | + required List<HealthRawSleepInterval> sleepIntervals, | ||
| 134 | + required int? lastPushSendTime, | ||
| 135 | + required int notificationBuildTime, | ||
| 119 | }) { | 136 | }) { |
| 120 | if (latestRealtimeStressPoint == null) return null; | 137 | if (latestRealtimeStressPoint == null) return null; |
| 121 | if (latestRealtimeStressPoint.isWorkout || | 138 | if (latestRealtimeStressPoint.isWorkout || |
| @@ -123,16 +140,20 @@ class HealthRawLocalNotificationBuilder { | @@ -123,16 +140,20 @@ class HealthRawLocalNotificationBuilder { | ||
| 123 | return null; | 140 | return null; |
| 124 | } | 141 | } |
| 125 | 142 | ||
| 143 | + final latest = latestRealtimeStressPoint; | ||
| 144 | + if (_sleepIntervalContaining(latest.rawEndTime, sleepIntervals) != null) { | ||
| 145 | + return null; | ||
| 146 | + } | ||
| 147 | + | ||
| 126 | final valid = realtimeWindow | 148 | final valid = realtimeWindow |
| 127 | .where((e) => e.result >= 1 && e.result <= 100) | 149 | .where((e) => e.result >= 1 && e.result <= 100) |
| 128 | .toList() | 150 | .toList() |
| 129 | ..sort((a, b) => a.rawEndTime.compareTo(b.rawEndTime)); | 151 | ..sort((a, b) => a.rawEndTime.compareTo(b.rawEndTime)); |
| 130 | if (valid.length < _realtimeMinPointCount) return null; | 152 | if (valid.length < _realtimeMinPointCount) return null; |
| 131 | 153 | ||
| 132 | - final latest = latestRealtimeStressPoint; | ||
| 133 | if (latest.isSleepLikely) return null; | 154 | if (latest.isSleepLikely) return null; |
| 134 | - if (record?.lastRealtimeStressTime case final lastPushTime?) { | ||
| 135 | - if (latest.rawEndTime - lastPushTime < _realtimeWindowSeconds) { | 155 | + if (lastPushSendTime case final lastPushTime?) { |
| 156 | + if (notificationBuildTime - lastPushTime < _realtimeWindowSeconds) { | ||
| 136 | return null; | 157 | return null; |
| 137 | } | 158 | } |
| 138 | } | 159 | } |
| @@ -162,6 +183,29 @@ class HealthRawLocalNotificationBuilder { | @@ -162,6 +183,29 @@ class HealthRawLocalNotificationBuilder { | ||
| 162 | return points.reduce((a, b) => a.rawEndTime >= b.rawEndTime ? a : b); | 183 | return points.reduce((a, b) => a.rawEndTime >= b.rawEndTime ? a : b); |
| 163 | } | 184 | } |
| 164 | 185 | ||
| 186 | + HealthRawSleepInterval? _sleepIntervalContaining( | ||
| 187 | + int time, | ||
| 188 | + List<HealthRawSleepInterval> intervals, | ||
| 189 | + ) { | ||
| 190 | + for (final interval in intervals) { | ||
| 191 | + if (time >= interval.startTime && time <= interval.endTime) { | ||
| 192 | + return interval; | ||
| 193 | + } | ||
| 194 | + } | ||
| 195 | + return null; | ||
| 196 | + } | ||
| 197 | + | ||
| 198 | + HealthRawSleepInterval _defaultSleepInterval(int time) { | ||
| 199 | + final day = _localDayStart(time); | ||
| 200 | + return (startTime: day, endTime: day + 6 * 60 * 60); | ||
| 201 | + } | ||
| 202 | + | ||
| 203 | + int _localDayStart(int time) { | ||
| 204 | + final date = DateTime.fromMillisecondsSinceEpoch(time * 1000); | ||
| 205 | + return DateTime(date.year, date.month, date.day).millisecondsSinceEpoch ~/ | ||
| 206 | + 1000; | ||
| 207 | + } | ||
| 208 | + | ||
| 165 | String _sleepDurationText(int minutes) { | 209 | String _sleepDurationText(int minutes) { |
| 166 | final hours = minutes ~/ 60; | 210 | final hours = minutes ~/ 60; |
| 167 | final remainingMinutes = minutes % 60; | 211 | final remainingMinutes = minutes % 60; |
| 1 | -import 'dart:convert'; | ||
| 2 | -import 'dart:io'; | ||
| 3 | - | ||
| 4 | -import 'package:path_provider/path_provider.dart'; | ||
| 5 | - | ||
| 6 | -class HealthRawLocalNotificationDebugStore { | ||
| 7 | - HealthRawLocalNotificationDebugStore({Directory? rootDirectory}) | ||
| 8 | - : _rootDirectory = rootDirectory; | ||
| 9 | - | ||
| 10 | - static const _fileName = 'health_raw_local_notification_debug_events.json'; | ||
| 11 | - static const _maxEventCount = 500; | ||
| 12 | - | ||
| 13 | - final Directory? _rootDirectory; | ||
| 14 | - | ||
| 15 | - Future<void> append(Map<String, Object?> event) async { | ||
| 16 | - final file = await _file(); | ||
| 17 | - await file.parent.create(recursive: true); | ||
| 18 | - final eventsByMinute = await _readEventsByMinute(file); | ||
| 19 | - final now = DateTime.now(); | ||
| 20 | - final minuteKey = _minuteKey(now); | ||
| 21 | - final enrichedEvent = <String, Object?>{ | ||
| 22 | - ..._sanitizeMap(event), | ||
| 23 | - 'saved_at': now.toIso8601String(), | ||
| 24 | - 'saved_at_readable': _readableTime(now), | ||
| 25 | - 'saved_at_minute': minuteKey, | ||
| 26 | - 'saved_at_unix': now.millisecondsSinceEpoch / 1000, | ||
| 27 | - }; | ||
| 28 | - eventsByMinute.putIfAbsent(minuteKey, () => <Map<String, Object?>>[]); | ||
| 29 | - eventsByMinute[minuteKey]!.add(enrichedEvent); | ||
| 30 | - final limitedEventsByMinute = _limitedEventsByMinute(eventsByMinute); | ||
| 31 | - const encoder = JsonEncoder.withIndent(' '); | ||
| 32 | - await file.writeAsString( | ||
| 33 | - encoder.convert(limitedEventsByMinute), | ||
| 34 | - flush: true, | ||
| 35 | - ); | ||
| 36 | - } | ||
| 37 | - | ||
| 38 | - Future<String> readText() async { | ||
| 39 | - final file = await _file(); | ||
| 40 | - if (!await file.exists()) { | ||
| 41 | - return '暂无本地推送诊断日志'; | ||
| 42 | - } | ||
| 43 | - final text = await file.readAsString(); | ||
| 44 | - if (text.trim().isEmpty) { | ||
| 45 | - return '暂无本地推送诊断日志'; | ||
| 46 | - } | ||
| 47 | - return text; | ||
| 48 | - } | ||
| 49 | - | ||
| 50 | - Future<File> _file() async { | ||
| 51 | - final rootDirectory = | ||
| 52 | - _rootDirectory ?? await getApplicationDocumentsDirectory(); | ||
| 53 | - return File('${rootDirectory.path}/$_fileName'); | ||
| 54 | - } | ||
| 55 | - | ||
| 56 | - Future<Map<String, List<Map<String, Object?>>>> _readEventsByMinute( | ||
| 57 | - File file, | ||
| 58 | - ) async { | ||
| 59 | - if (!await file.exists()) { | ||
| 60 | - return <String, List<Map<String, Object?>>>{}; | ||
| 61 | - } | ||
| 62 | - final text = await file.readAsString(); | ||
| 63 | - if (text.trim().isEmpty) { | ||
| 64 | - return <String, List<Map<String, Object?>>>{}; | ||
| 65 | - } | ||
| 66 | - final json = jsonDecode(text); | ||
| 67 | - if (json is Map) { | ||
| 68 | - return json.map((key, value) { | ||
| 69 | - final events = value is List | ||
| 70 | - ? value | ||
| 71 | - .whereType<Map>() | ||
| 72 | - .map((e) => _sanitizeMap(e.cast<String, Object?>())) | ||
| 73 | - .toList() | ||
| 74 | - : <Map<String, Object?>>[]; | ||
| 75 | - return MapEntry(key.toString(), events); | ||
| 76 | - }); | ||
| 77 | - } | ||
| 78 | - if (json is List) { | ||
| 79 | - final events = json | ||
| 80 | - .whereType<Map>() | ||
| 81 | - .map((e) => _sanitizeMap(e.cast<String, Object?>())) | ||
| 82 | - .toList(); | ||
| 83 | - final grouped = <String, List<Map<String, Object?>>>{}; | ||
| 84 | - for (final event in events) { | ||
| 85 | - final key = | ||
| 86 | - (event['saved_at_minute'] ?? event['saved_at_readable'] ?? 'legacy') | ||
| 87 | - .toString(); | ||
| 88 | - grouped.putIfAbsent(key, () => <Map<String, Object?>>[]); | ||
| 89 | - grouped[key]!.add(event); | ||
| 90 | - } | ||
| 91 | - return grouped; | ||
| 92 | - } | ||
| 93 | - return <String, List<Map<String, Object?>>>{}; | ||
| 94 | - } | ||
| 95 | - | ||
| 96 | - Map<String, List<Map<String, Object?>>> _limitedEventsByMinute( | ||
| 97 | - Map<String, List<Map<String, Object?>>> eventsByMinute, | ||
| 98 | - ) { | ||
| 99 | - final events = eventsByMinute.values.expand((e) => e).toList() | ||
| 100 | - ..sort((a, b) { | ||
| 101 | - final aTime = (a['saved_at_unix'] as num?)?.toDouble() ?? 0; | ||
| 102 | - final bTime = (b['saved_at_unix'] as num?)?.toDouble() ?? 0; | ||
| 103 | - return aTime.compareTo(bTime); | ||
| 104 | - }); | ||
| 105 | - final limitedEvents = events.length > _maxEventCount | ||
| 106 | - ? events.sublist(events.length - _maxEventCount) | ||
| 107 | - : events; | ||
| 108 | - final grouped = <String, List<Map<String, Object?>>>{}; | ||
| 109 | - for (final event in limitedEvents) { | ||
| 110 | - final key = | ||
| 111 | - (event['saved_at_minute'] ?? event['saved_at_readable'] ?? 'unknown') | ||
| 112 | - .toString(); | ||
| 113 | - grouped.putIfAbsent(key, () => <Map<String, Object?>>[]); | ||
| 114 | - grouped[key]!.add(event); | ||
| 115 | - } | ||
| 116 | - return grouped; | ||
| 117 | - } | ||
| 118 | - | ||
| 119 | - static Map<String, Object?> _sanitizeMap(Map<String, Object?> map) { | ||
| 120 | - return map.map((key, value) => MapEntry(key, _sanitizeValue(value))); | ||
| 121 | - } | ||
| 122 | - | ||
| 123 | - static Object? _sanitizeValue(Object? value) { | ||
| 124 | - return switch (value) { | ||
| 125 | - null => null, | ||
| 126 | - String() => value, | ||
| 127 | - num() => value, | ||
| 128 | - bool() => value, | ||
| 129 | - DateTime() => value.toIso8601String(), | ||
| 130 | - List() => value.map(_sanitizeValue).toList(), | ||
| 131 | - Map() => value.map( | ||
| 132 | - (key, value) => MapEntry(key.toString(), _sanitizeValue(value)), | ||
| 133 | - ), | ||
| 134 | - _ => value.toString(), | ||
| 135 | - }; | ||
| 136 | - } | ||
| 137 | - | ||
| 138 | - static String _minuteKey(DateTime time) { | ||
| 139 | - return '${time.year.toString().padLeft(4, '0')}-' | ||
| 140 | - '${time.month.toString().padLeft(2, '0')}-' | ||
| 141 | - '${time.day.toString().padLeft(2, '0')} ' | ||
| 142 | - '${time.hour.toString().padLeft(2, '0')}:' | ||
| 143 | - '${time.minute.toString().padLeft(2, '0')}'; | ||
| 144 | - } | ||
| 145 | - | ||
| 146 | - static String _readableTime(DateTime time) { | ||
| 147 | - return '${time.year.toString().padLeft(4, '0')}-' | ||
| 148 | - '${time.month.toString().padLeft(2, '0')}-' | ||
| 149 | - '${time.day.toString().padLeft(2, '0')} ' | ||
| 150 | - '${time.hour.toString().padLeft(2, '0')}:' | ||
| 151 | - '${time.minute.toString().padLeft(2, '0')}:' | ||
| 152 | - '${time.second.toString().padLeft(2, '0')}'; | ||
| 153 | - } | ||
| 154 | -} |
| @@ -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( |
| @@ -768,7 +773,7 @@ void main() { | @@ -768,7 +773,7 @@ void main() { | ||
| 768 | expect( | 773 | expect( |
| 769 | notificationDispatcher.sentNotifications.where( | 774 | notificationDispatcher.sentNotifications.where( |
| 770 | (e) => e.recordType == HealthRawLocalNotificationRecordType.hrv), | 775 | (e) => e.recordType == HealthRawLocalNotificationRecordType.hrv), |
| 771 | - hasLength(1), | 776 | + hasLength(3), |
| 772 | ); | 777 | ); |
| 773 | expect( | 778 | expect( |
| 774 | notificationDispatcher.sentNotifications.where( | 779 | notificationDispatcher.sentNotifications.where( |
| @@ -779,6 +784,25 @@ void main() { | @@ -779,6 +784,25 @@ void main() { | ||
| 779 | expect(notificationDispatcher.record.latestHrvDataTime, base + 720); | 784 | expect(notificationDispatcher.record.latestHrvDataTime, base + 720); |
| 780 | expect(notificationDispatcher.record.latestSleepDataTime, | 785 | expect(notificationDispatcher.record.latestSleepDataTime, |
| 781 | LocalHealthDataConvert.unixSeconds(sleepEnd)); | 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((row) => [base + 120, base + 420, base + 720] | ||
| 796 | + .contains(row['raw_end_time'])) | ||
| 797 | + .every((row) => row['push_send_time'] != null), | ||
| 798 | + isTrue, | ||
| 799 | + ); | ||
| 800 | + expect( | ||
| 801 | + rows.sleepRows.singleWhere( | ||
| 802 | + (row) => row['date'] == LocalHealthDataConvert.unixSeconds(sleepEnd), | ||
| 803 | + )['push_send_time'], | ||
| 804 | + isNotNull, | ||
| 805 | + ); | ||
| 782 | }); | 806 | }); |
| 783 | 807 | ||
| 784 | test( | 808 | test( |
| @@ -887,6 +911,127 @@ void main() { | @@ -887,6 +911,127 @@ void main() { | ||
| 887 | notificationDispatcher.record.latestRealtimeStressDataTime, | 911 | notificationDispatcher.record.latestRealtimeStressDataTime, |
| 888 | base + 2700, | 912 | base + 2700, |
| 889 | ); | 913 | ); |
| 914 | + final rows = await store.debugQueryAllRows(42); | ||
| 915 | + expect(rows.realtimeRows.last['push_send_time'], isNotNull); | ||
| 916 | + }); | ||
| 917 | + | ||
| 918 | + test('startCoreCaculate suppresses realtime notification inside sleep result', | ||
| 919 | + () async { | ||
| 920 | + final now = DateTime.now(); | ||
| 921 | + final day = DateTime(now.year, now.month, now.day).subtract( | ||
| 922 | + const Duration(days: 7), | ||
| 923 | + ); | ||
| 924 | + final dayStart = LocalHealthDataConvert.unixSeconds(day); | ||
| 925 | + final latestTime = dayStart + 5 * 3600 + 30 * 60; | ||
| 926 | + final firstTime = latestTime - 9 * 300; | ||
| 927 | + final api = _FakeHealthKitRawDataHostApi(); | ||
| 928 | + final store = _MemoryHealthRawStressLocalStore(); | ||
| 929 | + await store.upsertResult( | ||
| 930 | + HealthRawStressCalculationResult( | ||
| 931 | + userId: 42, | ||
| 932 | + hrvStressPoints: const <HealthRawHrvStressPoint>[], | ||
| 933 | + realtimeStressPoints: [ | ||
| 934 | + for (var i = 0; i < 10; i += 1) | ||
| 935 | + _realtimeStressPoint(firstTime + i * 300, result: 70), | ||
| 936 | + ], | ||
| 937 | + dailyStressPoints: const <HealthRawDailyStressPoint>[], | ||
| 938 | + ), | ||
| 939 | + ); | ||
| 940 | + await store.upsertSleepResults( | ||
| 941 | + userId: 42, | ||
| 942 | + results: [ | ||
| 943 | + HealthRawSleepResult( | ||
| 944 | + userId: 42, | ||
| 945 | + date: dayStart + 6 * 3600, | ||
| 946 | + startDate: dayStart + 5 * 3600, | ||
| 947 | + sleepScore: 80, | ||
| 948 | + sleepState: 2, | ||
| 949 | + inBedMinutes: 60, | ||
| 950 | + awakMinutes: 0, | ||
| 951 | + sleepMinutes: 60, | ||
| 952 | + ), | ||
| 953 | + ], | ||
| 954 | + ); | ||
| 955 | + final notificationDispatcher = _FakeHealthRawLocalNotificationDispatcher() | ||
| 956 | + ..record = HealthRawLocalNotificationRecord( | ||
| 957 | + latestRealtimeStressDataTime: firstTime - 1, | ||
| 958 | + latestSleepDataTime: dayStart + 6 * 3600, | ||
| 959 | + ); | ||
| 960 | + final service = AppleHealthRawDataCoreService( | ||
| 961 | + healthApi: _FakeHealthKitHostApi(), | ||
| 962 | + rawDataApi: api, | ||
| 963 | + localStore: store, | ||
| 964 | + userIdProvider: () => 42, | ||
| 965 | + uploadResultsAfterCalculation: false, | ||
| 966 | + localNotificationDispatcher: notificationDispatcher, | ||
| 967 | + ); | ||
| 968 | + | ||
| 969 | + await service.startCoreCaculate( | ||
| 970 | + endTime: latestTime, | ||
| 971 | + readChunkDays: 1, | ||
| 972 | + ); | ||
| 973 | + | ||
| 974 | + expect( | ||
| 975 | + notificationDispatcher.sentNotifications.where( | ||
| 976 | + (e) => | ||
| 977 | + e.recordType == HealthRawLocalNotificationRecordType.realtimeStress, | ||
| 978 | + ), | ||
| 979 | + isEmpty, | ||
| 980 | + ); | ||
| 981 | + final rows = await store.debugQueryAllRows(42); | ||
| 982 | + expect(rows.realtimeRows.last['push_send_time'], isNull); | ||
| 983 | + }); | ||
| 984 | + | ||
| 985 | + test( | ||
| 986 | + 'startCoreCaculate uses default sleep interval for realtime notification', | ||
| 987 | + () async { | ||
| 988 | + final now = DateTime.now(); | ||
| 989 | + final day = DateTime(now.year, now.month, now.day).subtract( | ||
| 990 | + const Duration(days: 7), | ||
| 991 | + ); | ||
| 992 | + final dayStart = LocalHealthDataConvert.unixSeconds(day); | ||
| 993 | + final latestTime = dayStart + 5 * 3600 + 30 * 60; | ||
| 994 | + final firstTime = latestTime - 9 * 300; | ||
| 995 | + final api = _FakeHealthKitRawDataHostApi(); | ||
| 996 | + final store = _MemoryHealthRawStressLocalStore(); | ||
| 997 | + await store.upsertResult( | ||
| 998 | + HealthRawStressCalculationResult( | ||
| 999 | + userId: 42, | ||
| 1000 | + hrvStressPoints: const <HealthRawHrvStressPoint>[], | ||
| 1001 | + realtimeStressPoints: [ | ||
| 1002 | + for (var i = 0; i < 10; i += 1) | ||
| 1003 | + _realtimeStressPoint(firstTime + i * 300, result: 70), | ||
| 1004 | + ], | ||
| 1005 | + dailyStressPoints: const <HealthRawDailyStressPoint>[], | ||
| 1006 | + ), | ||
| 1007 | + ); | ||
| 1008 | + final notificationDispatcher = _FakeHealthRawLocalNotificationDispatcher() | ||
| 1009 | + ..record = HealthRawLocalNotificationRecord( | ||
| 1010 | + latestRealtimeStressDataTime: firstTime - 1, | ||
| 1011 | + ); | ||
| 1012 | + final service = AppleHealthRawDataCoreService( | ||
| 1013 | + healthApi: _FakeHealthKitHostApi(), | ||
| 1014 | + rawDataApi: api, | ||
| 1015 | + localStore: store, | ||
| 1016 | + userIdProvider: () => 42, | ||
| 1017 | + uploadResultsAfterCalculation: false, | ||
| 1018 | + localNotificationDispatcher: notificationDispatcher, | ||
| 1019 | + ); | ||
| 1020 | + | ||
| 1021 | + await service.startCoreCaculate( | ||
| 1022 | + endTime: latestTime, | ||
| 1023 | + readChunkDays: 1, | ||
| 1024 | + ); | ||
| 1025 | + | ||
| 1026 | + expect( | ||
| 1027 | + notificationDispatcher.sentNotifications.where( | ||
| 1028 | + (e) => | ||
| 1029 | + e.recordType == HealthRawLocalNotificationRecordType.realtimeStress, | ||
| 1030 | + ), | ||
| 1031 | + isEmpty, | ||
| 1032 | + ); | ||
| 1033 | + final rows = await store.debugQueryAllRows(42); | ||
| 1034 | + expect(rows.realtimeRows.last['push_send_time'], isNull); | ||
| 890 | }); | 1035 | }); |
| 891 | 1036 | ||
| 892 | test( | 1037 | test( |
| @@ -1017,6 +1162,10 @@ void main() { | @@ -1017,6 +1162,10 @@ void main() { | ||
| 1017 | expect(rows.realtimeRows.single['uploaded'], 1); | 1162 | expect(rows.realtimeRows.single['uploaded'], 1); |
| 1018 | expect(rows.dailyStressRows.single['uploaded'], 1); | 1163 | expect(rows.dailyStressRows.single['uploaded'], 1); |
| 1019 | expect(rows.sleepRows.single['uploaded'], 1); | 1164 | expect(rows.sleepRows.single['uploaded'], 1); |
| 1165 | + expect(rows.hrvRows.single['upload_time'], isNotNull); | ||
| 1166 | + expect(rows.realtimeRows.single['upload_time'], isNotNull); | ||
| 1167 | + expect(rows.dailyStressRows.single['upload_time'], isNotNull); | ||
| 1168 | + expect(rows.sleepRows.single['upload_time'], isNotNull); | ||
| 1020 | }); | 1169 | }); |
| 1021 | 1170 | ||
| 1022 | test('startCoreCaculate does not wait for pending uploads', () async { | 1171 | test('startCoreCaculate does not wait for pending uploads', () async { |
| @@ -1055,6 +1204,8 @@ void main() { | @@ -1055,6 +1204,8 @@ void main() { | ||
| 1055 | var rows = await store.debugQueryAllRows(42); | 1204 | var rows = await store.debugQueryAllRows(42); |
| 1056 | expect(rows.hrvRows.single['uploaded'], 0); | 1205 | expect(rows.hrvRows.single['uploaded'], 0); |
| 1057 | expect(rows.realtimeRows.single['uploaded'], 0); | 1206 | expect(rows.realtimeRows.single['uploaded'], 0); |
| 1207 | + expect(rows.hrvRows.single['upload_time'], isNull); | ||
| 1208 | + expect(rows.realtimeRows.single['upload_time'], isNull); | ||
| 1058 | 1209 | ||
| 1059 | api.hrvUploadCompleter!.complete(base + 10); | 1210 | api.hrvUploadCompleter!.complete(base + 10); |
| 1060 | api.hrUploadCompleter!.complete(base + 20); | 1211 | api.hrUploadCompleter!.complete(base + 20); |
| @@ -1063,6 +1214,8 @@ void main() { | @@ -1063,6 +1214,8 @@ void main() { | ||
| 1063 | 1214 | ||
| 1064 | expect(rows.hrvRows.single['uploaded'], 1); | 1215 | expect(rows.hrvRows.single['uploaded'], 1); |
| 1065 | expect(rows.realtimeRows.single['uploaded'], 1); | 1216 | expect(rows.realtimeRows.single['uploaded'], 1); |
| 1217 | + expect(rows.hrvRows.single['upload_time'], isNotNull); | ||
| 1218 | + expect(rows.realtimeRows.single['upload_time'], isNotNull); | ||
| 1066 | }); | 1219 | }); |
| 1067 | 1220 | ||
| 1068 | test('local data source returns earliest local hr start time', () async { | 1221 | test('local data source returns earliest local hr start time', () async { |
| @@ -1354,8 +1507,16 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | @@ -1354,8 +1507,16 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | ||
| 1354 | final Map<int, Map<int, int>> _realtimeUpdateTimes = {}; | 1507 | final Map<int, Map<int, int>> _realtimeUpdateTimes = {}; |
| 1355 | final Map<int, Map<int, int>> _dailyUpdateTimes = {}; | 1508 | final Map<int, Map<int, int>> _dailyUpdateTimes = {}; |
| 1356 | final Map<int, Map<int, int>> _sleepUpdateTimes = {}; | 1509 | final Map<int, Map<int, int>> _sleepUpdateTimes = {}; |
| 1510 | + final Map<int, Map<int, int>> _hrvPushSendTimes = {}; | ||
| 1511 | + final Map<int, Map<int, int>> _realtimePushSendTimes = {}; | ||
| 1512 | + final Map<int, Map<int, int>> _sleepPushSendTimes = {}; | ||
| 1513 | + final Map<int, Map<int, int>> _hrvUploadTimes = {}; | ||
| 1514 | + final Map<int, Map<int, int>> _realtimeUploadTimes = {}; | ||
| 1515 | + final Map<int, Map<int, int>> _dailyUploadTimes = {}; | ||
| 1516 | + final Map<int, Map<int, int>> _sleepUploadTimes = {}; | ||
| 1357 | final List<String> operationLog; | 1517 | final List<String> operationLog; |
| 1358 | var _updateTimeClock = 1; | 1518 | var _updateTimeClock = 1; |
| 1519 | + var _uploadTimeClock = 1000; | ||
| 1359 | var realtimeNotificationWindowQueryCount = 0; | 1520 | var realtimeNotificationWindowQueryCount = 0; |
| 1360 | 1521 | ||
| 1361 | void insertDailyStress(HealthRawDailyStressPoint point) { | 1522 | void insertDailyStress(HealthRawDailyStressPoint point) { |
| @@ -1382,6 +1543,7 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | @@ -1382,6 +1543,7 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | ||
| 1382 | @override | 1543 | @override |
| 1383 | Future<void> upsertResult(HealthRawStressCalculationResult result) async { | 1544 | Future<void> upsertResult(HealthRawStressCalculationResult result) async { |
| 1384 | operationLog.add('upsertResult'); | 1545 | operationLog.add('upsertResult'); |
| 1546 | + final updateTime = _nextUpdateTime(); | ||
| 1385 | final hrvByTime = <int, HealthRawHrvStressPoint>{ | 1547 | final hrvByTime = <int, HealthRawHrvStressPoint>{ |
| 1386 | for (final point in _hrv[result.userId] ?? <HealthRawHrvStressPoint>[]) | 1548 | for (final point in _hrv[result.userId] ?? <HealthRawHrvStressPoint>[]) |
| 1387 | point.rawEndTime: point, | 1549 | point.rawEndTime: point, |
| @@ -1403,10 +1565,14 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | @@ -1403,10 +1565,14 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | ||
| 1403 | baselineRestingHr: point.baselineRestingHr, | 1565 | baselineRestingHr: point.baselineRestingHr, |
| 1404 | flags: point.flags, | 1566 | flags: point.flags, |
| 1405 | uploaded: same ? existing.uploaded : false, | 1567 | uploaded: same ? existing.uploaded : false, |
| 1568 | + pushSendTime: existing?.pushSendTime, | ||
| 1569 | + uploadTime: same ? existing.uploadTime : null, | ||
| 1406 | ); | 1570 | ); |
| 1407 | _hrvUpdateTimes.putIfAbsent( | 1571 | _hrvUpdateTimes.putIfAbsent( |
| 1408 | - point.userId, () => <int, int>{})[point.rawEndTime] = | ||
| 1409 | - _nextUpdateTime(); | 1572 | + point.userId, () => <int, int>{})[point.rawEndTime] = updateTime; |
| 1573 | + if (!same) { | ||
| 1574 | + _hrvUploadTimes[point.userId]?.remove(point.rawEndTime); | ||
| 1575 | + } | ||
| 1410 | } | 1576 | } |
| 1411 | _hrv[result.userId] = hrvByTime.values.toList() | 1577 | _hrv[result.userId] = hrvByTime.values.toList() |
| 1412 | ..sort((a, b) => a.rawEndTime.compareTo(b.rawEndTime)); | 1578 | ..sort((a, b) => a.rawEndTime.compareTo(b.rawEndTime)); |
| @@ -1429,10 +1595,14 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | @@ -1429,10 +1595,14 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | ||
| 1429 | sourceEndTime: point.sourceEndTime, | 1595 | sourceEndTime: point.sourceEndTime, |
| 1430 | flags: point.flags, | 1596 | flags: point.flags, |
| 1431 | uploaded: same ? existing.uploaded : false, | 1597 | uploaded: same ? existing.uploaded : false, |
| 1598 | + pushSendTime: existing?.pushSendTime, | ||
| 1599 | + uploadTime: same ? existing.uploadTime : null, | ||
| 1432 | ); | 1600 | ); |
| 1433 | _realtimeUpdateTimes.putIfAbsent( | 1601 | _realtimeUpdateTimes.putIfAbsent( |
| 1434 | - point.userId, () => <int, int>{})[point.rawEndTime] = | ||
| 1435 | - _nextUpdateTime(); | 1602 | + point.userId, () => <int, int>{})[point.rawEndTime] = updateTime; |
| 1603 | + if (!same) { | ||
| 1604 | + _realtimeUploadTimes[point.userId]?.remove(point.rawEndTime); | ||
| 1605 | + } | ||
| 1436 | } | 1606 | } |
| 1437 | _realtime[result.userId] = realtimeByTime.values.toList() | 1607 | _realtime[result.userId] = realtimeByTime.values.toList() |
| 1438 | ..sort((a, b) => a.rawEndTime.compareTo(b.rawEndTime)); | 1608 | ..sort((a, b) => a.rawEndTime.compareTo(b.rawEndTime)); |
| @@ -1444,6 +1614,7 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | @@ -1444,6 +1614,7 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | ||
| 1444 | required Iterable<HealthRawDailyStressPoint> points, | 1614 | required Iterable<HealthRawDailyStressPoint> points, |
| 1445 | }) async { | 1615 | }) async { |
| 1446 | operationLog.add('upsertDailyStressPoints'); | 1616 | operationLog.add('upsertDailyStressPoints'); |
| 1617 | + final updateTime = _nextUpdateTime(); | ||
| 1447 | final byDate = <int, HealthRawDailyStressPoint>{ | 1618 | final byDate = <int, HealthRawDailyStressPoint>{ |
| 1448 | for (final point in _daily[userId] ?? <HealthRawDailyStressPoint>[]) | 1619 | for (final point in _daily[userId] ?? <HealthRawDailyStressPoint>[]) |
| 1449 | point.date: point, | 1620 | point.date: point, |
| @@ -1464,9 +1635,13 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | @@ -1464,9 +1635,13 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | ||
| 1464 | state: point.state, | 1635 | state: point.state, |
| 1465 | dataTime: point.dataTime, | 1636 | dataTime: point.dataTime, |
| 1466 | uploaded: isToday ? false : (same ? existing.uploaded : false), | 1637 | uploaded: isToday ? false : (same ? existing.uploaded : false), |
| 1638 | + uploadTime: isToday || !same ? null : existing.uploadTime, | ||
| 1467 | ); | 1639 | ); |
| 1468 | _dailyUpdateTimes.putIfAbsent( | 1640 | _dailyUpdateTimes.putIfAbsent( |
| 1469 | - point.userId, () => <int, int>{})[point.date] = _nextUpdateTime(); | 1641 | + point.userId, () => <int, int>{})[point.date] = updateTime; |
| 1642 | + if (isToday || !same) { | ||
| 1643 | + _dailyUploadTimes[point.userId]?.remove(point.date); | ||
| 1644 | + } | ||
| 1470 | } | 1645 | } |
| 1471 | _daily[userId] = byDate.values.toList() | 1646 | _daily[userId] = byDate.values.toList() |
| 1472 | ..sort((a, b) => a.date.compareTo(b.date)); | 1647 | ..sort((a, b) => a.date.compareTo(b.date)); |
| @@ -1477,14 +1652,39 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | @@ -1477,14 +1652,39 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | ||
| 1477 | required int userId, | 1652 | required int userId, |
| 1478 | required Iterable<HealthRawSleepResult> results, | 1653 | required Iterable<HealthRawSleepResult> results, |
| 1479 | }) async { | 1654 | }) async { |
| 1655 | + final updateTime = _nextUpdateTime(); | ||
| 1480 | final byDate = <int, HealthRawSleepResult>{ | 1656 | final byDate = <int, HealthRawSleepResult>{ |
| 1481 | for (final result in _sleep[userId] ?? <HealthRawSleepResult>[]) | 1657 | for (final result in _sleep[userId] ?? <HealthRawSleepResult>[]) |
| 1482 | result.date: result, | 1658 | result.date: result, |
| 1483 | }; | 1659 | }; |
| 1484 | for (final result in results) { | 1660 | for (final result in results) { |
| 1485 | - byDate[result.date] = result; | 1661 | + final existing = byDate[result.date]; |
| 1662 | + final same = existing != null && | ||
| 1663 | + existing.userId == result.userId && | ||
| 1664 | + existing.startDate == result.startDate && | ||
| 1665 | + existing.sleepScore == result.sleepScore && | ||
| 1666 | + existing.sleepState == result.sleepState && | ||
| 1667 | + existing.inBedMinutes == result.inBedMinutes && | ||
| 1668 | + existing.awakMinutes == result.awakMinutes && | ||
| 1669 | + existing.sleepMinutes == result.sleepMinutes; | ||
| 1670 | + byDate[result.date] = HealthRawSleepResult( | ||
| 1671 | + userId: result.userId, | ||
| 1672 | + date: result.date, | ||
| 1673 | + startDate: result.startDate, | ||
| 1674 | + sleepScore: result.sleepScore, | ||
| 1675 | + sleepState: result.sleepState, | ||
| 1676 | + inBedMinutes: result.inBedMinutes, | ||
| 1677 | + awakMinutes: result.awakMinutes, | ||
| 1678 | + sleepMinutes: result.sleepMinutes, | ||
| 1679 | + uploaded: same ? existing.uploaded : result.uploaded, | ||
| 1680 | + pushSendTime: existing?.pushSendTime, | ||
| 1681 | + uploadTime: same ? existing.uploadTime : result.uploadTime, | ||
| 1682 | + ); | ||
| 1486 | _sleepUpdateTimes.putIfAbsent( | 1683 | _sleepUpdateTimes.putIfAbsent( |
| 1487 | - result.userId, () => <int, int>{})[result.date] = _nextUpdateTime(); | 1684 | + result.userId, () => <int, int>{})[result.date] = updateTime; |
| 1685 | + if (!same) { | ||
| 1686 | + _sleepUploadTimes[result.userId]?.remove(result.date); | ||
| 1687 | + } | ||
| 1488 | } | 1688 | } |
| 1489 | _sleep[userId] = byDate.values.toList() | 1689 | _sleep[userId] = byDate.values.toList() |
| 1490 | ..sort((a, b) => a.date.compareTo(b.date)); | 1690 | ..sort((a, b) => a.date.compareTo(b.date)); |
| @@ -1614,6 +1814,88 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | @@ -1614,6 +1814,88 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | ||
| 1614 | } | 1814 | } |
| 1615 | 1815 | ||
| 1616 | @override | 1816 | @override |
| 1817 | + Future<int?> latestRealtimeStressPushSendTime(int userId) async { | ||
| 1818 | + final times = (_realtimePushSendTimes[userId] ?? <int, int>{}).values; | ||
| 1819 | + if (times.isEmpty) return null; | ||
| 1820 | + return times.reduce((a, b) => a >= b ? a : b); | ||
| 1821 | + } | ||
| 1822 | + | ||
| 1823 | + @override | ||
| 1824 | + Future<void> markLocalNotificationPushed({ | ||
| 1825 | + required int userId, | ||
| 1826 | + required HealthRawLocalNotificationRecordType recordType, | ||
| 1827 | + required int recordTime, | ||
| 1828 | + required int pushSendTime, | ||
| 1829 | + }) async { | ||
| 1830 | + switch (recordType) { | ||
| 1831 | + case HealthRawLocalNotificationRecordType.hrv: | ||
| 1832 | + _hrvPushSendTimes.putIfAbsent(userId, () => <int, int>{})[recordTime] = | ||
| 1833 | + pushSendTime; | ||
| 1834 | + _hrv[userId] = (_hrv[userId] ?? <HealthRawHrvStressPoint>[]) | ||
| 1835 | + .map((point) => point.rawEndTime == recordTime | ||
| 1836 | + ? HealthRawHrvStressPoint( | ||
| 1837 | + userId: point.userId, | ||
| 1838 | + rawEndTime: point.rawEndTime, | ||
| 1839 | + rawHrv: point.rawHrv, | ||
| 1840 | + result: point.result, | ||
| 1841 | + sourceStartTime: point.sourceStartTime, | ||
| 1842 | + sourceEndTime: point.sourceEndTime, | ||
| 1843 | + state: point.state, | ||
| 1844 | + baselineHrv: point.baselineHrv, | ||
| 1845 | + baselineAwakeHrv: point.baselineAwakeHrv, | ||
| 1846 | + baselineSleepHrv: point.baselineSleepHrv, | ||
| 1847 | + baselineRestingHr: point.baselineRestingHr, | ||
| 1848 | + flags: point.flags, | ||
| 1849 | + uploaded: point.uploaded, | ||
| 1850 | + pushSendTime: pushSendTime, | ||
| 1851 | + uploadTime: point.uploadTime, | ||
| 1852 | + ) | ||
| 1853 | + : point) | ||
| 1854 | + .toList(); | ||
| 1855 | + case HealthRawLocalNotificationRecordType.realtimeStress: | ||
| 1856 | + _realtimePushSendTimes.putIfAbsent( | ||
| 1857 | + userId, () => <int, int>{})[recordTime] = pushSendTime; | ||
| 1858 | + _realtime[userId] = | ||
| 1859 | + (_realtime[userId] ?? <HealthRawRealtimeStressPoint>[]) | ||
| 1860 | + .map((point) => point.rawEndTime == recordTime | ||
| 1861 | + ? HealthRawRealtimeStressPoint( | ||
| 1862 | + userId: point.userId, | ||
| 1863 | + rawEndTime: point.rawEndTime, | ||
| 1864 | + rawHr: point.rawHr, | ||
| 1865 | + result: point.result, | ||
| 1866 | + sourceStartTime: point.sourceStartTime, | ||
| 1867 | + sourceEndTime: point.sourceEndTime, | ||
| 1868 | + flags: point.flags, | ||
| 1869 | + uploaded: point.uploaded, | ||
| 1870 | + pushSendTime: pushSendTime, | ||
| 1871 | + uploadTime: point.uploadTime, | ||
| 1872 | + ) | ||
| 1873 | + : point) | ||
| 1874 | + .toList(); | ||
| 1875 | + case HealthRawLocalNotificationRecordType.sleep: | ||
| 1876 | + _sleepPushSendTimes.putIfAbsent( | ||
| 1877 | + userId, () => <int, int>{})[recordTime] = pushSendTime; | ||
| 1878 | + _sleep[userId] = (_sleep[userId] ?? <HealthRawSleepResult>[]) | ||
| 1879 | + .map((result) => result.date == recordTime | ||
| 1880 | + ? HealthRawSleepResult( | ||
| 1881 | + userId: result.userId, | ||
| 1882 | + date: result.date, | ||
| 1883 | + startDate: result.startDate, | ||
| 1884 | + sleepScore: result.sleepScore, | ||
| 1885 | + sleepState: result.sleepState, | ||
| 1886 | + inBedMinutes: result.inBedMinutes, | ||
| 1887 | + awakMinutes: result.awakMinutes, | ||
| 1888 | + sleepMinutes: result.sleepMinutes, | ||
| 1889 | + uploaded: result.uploaded, | ||
| 1890 | + pushSendTime: pushSendTime, | ||
| 1891 | + uploadTime: result.uploadTime, | ||
| 1892 | + ) | ||
| 1893 | + : result) | ||
| 1894 | + .toList(); | ||
| 1895 | + } | ||
| 1896 | + } | ||
| 1897 | + | ||
| 1898 | + @override | ||
| 1617 | Future<int?> earliestRealtimeRawEndTime(int userId) async { | 1899 | Future<int?> earliestRealtimeRawEndTime(int userId) async { |
| 1618 | final points = _realtime[userId]; | 1900 | final points = _realtime[userId]; |
| 1619 | if (points == null || points.isEmpty) return null; | 1901 | if (points == null || points.isEmpty) return null; |
| @@ -1633,8 +1915,11 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | @@ -1633,8 +1915,11 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | ||
| 1633 | required Iterable<int> rawEndTimes, | 1915 | required Iterable<int> rawEndTimes, |
| 1634 | }) async { | 1916 | }) async { |
| 1635 | final timeSet = rawEndTimes.toSet(); | 1917 | final timeSet = rawEndTimes.toSet(); |
| 1918 | + final uploadTime = _nextUploadTime(); | ||
| 1636 | _hrv[userId] = (_hrv[userId] ?? <HealthRawHrvStressPoint>[]).map((point) { | 1919 | _hrv[userId] = (_hrv[userId] ?? <HealthRawHrvStressPoint>[]).map((point) { |
| 1637 | if (!timeSet.contains(point.rawEndTime)) return point; | 1920 | if (!timeSet.contains(point.rawEndTime)) return point; |
| 1921 | + _hrvUploadTimes.putIfAbsent( | ||
| 1922 | + userId, () => <int, int>{})[point.rawEndTime] = uploadTime; | ||
| 1638 | return HealthRawHrvStressPoint( | 1923 | return HealthRawHrvStressPoint( |
| 1639 | userId: point.userId, | 1924 | userId: point.userId, |
| 1640 | rawEndTime: point.rawEndTime, | 1925 | rawEndTime: point.rawEndTime, |
| @@ -1649,6 +1934,8 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | @@ -1649,6 +1934,8 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | ||
| 1649 | baselineRestingHr: point.baselineRestingHr, | 1934 | baselineRestingHr: point.baselineRestingHr, |
| 1650 | flags: point.flags, | 1935 | flags: point.flags, |
| 1651 | uploaded: true, | 1936 | uploaded: true, |
| 1937 | + pushSendTime: point.pushSendTime, | ||
| 1938 | + uploadTime: uploadTime, | ||
| 1652 | ); | 1939 | ); |
| 1653 | }).toList(); | 1940 | }).toList(); |
| 1654 | } | 1941 | } |
| @@ -1670,9 +1957,12 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | @@ -1670,9 +1957,12 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | ||
| 1670 | required Iterable<int> rawEndTimes, | 1957 | required Iterable<int> rawEndTimes, |
| 1671 | }) async { | 1958 | }) async { |
| 1672 | final timeSet = rawEndTimes.toSet(); | 1959 | final timeSet = rawEndTimes.toSet(); |
| 1960 | + final uploadTime = _nextUploadTime(); | ||
| 1673 | _realtime[userId] = | 1961 | _realtime[userId] = |
| 1674 | (_realtime[userId] ?? <HealthRawRealtimeStressPoint>[]).map((point) { | 1962 | (_realtime[userId] ?? <HealthRawRealtimeStressPoint>[]).map((point) { |
| 1675 | if (!timeSet.contains(point.rawEndTime)) return point; | 1963 | if (!timeSet.contains(point.rawEndTime)) return point; |
| 1964 | + _realtimeUploadTimes.putIfAbsent( | ||
| 1965 | + userId, () => <int, int>{})[point.rawEndTime] = uploadTime; | ||
| 1676 | return HealthRawRealtimeStressPoint( | 1966 | return HealthRawRealtimeStressPoint( |
| 1677 | userId: point.userId, | 1967 | userId: point.userId, |
| 1678 | rawEndTime: point.rawEndTime, | 1968 | rawEndTime: point.rawEndTime, |
| @@ -1682,6 +1972,8 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | @@ -1682,6 +1972,8 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | ||
| 1682 | sourceEndTime: point.sourceEndTime, | 1972 | sourceEndTime: point.sourceEndTime, |
| 1683 | flags: point.flags, | 1973 | flags: point.flags, |
| 1684 | uploaded: true, | 1974 | uploaded: true, |
| 1975 | + pushSendTime: point.pushSendTime, | ||
| 1976 | + uploadTime: uploadTime, | ||
| 1685 | ); | 1977 | ); |
| 1686 | }).toList(); | 1978 | }).toList(); |
| 1687 | } | 1979 | } |
| @@ -1702,9 +1994,12 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | @@ -1702,9 +1994,12 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | ||
| 1702 | required int userId, | 1994 | required int userId, |
| 1703 | required int date, | 1995 | required int date, |
| 1704 | }) async { | 1996 | }) async { |
| 1997 | + final uploadTime = _nextUploadTime(); | ||
| 1705 | _daily[userId] = | 1998 | _daily[userId] = |
| 1706 | (_daily[userId] ?? <HealthRawDailyStressPoint>[]).map((point) { | 1999 | (_daily[userId] ?? <HealthRawDailyStressPoint>[]).map((point) { |
| 1707 | if (point.date > date) return point; | 2000 | if (point.date > date) return point; |
| 2001 | + _dailyUploadTimes.putIfAbsent(userId, () => <int, int>{})[point.date] = | ||
| 2002 | + uploadTime; | ||
| 1708 | return HealthRawDailyStressPoint( | 2003 | return HealthRawDailyStressPoint( |
| 1709 | userId: point.userId, | 2004 | userId: point.userId, |
| 1710 | date: point.date, | 2005 | date: point.date, |
| @@ -1713,6 +2008,7 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | @@ -1713,6 +2008,7 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | ||
| 1713 | state: point.state, | 2008 | state: point.state, |
| 1714 | dataTime: point.dataTime, | 2009 | dataTime: point.dataTime, |
| 1715 | uploaded: true, | 2010 | uploaded: true, |
| 2011 | + uploadTime: uploadTime, | ||
| 1716 | ); | 2012 | ); |
| 1717 | }).toList(); | 2013 | }).toList(); |
| 1718 | } | 2014 | } |
| @@ -1722,8 +2018,11 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | @@ -1722,8 +2018,11 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | ||
| 1722 | required int userId, | 2018 | required int userId, |
| 1723 | required int date, | 2019 | required int date, |
| 1724 | }) async { | 2020 | }) async { |
| 2021 | + final uploadTime = _nextUploadTime(); | ||
| 1725 | _sleep[userId] = (_sleep[userId] ?? <HealthRawSleepResult>[]).map((result) { | 2022 | _sleep[userId] = (_sleep[userId] ?? <HealthRawSleepResult>[]).map((result) { |
| 1726 | if (result.date > date) return result; | 2023 | if (result.date > date) return result; |
| 2024 | + _sleepUploadTimes.putIfAbsent(userId, () => <int, int>{})[result.date] = | ||
| 2025 | + uploadTime; | ||
| 1727 | return HealthRawSleepResult( | 2026 | return HealthRawSleepResult( |
| 1728 | userId: result.userId, | 2027 | userId: result.userId, |
| 1729 | date: result.date, | 2028 | date: result.date, |
| @@ -1734,6 +2033,8 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | @@ -1734,6 +2033,8 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | ||
| 1734 | awakMinutes: result.awakMinutes, | 2033 | awakMinutes: result.awakMinutes, |
| 1735 | sleepMinutes: result.sleepMinutes, | 2034 | sleepMinutes: result.sleepMinutes, |
| 1736 | uploaded: true, | 2035 | uploaded: true, |
| 2036 | + pushSendTime: result.pushSendTime, | ||
| 2037 | + uploadTime: uploadTime, | ||
| 1737 | ); | 2038 | ); |
| 1738 | }).toList(); | 2039 | }).toList(); |
| 1739 | } | 2040 | } |
| @@ -1813,6 +2114,9 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | @@ -1813,6 +2114,9 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | ||
| 1813 | 'is_suspected_activity': point.flags.isSuspectedActivity ? 1 : 0, | 2114 | 'is_suspected_activity': point.flags.isSuspectedActivity ? 1 : 0, |
| 1814 | 'uploaded': point.uploaded ? 1 : 0, | 2115 | 'uploaded': point.uploaded ? 1 : 0, |
| 1815 | 'update_time': _hrvUpdateTimes[point.userId]?[point.rawEndTime] ?? 0, | 2116 | 'update_time': _hrvUpdateTimes[point.userId]?[point.rawEndTime] ?? 0, |
| 2117 | + 'push_send_time': _hrvPushSendTimes[point.userId]?[point.rawEndTime], | ||
| 2118 | + 'upload_time': | ||
| 2119 | + _hrvUploadTimes[point.userId]?[point.rawEndTime] ?? point.uploadTime, | ||
| 1816 | }; | 2120 | }; |
| 1817 | } | 2121 | } |
| 1818 | 2122 | ||
| @@ -1830,6 +2134,9 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | @@ -1830,6 +2134,9 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | ||
| 1830 | 'is_suspected_activity': point.flags.isSuspectedActivity ? 1 : 0, | 2134 | 'is_suspected_activity': point.flags.isSuspectedActivity ? 1 : 0, |
| 1831 | 'uploaded': point.uploaded ? 1 : 0, | 2135 | 'uploaded': point.uploaded ? 1 : 0, |
| 1832 | 'update_time': _realtimeUpdateTimes[point.userId]?[point.rawEndTime] ?? 0, | 2136 | 'update_time': _realtimeUpdateTimes[point.userId]?[point.rawEndTime] ?? 0, |
| 2137 | + 'push_send_time': _realtimePushSendTimes[point.userId]?[point.rawEndTime], | ||
| 2138 | + 'upload_time': _realtimeUploadTimes[point.userId]?[point.rawEndTime] ?? | ||
| 2139 | + point.uploadTime, | ||
| 1833 | }; | 2140 | }; |
| 1834 | } | 2141 | } |
| 1835 | 2142 | ||
| @@ -1843,6 +2150,8 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | @@ -1843,6 +2150,8 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | ||
| 1843 | 'data_time': point.dataTime, | 2150 | 'data_time': point.dataTime, |
| 1844 | 'uploaded': point.uploaded ? 1 : 0, | 2151 | 'uploaded': point.uploaded ? 1 : 0, |
| 1845 | 'update_time': _dailyUpdateTimes[point.userId]?[point.date] ?? 0, | 2152 | 'update_time': _dailyUpdateTimes[point.userId]?[point.date] ?? 0, |
| 2153 | + 'upload_time': | ||
| 2154 | + _dailyUploadTimes[point.userId]?[point.date] ?? point.uploadTime, | ||
| 1846 | }; | 2155 | }; |
| 1847 | } | 2156 | } |
| 1848 | 2157 | ||
| @@ -1858,11 +2167,16 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | @@ -1858,11 +2167,16 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | ||
| 1858 | 'sleep_minutes': result.sleepMinutes, | 2167 | 'sleep_minutes': result.sleepMinutes, |
| 1859 | 'uploaded': result.uploaded ? 1 : 0, | 2168 | 'uploaded': result.uploaded ? 1 : 0, |
| 1860 | 'update_time': _sleepUpdateTimes[result.userId]?[result.date] ?? 0, | 2169 | 'update_time': _sleepUpdateTimes[result.userId]?[result.date] ?? 0, |
| 2170 | + 'push_send_time': _sleepPushSendTimes[result.userId]?[result.date], | ||
| 2171 | + 'upload_time': | ||
| 2172 | + _sleepUploadTimes[result.userId]?[result.date] ?? result.uploadTime, | ||
| 1861 | }; | 2173 | }; |
| 1862 | } | 2174 | } |
| 1863 | 2175 | ||
| 1864 | int _nextUpdateTime() => _updateTimeClock++; | 2176 | int _nextUpdateTime() => _updateTimeClock++; |
| 1865 | 2177 | ||
| 2178 | + int _nextUploadTime() => _uploadTimeClock++; | ||
| 2179 | + | ||
| 1866 | int _todayDateKey() { | 2180 | int _todayDateKey() { |
| 1867 | final now = DateTime.now(); | 2181 | final now = DateTime.now(); |
| 1868 | return now.year * 10000 + now.month * 100 + now.day; | 2182 | return now.year * 10000 + now.month * 100 + now.day; |
| @@ -153,6 +153,30 @@ void main() { | @@ -153,6 +153,30 @@ void main() { | ||
| 153 | expect(notifications.single.recordTime, latestTime); | 153 | expect(notifications.single.recordTime, latestTime); |
| 154 | }); | 154 | }); |
| 155 | 155 | ||
| 156 | + test('builds hrv notifications for every candidate including sleep likely', | ||
| 157 | + () { | ||
| 158 | + final firstTime = _seconds(DateTime(2026, 1, 1, 9)); | ||
| 159 | + final secondTime = _seconds(DateTime(2026, 1, 1, 10)); | ||
| 160 | + final notifications = builder.build( | ||
| 161 | + result: HealthRawStressCalculationResult( | ||
| 162 | + userId: 1, | ||
| 163 | + hrvStressPoints: [ | ||
| 164 | + _hrvPoint(secondTime, isSleepLikely: true), | ||
| 165 | + _hrvPoint(firstTime), | ||
| 166 | + ], | ||
| 167 | + realtimeStressPoints: const [], | ||
| 168 | + dailyStressPoints: const [], | ||
| 169 | + ), | ||
| 170 | + hasExistingHrv: true, | ||
| 171 | + hasExistingSleep: false, | ||
| 172 | + realtimeWindow: const [], | ||
| 173 | + record: const HealthRawLocalNotificationRecord(), | ||
| 174 | + ); | ||
| 175 | + | ||
| 176 | + expect(notifications, hasLength(2)); | ||
| 177 | + expect(notifications.map((e) => e.recordTime), [firstTime, secondTime]); | ||
| 178 | + }); | ||
| 179 | + | ||
| 156 | test('builds realtime stress notification from latest 60 minute window', () { | 180 | test('builds realtime stress notification from latest 60 minute window', () { |
| 157 | final base = _seconds(DateTime(2026, 1, 1, 9)); | 181 | final base = _seconds(DateTime(2026, 1, 1, 9)); |
| 158 | final notifications = builder.build( | 182 | final notifications = builder.build( |
| @@ -208,6 +232,138 @@ void main() { | @@ -208,6 +232,138 @@ void main() { | ||
| 208 | ); | 232 | ); |
| 209 | }); | 233 | }); |
| 210 | 234 | ||
| 235 | + test('skips realtime stress notification within push send interval', () { | ||
| 236 | + final base = _seconds(DateTime(2026, 1, 1, 9)); | ||
| 237 | + final notifications = builder.build( | ||
| 238 | + result: HealthRawStressCalculationResult( | ||
| 239 | + userId: 1, | ||
| 240 | + hrvStressPoints: const [], | ||
| 241 | + realtimeStressPoints: [_realtimePoint(base + 9 * 300, 70)], | ||
| 242 | + dailyStressPoints: const [], | ||
| 243 | + ), | ||
| 244 | + hasExistingHrv: false, | ||
| 245 | + hasExistingSleep: false, | ||
| 246 | + realtimeWindow: [ | ||
| 247 | + for (var i = 0; i < 10; i++) _realtimePoint(base + i * 300, 70), | ||
| 248 | + ], | ||
| 249 | + record: const HealthRawLocalNotificationRecord(), | ||
| 250 | + lastRealtimeStressPushSendTime: base + 100, | ||
| 251 | + notificationBuildTime: base + 100 + 3599, | ||
| 252 | + ); | ||
| 253 | + | ||
| 254 | + expect(notifications, isEmpty); | ||
| 255 | + }); | ||
| 256 | + | ||
| 257 | + test('skips realtime stress notification inside supplied sleep interval', () { | ||
| 258 | + final base = _seconds(DateTime(2026, 1, 1, 9)); | ||
| 259 | + final notifications = builder.build( | ||
| 260 | + result: HealthRawStressCalculationResult( | ||
| 261 | + userId: 1, | ||
| 262 | + hrvStressPoints: const [], | ||
| 263 | + realtimeStressPoints: [_realtimePoint(base + 9 * 300, 70)], | ||
| 264 | + dailyStressPoints: const [], | ||
| 265 | + ), | ||
| 266 | + hasExistingHrv: false, | ||
| 267 | + hasExistingSleep: false, | ||
| 268 | + realtimeWindow: [ | ||
| 269 | + for (var i = 0; i < 10; i++) _realtimePoint(base + i * 300, 70), | ||
| 270 | + ], | ||
| 271 | + record: const HealthRawLocalNotificationRecord(), | ||
| 272 | + realtimeSleepIntervals: [ | ||
| 273 | + (startTime: base + 8 * 300, endTime: base + 10 * 300), | ||
| 274 | + ], | ||
| 275 | + ); | ||
| 276 | + | ||
| 277 | + expect(notifications, isEmpty); | ||
| 278 | + }); | ||
| 279 | + | ||
| 280 | + test('builds realtime stress notification outside supplied sleep interval', | ||
| 281 | + () { | ||
| 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 | + realtimeSleepIntervals: [ | ||
| 297 | + (startTime: base - 3 * 3600, endTime: base - 2 * 3600), | ||
| 298 | + ], | ||
| 299 | + ); | ||
| 300 | + | ||
| 301 | + expect(notifications, hasLength(1)); | ||
| 302 | + }); | ||
| 303 | + | ||
| 304 | + test('uses midnight to six default realtime sleep interval', () { | ||
| 305 | + final dayStart = _seconds(DateTime(2026, 1, 1)); | ||
| 306 | + final sleepTime = dayStart + 5 * 3600 + 30 * 60; | ||
| 307 | + final awakeTime = dayStart + 6 * 3600 + 60; | ||
| 308 | + | ||
| 309 | + final sleepNotifications = builder.build( | ||
| 310 | + result: HealthRawStressCalculationResult( | ||
| 311 | + userId: 1, | ||
| 312 | + hrvStressPoints: const [], | ||
| 313 | + realtimeStressPoints: [_realtimePoint(sleepTime, 70)], | ||
| 314 | + dailyStressPoints: const [], | ||
| 315 | + ), | ||
| 316 | + hasExistingHrv: false, | ||
| 317 | + hasExistingSleep: false, | ||
| 318 | + realtimeWindow: [ | ||
| 319 | + for (var i = 0; i < 10; i++) | ||
| 320 | + _realtimePoint(sleepTime - (9 - i) * 300, 70), | ||
| 321 | + ], | ||
| 322 | + record: const HealthRawLocalNotificationRecord(), | ||
| 323 | + ); | ||
| 324 | + final awakeNotifications = builder.build( | ||
| 325 | + result: HealthRawStressCalculationResult( | ||
| 326 | + userId: 1, | ||
| 327 | + hrvStressPoints: const [], | ||
| 328 | + realtimeStressPoints: [_realtimePoint(awakeTime, 70)], | ||
| 329 | + dailyStressPoints: const [], | ||
| 330 | + ), | ||
| 331 | + hasExistingHrv: false, | ||
| 332 | + hasExistingSleep: false, | ||
| 333 | + realtimeWindow: [ | ||
| 334 | + for (var i = 0; i < 10; i++) | ||
| 335 | + _realtimePoint(awakeTime - (9 - i) * 300, 70), | ||
| 336 | + ], | ||
| 337 | + record: const HealthRawLocalNotificationRecord(), | ||
| 338 | + ); | ||
| 339 | + | ||
| 340 | + expect(sleepNotifications, isEmpty); | ||
| 341 | + expect(awakeNotifications, hasLength(1)); | ||
| 342 | + }); | ||
| 343 | + | ||
| 344 | + test('ignores legacy realtime record time for push send interval', () { | ||
| 345 | + final base = _seconds(DateTime(2026, 1, 1, 9)); | ||
| 346 | + final notifications = builder.build( | ||
| 347 | + result: HealthRawStressCalculationResult( | ||
| 348 | + userId: 1, | ||
| 349 | + hrvStressPoints: const [], | ||
| 350 | + realtimeStressPoints: [_realtimePoint(base + 9 * 300, 70)], | ||
| 351 | + dailyStressPoints: const [], | ||
| 352 | + ), | ||
| 353 | + hasExistingHrv: false, | ||
| 354 | + hasExistingSleep: false, | ||
| 355 | + realtimeWindow: [ | ||
| 356 | + for (var i = 0; i < 10; i++) _realtimePoint(base + i * 300, 70), | ||
| 357 | + ], | ||
| 358 | + record: HealthRawLocalNotificationRecord( | ||
| 359 | + lastRealtimeStressTime: base + 9 * 300, | ||
| 360 | + ), | ||
| 361 | + notificationBuildTime: base + 9 * 300, | ||
| 362 | + ); | ||
| 363 | + | ||
| 364 | + expect(notifications, hasLength(1)); | ||
| 365 | + }); | ||
| 366 | + | ||
| 211 | test('skips realtime stress notification during likely sleep', () { | 367 | test('skips realtime stress notification during likely sleep', () { |
| 212 | final base = _seconds(DateTime(2026, 1, 1, 9)); | 368 | final base = _seconds(DateTime(2026, 1, 1, 9)); |
| 213 | final notifications = builder.build( | 369 | final notifications = builder.build( |
| @@ -339,7 +495,10 @@ void main() { | @@ -339,7 +495,10 @@ void main() { | ||
| 339 | 495 | ||
| 340 | int _seconds(DateTime time) => time.millisecondsSinceEpoch ~/ 1000; | 496 | int _seconds(DateTime time) => time.millisecondsSinceEpoch ~/ 1000; |
| 341 | 497 | ||
| 342 | -HealthRawHrvStressPoint _hrvPoint(int rawEndTime) { | 498 | +HealthRawHrvStressPoint _hrvPoint( |
| 499 | + int rawEndTime, { | ||
| 500 | + bool isSleepLikely = false, | ||
| 501 | +}) { | ||
| 343 | return HealthRawHrvStressPoint( | 502 | return HealthRawHrvStressPoint( |
| 344 | userId: 1, | 503 | userId: 1, |
| 345 | rawEndTime: rawEndTime, | 504 | rawEndTime: rawEndTime, |
| @@ -352,6 +511,12 @@ HealthRawHrvStressPoint _hrvPoint(int rawEndTime) { | @@ -352,6 +511,12 @@ HealthRawHrvStressPoint _hrvPoint(int rawEndTime) { | ||
| 352 | baselineAwakeHrv: 30, | 511 | baselineAwakeHrv: 30, |
| 353 | baselineSleepHrv: null, | 512 | baselineSleepHrv: null, |
| 354 | baselineRestingHr: 60, | 513 | baselineRestingHr: 60, |
| 514 | + flags: HealthRawPointFlags( | ||
| 515 | + isSleepLikely: isSleepLikely, | ||
| 516 | + isWorkout: false, | ||
| 517 | + isWorkoutRecovery: false, | ||
| 518 | + isSuspectedActivity: false, | ||
| 519 | + ), | ||
| 355 | ); | 520 | ); |
| 356 | } | 521 | } |
| 357 | 522 |
-
Please register or login to post a comment