Showing
3 changed files
with
223 additions
and
45 deletions
| @@ -404,41 +404,114 @@ class HealthRawDataCoreService { | @@ -404,41 +404,114 @@ class HealthRawDataCoreService { | ||
| 404 | result.sleepResults.isEmpty) { | 404 | result.sleepResults.isEmpty) { |
| 405 | return; | 405 | return; |
| 406 | } | 406 | } |
| 407 | + | ||
| 408 | + final latestRealtimeStressPoint = result.realtimeStressPoints.isEmpty | ||
| 409 | + ? null | ||
| 410 | + : result.realtimeStressPoints.reduce( | ||
| 411 | + (a, b) => a.rawEndTime >= b.rawEndTime ? a : b, | ||
| 412 | + ); | ||
| 413 | + var notificationResult = result; | ||
| 414 | + var realtimeWindow = const <HealthRawRealtimeStressPoint>[]; | ||
| 415 | + if (latestRealtimeStressPoint != null) { | ||
| 416 | + final shouldResetRealtimeStressPushTime = | ||
| 417 | + latestRealtimeStressPoint.isWorkout || | ||
| 418 | + latestRealtimeStressPoint.isWorkoutRecovery; | ||
| 419 | + if (shouldResetRealtimeStressPushTime) { | ||
| 420 | + await _recordRealtimeStressTimeSafely( | ||
| 421 | + userId: result.userId, | ||
| 422 | + recordTime: latestRealtimeStressPoint.rawEndTime, | ||
| 423 | + ); | ||
| 424 | + notificationResult = result.copyWith( | ||
| 425 | + realtimeStressPoints: const <HealthRawRealtimeStressPoint>[], | ||
| 426 | + ); | ||
| 427 | + } else { | ||
| 428 | + realtimeWindow = await _queryRealtimeStressNotificationWindowSafely( | ||
| 429 | + userId: result.userId, | ||
| 430 | + latestRawEndTime: latestRealtimeStressPoint.rawEndTime, | ||
| 431 | + ); | ||
| 432 | + } | ||
| 433 | + } | ||
| 434 | + | ||
| 435 | + final record = await _readLocalNotificationRecordSafely(result.userId); | ||
| 436 | + final notifications = HealthRawLocalNotificationBuilder(l10n).build( | ||
| 437 | + result: notificationResult, | ||
| 438 | + hasExistingHrv: hasExistingHrv, | ||
| 439 | + realtimeWindow: realtimeWindow, | ||
| 440 | + record: record, | ||
| 441 | + ); | ||
| 442 | + if (notifications.isEmpty) return; | ||
| 443 | + | ||
| 444 | + final sentNotifications = <HealthRawLocalNotification>[]; | ||
| 445 | + for (final notification in notifications) { | ||
| 446 | + final sent = await _sendLocalNotificationSafely( | ||
| 447 | + userId: result.userId, | ||
| 448 | + notification: notification, | ||
| 449 | + ); | ||
| 450 | + if (sent) { | ||
| 451 | + sentNotifications.add(notification); | ||
| 452 | + } | ||
| 453 | + } | ||
| 454 | + _scheduleRealtimeStressServerPush(sentNotifications); | ||
| 455 | + } | ||
| 456 | + | ||
| 457 | + Future<HealthRawLocalNotificationRecord> _readLocalNotificationRecordSafely( | ||
| 458 | + int userId, | ||
| 459 | + ) async { | ||
| 460 | + try { | ||
| 461 | + return await _localNotificationDispatcher.readRecord(userId); | ||
| 462 | + } catch (error, stackTrace) { | ||
| 463 | + _logError('read local notification record failed', error, stackTrace); | ||
| 464 | + return const HealthRawLocalNotificationRecord(); | ||
| 465 | + } | ||
| 466 | + } | ||
| 467 | + | ||
| 468 | + Future<void> _recordRealtimeStressTimeSafely({ | ||
| 469 | + required int userId, | ||
| 470 | + required int recordTime, | ||
| 471 | + }) async { | ||
| 407 | try { | 472 | try { |
| 408 | - final latestRealtimeStressPoint = result.realtimeStressPoints.isEmpty | ||
| 409 | - ? null | ||
| 410 | - : result.realtimeStressPoints.reduce( | ||
| 411 | - (a, b) => a.rawEndTime >= b.rawEndTime ? a : b, | ||
| 412 | - ); | ||
| 413 | - final shouldSkipRealtimeStressNotification = | ||
| 414 | - latestRealtimeStressPoint?.isWorkout ?? false; | ||
| 415 | - final realtimeWindow = latestRealtimeStressPoint == null || | ||
| 416 | - shouldSkipRealtimeStressNotification | ||
| 417 | - ? const <HealthRawRealtimeStressPoint>[] | ||
| 418 | - : await _localStore.queryRealtimeStressPoints( | ||
| 419 | - userId: result.userId, | ||
| 420 | - startTime: latestRealtimeStressPoint.rawEndTime - | ||
| 421 | - Duration.secondsPerHour + | ||
| 422 | - 1, | ||
| 423 | - endTime: latestRealtimeStressPoint.rawEndTime, | ||
| 424 | - ); | ||
| 425 | - final record = await _localNotificationDispatcher.readRecord( | ||
| 426 | - result.userId, | 473 | + await _localNotificationDispatcher.recordRealtimeStressTime( |
| 474 | + userId: userId, | ||
| 475 | + recordTime: recordTime, | ||
| 427 | ); | 476 | ); |
| 428 | - final notifications = HealthRawLocalNotificationBuilder(l10n).build( | ||
| 429 | - result: result, | ||
| 430 | - hasExistingHrv: hasExistingHrv, | ||
| 431 | - realtimeWindow: realtimeWindow, | ||
| 432 | - record: record, | 477 | + } catch (error, stackTrace) { |
| 478 | + _logError('record realtime stress push time failed', error, stackTrace); | ||
| 479 | + } | ||
| 480 | + } | ||
| 481 | + | ||
| 482 | + Future<List<HealthRawRealtimeStressPoint>> | ||
| 483 | + _queryRealtimeStressNotificationWindowSafely({ | ||
| 484 | + required int userId, | ||
| 485 | + required int latestRawEndTime, | ||
| 486 | + }) async { | ||
| 487 | + try { | ||
| 488 | + return await _localStore.queryRealtimeStressPoints( | ||
| 489 | + userId: userId, | ||
| 490 | + startTime: latestRawEndTime - Duration.secondsPerHour + 1, | ||
| 491 | + endTime: latestRawEndTime, | ||
| 433 | ); | 492 | ); |
| 434 | - if (notifications.isEmpty) return; | ||
| 435 | - final sentNotifications = await _localNotificationDispatcher.sendAll( | ||
| 436 | - userId: result.userId, | ||
| 437 | - notifications: notifications, | 493 | + } catch (error, stackTrace) { |
| 494 | + _logError( | ||
| 495 | + 'query realtime stress notification window failed', | ||
| 496 | + error, | ||
| 497 | + stackTrace, | ||
| 498 | + ); | ||
| 499 | + return const <HealthRawRealtimeStressPoint>[]; | ||
| 500 | + } | ||
| 501 | + } | ||
| 502 | + | ||
| 503 | + Future<bool> _sendLocalNotificationSafely({ | ||
| 504 | + required int userId, | ||
| 505 | + required HealthRawLocalNotification notification, | ||
| 506 | + }) async { | ||
| 507 | + try { | ||
| 508 | + return await _localNotificationDispatcher.sendOne( | ||
| 509 | + userId: userId, | ||
| 510 | + notification: notification, | ||
| 438 | ); | 511 | ); |
| 439 | - _scheduleRealtimeStressServerPush(sentNotifications); | ||
| 440 | } catch (error, stackTrace) { | 512 | } catch (error, stackTrace) { |
| 441 | - _logError('send local health notifications failed', error, stackTrace); | 513 | + _logError('send local health notification failed', error, stackTrace); |
| 514 | + return false; | ||
| 442 | } | 515 | } |
| 443 | } | 516 | } |
| 444 | 517 |
| @@ -55,7 +55,13 @@ class HealthRawLocalNotificationBuilder { | @@ -55,7 +55,13 @@ class HealthRawLocalNotificationBuilder { | ||
| 55 | if (_hrvNotification(result.hrvStressPoints, hasExistingHrv, record) | 55 | if (_hrvNotification(result.hrvStressPoints, hasExistingHrv, record) |
| 56 | case final notification?) | 56 | case final notification?) |
| 57 | notification, | 57 | notification, |
| 58 | - if (_realtimeStressNotification(realtimeWindow, record) | 58 | + if (_realtimeStressNotification( |
| 59 | + realtimeWindow, | ||
| 60 | + record, | ||
| 61 | + latestRealtimeStressPoint: _latestRealtimeStressPoint( | ||
| 62 | + result.realtimeStressPoints, | ||
| 63 | + ), | ||
| 64 | + ) | ||
| 59 | case final notification?) | 65 | case final notification?) |
| 60 | notification, | 66 | notification, |
| 61 | ]; | 67 | ]; |
| @@ -106,15 +112,22 @@ class HealthRawLocalNotificationBuilder { | @@ -106,15 +112,22 @@ class HealthRawLocalNotificationBuilder { | ||
| 106 | 112 | ||
| 107 | HealthRawLocalNotification? _realtimeStressNotification( | 113 | HealthRawLocalNotification? _realtimeStressNotification( |
| 108 | List<HealthRawRealtimeStressPoint> realtimeWindow, | 114 | List<HealthRawRealtimeStressPoint> realtimeWindow, |
| 109 | - HealthRawLocalNotificationRecord? record, | ||
| 110 | - ) { | 115 | + HealthRawLocalNotificationRecord? record, { |
| 116 | + required HealthRawRealtimeStressPoint? latestRealtimeStressPoint, | ||
| 117 | + }) { | ||
| 118 | + if (latestRealtimeStressPoint == null) return null; | ||
| 119 | + if (latestRealtimeStressPoint.isWorkout || | ||
| 120 | + latestRealtimeStressPoint.isWorkoutRecovery) { | ||
| 121 | + return null; | ||
| 122 | + } | ||
| 123 | + | ||
| 111 | final valid = realtimeWindow | 124 | final valid = realtimeWindow |
| 112 | .where((e) => e.result >= 1 && e.result <= 100) | 125 | .where((e) => e.result >= 1 && e.result <= 100) |
| 113 | .toList() | 126 | .toList() |
| 114 | ..sort((a, b) => a.rawEndTime.compareTo(b.rawEndTime)); | 127 | ..sort((a, b) => a.rawEndTime.compareTo(b.rawEndTime)); |
| 115 | if (valid.length < _realtimeMinPointCount) return null; | 128 | if (valid.length < _realtimeMinPointCount) return null; |
| 116 | 129 | ||
| 117 | - final latest = valid.last; | 130 | + final latest = latestRealtimeStressPoint; |
| 118 | if (latest.isSleepLikely) return null; | 131 | if (latest.isSleepLikely) return null; |
| 119 | if (record?.lastRealtimeStressTime case final lastPushTime?) { | 132 | if (record?.lastRealtimeStressTime case final lastPushTime?) { |
| 120 | if (latest.rawEndTime - lastPushTime < _realtimeWindowSeconds) { | 133 | if (latest.rawEndTime - lastPushTime < _realtimeWindowSeconds) { |
| @@ -140,6 +153,13 @@ class HealthRawLocalNotificationBuilder { | @@ -140,6 +153,13 @@ class HealthRawLocalNotificationBuilder { | ||
| 140 | ); | 153 | ); |
| 141 | } | 154 | } |
| 142 | 155 | ||
| 156 | + HealthRawRealtimeStressPoint? _latestRealtimeStressPoint( | ||
| 157 | + List<HealthRawRealtimeStressPoint> points, | ||
| 158 | + ) { | ||
| 159 | + if (points.isEmpty) return null; | ||
| 160 | + return points.reduce((a, b) => a.rawEndTime >= b.rawEndTime ? a : b); | ||
| 161 | + } | ||
| 162 | + | ||
| 143 | String _sleepDurationText(int minutes) { | 163 | String _sleepDurationText(int minutes) { |
| 144 | final hours = minutes ~/ 60; | 164 | final hours = minutes ~/ 60; |
| 145 | final remainingMinutes = minutes % 60; | 165 | final remainingMinutes = minutes % 60; |
| @@ -217,25 +237,46 @@ class HealthRawLocalNotificationDispatcher { | @@ -217,25 +237,46 @@ class HealthRawLocalNotificationDispatcher { | ||
| 217 | required int userId, | 237 | required int userId, |
| 218 | required Iterable<HealthRawLocalNotification> notifications, | 238 | required Iterable<HealthRawLocalNotification> notifications, |
| 219 | }) async { | 239 | }) async { |
| 220 | - var record = await _recordStore.read(userId); | ||
| 221 | final sentNotifications = <HealthRawLocalNotification>[]; | 240 | final sentNotifications = <HealthRawLocalNotification>[]; |
| 222 | for (final notification in notifications) { | 241 | for (final notification in notifications) { |
| 223 | - final sent = await _platformApi.sendLocalNotification( | ||
| 224 | - notification.title, | ||
| 225 | - notification.content, | ||
| 226 | - notification.link, | ||
| 227 | - ); | ||
| 228 | - if (!sent) continue; | 242 | + final sent = await sendOne(userId: userId, notification: notification); |
| 243 | + if (!sent) { | ||
| 244 | + continue; | ||
| 245 | + } | ||
| 229 | sentNotifications.add(notification); | 246 | sentNotifications.add(notification); |
| 230 | - record = record.withNotification(notification); | ||
| 231 | - await _recordStore.write(userId, record); | ||
| 232 | } | 247 | } |
| 233 | return sentNotifications; | 248 | return sentNotifications; |
| 234 | } | 249 | } |
| 235 | 250 | ||
| 251 | + Future<bool> sendOne({ | ||
| 252 | + required int userId, | ||
| 253 | + required HealthRawLocalNotification notification, | ||
| 254 | + }) async { | ||
| 255 | + final sent = await _platformApi.sendLocalNotification( | ||
| 256 | + notification.title, | ||
| 257 | + notification.content, | ||
| 258 | + notification.link, | ||
| 259 | + ); | ||
| 260 | + if (!sent) return false; | ||
| 261 | + final record = await _recordStore.read(userId); | ||
| 262 | + await _recordStore.write(userId, record.withNotification(notification)); | ||
| 263 | + return true; | ||
| 264 | + } | ||
| 265 | + | ||
| 236 | Future<HealthRawLocalNotificationRecord> readRecord(int userId) { | 266 | Future<HealthRawLocalNotificationRecord> readRecord(int userId) { |
| 237 | return _recordStore.read(userId); | 267 | return _recordStore.read(userId); |
| 238 | } | 268 | } |
| 269 | + | ||
| 270 | + Future<void> recordRealtimeStressTime({ | ||
| 271 | + required int userId, | ||
| 272 | + required int recordTime, | ||
| 273 | + }) async { | ||
| 274 | + final record = await _recordStore.read(userId); | ||
| 275 | + await _recordStore.write( | ||
| 276 | + userId, | ||
| 277 | + record.copyWith(lastRealtimeStressTime: recordTime), | ||
| 278 | + ); | ||
| 279 | + } | ||
| 239 | } | 280 | } |
| 240 | 281 | ||
| 241 | class HealthRawLocalNotificationRecord { | 282 | class HealthRawLocalNotificationRecord { |
| 1 | +import 'dart:io'; | ||
| 2 | + | ||
| 1 | import 'package:doublefeel_flutter/core/services/health_raw_data_core_service.dart'; | 3 | import 'package:doublefeel_flutter/core/services/health_raw_data_core_service.dart'; |
| 2 | import 'package:doublefeel_flutter/core/services/health_raw_local_notification.dart'; | 4 | import 'package:doublefeel_flutter/core/services/health_raw_local_notification.dart'; |
| 3 | import 'package:doublefeel_flutter/l10n/gen/app_localizations_zh.dart'; | 5 | import 'package:doublefeel_flutter/l10n/gen/app_localizations_zh.dart'; |
| @@ -123,6 +125,66 @@ void main() { | @@ -123,6 +125,66 @@ void main() { | ||
| 123 | 125 | ||
| 124 | expect(notifications, isEmpty); | 126 | expect(notifications, isEmpty); |
| 125 | }); | 127 | }); |
| 128 | + | ||
| 129 | + test('skips realtime stress notification when latest point is workout', () { | ||
| 130 | + final base = _seconds(DateTime(2026, 1, 1, 9)); | ||
| 131 | + final notifications = builder.build( | ||
| 132 | + result: HealthRawStressCalculationResult( | ||
| 133 | + userId: 1, | ||
| 134 | + hrvStressPoints: const [], | ||
| 135 | + realtimeStressPoints: [ | ||
| 136 | + _realtimePoint(base + 9 * 300, 70, isWorkout: true), | ||
| 137 | + ], | ||
| 138 | + dailyStressPoints: const [], | ||
| 139 | + ), | ||
| 140 | + hasExistingHrv: false, | ||
| 141 | + realtimeWindow: [ | ||
| 142 | + for (var i = 0; i < 9; i++) _realtimePoint(base + i * 300, 70), | ||
| 143 | + _realtimePoint(base + 9 * 300, 70, isWorkout: true), | ||
| 144 | + ], | ||
| 145 | + record: const HealthRawLocalNotificationRecord(), | ||
| 146 | + ); | ||
| 147 | + | ||
| 148 | + expect(notifications, isEmpty); | ||
| 149 | + }); | ||
| 150 | + | ||
| 151 | + test('skips realtime stress notification when latest point is recovery', () { | ||
| 152 | + final base = _seconds(DateTime(2026, 1, 1, 9)); | ||
| 153 | + final notifications = builder.build( | ||
| 154 | + result: HealthRawStressCalculationResult( | ||
| 155 | + userId: 1, | ||
| 156 | + hrvStressPoints: const [], | ||
| 157 | + realtimeStressPoints: [ | ||
| 158 | + _realtimePoint(base + 9 * 300, 70, isWorkoutRecovery: true), | ||
| 159 | + ], | ||
| 160 | + dailyStressPoints: const [], | ||
| 161 | + ), | ||
| 162 | + hasExistingHrv: false, | ||
| 163 | + realtimeWindow: [ | ||
| 164 | + for (var i = 0; i < 9; i++) _realtimePoint(base + i * 300, 70), | ||
| 165 | + _realtimePoint(base + 9 * 300, 70, isWorkoutRecovery: true), | ||
| 166 | + ], | ||
| 167 | + record: const HealthRawLocalNotificationRecord(), | ||
| 168 | + ); | ||
| 169 | + | ||
| 170 | + expect(notifications, isEmpty); | ||
| 171 | + }); | ||
| 172 | + | ||
| 173 | + test('records realtime stress push time without sending notification', | ||
| 174 | + () async { | ||
| 175 | + final dir = await Directory.systemTemp.createTemp( | ||
| 176 | + 'health_raw_notification_test_', | ||
| 177 | + ); | ||
| 178 | + addTearDown(() => dir.delete(recursive: true)); | ||
| 179 | + final dispatcher = HealthRawLocalNotificationDispatcher( | ||
| 180 | + recordStore: HealthRawLocalNotificationRecordStore(rootDirectory: dir), | ||
| 181 | + ); | ||
| 182 | + | ||
| 183 | + await dispatcher.recordRealtimeStressTime(userId: 1, recordTime: 1234); | ||
| 184 | + | ||
| 185 | + final record = await dispatcher.readRecord(1); | ||
| 186 | + expect(record.lastRealtimeStressTime, 1234); | ||
| 187 | + }); | ||
| 126 | } | 188 | } |
| 127 | 189 | ||
| 128 | int _seconds(DateTime time) => time.millisecondsSinceEpoch ~/ 1000; | 190 | int _seconds(DateTime time) => time.millisecondsSinceEpoch ~/ 1000; |
| @@ -147,6 +209,8 @@ HealthRawRealtimeStressPoint _realtimePoint( | @@ -147,6 +209,8 @@ HealthRawRealtimeStressPoint _realtimePoint( | ||
| 147 | int rawEndTime, | 209 | int rawEndTime, |
| 148 | double result, { | 210 | double result, { |
| 149 | bool isSleepLikely = false, | 211 | bool isSleepLikely = false, |
| 212 | + bool isWorkout = false, | ||
| 213 | + bool isWorkoutRecovery = false, | ||
| 150 | }) { | 214 | }) { |
| 151 | return HealthRawRealtimeStressPoint( | 215 | return HealthRawRealtimeStressPoint( |
| 152 | userId: 1, | 216 | userId: 1, |
| @@ -157,8 +221,8 @@ HealthRawRealtimeStressPoint _realtimePoint( | @@ -157,8 +221,8 @@ HealthRawRealtimeStressPoint _realtimePoint( | ||
| 157 | sourceEndTime: rawEndTime, | 221 | sourceEndTime: rawEndTime, |
| 158 | flags: HealthRawPointFlags( | 222 | flags: HealthRawPointFlags( |
| 159 | isSleepLikely: isSleepLikely, | 223 | isSleepLikely: isSleepLikely, |
| 160 | - isWorkout: false, | ||
| 161 | - isWorkoutRecovery: false, | 224 | + isWorkout: isWorkout, |
| 225 | + isWorkoutRecovery: isWorkoutRecovery, | ||
| 162 | isSuspectedActivity: false, | 226 | isSuspectedActivity: false, |
| 163 | ), | 227 | ), |
| 164 | ); | 228 | ); |
-
Please register or login to post a comment