Showing
9 changed files
with
931 additions
and
27 deletions
| @@ -214,7 +214,7 @@ final class HealthKitQueryReader { | @@ -214,7 +214,7 @@ final class HealthKitQueryReader { | ||
| 214 | activeEnergyBurnedGoal: summary.activeEnergyBurnedGoal.doubleValue(for: .kilocalorie()), | 214 | activeEnergyBurnedGoal: summary.activeEnergyBurnedGoal.doubleValue(for: .kilocalorie()), |
| 215 | appleMoveTime: Self.appleMoveTimeValue(from: summary), | 215 | appleMoveTime: Self.appleMoveTimeValue(from: summary), |
| 216 | appleMoveTimeGoal: Self.appleMoveTimeGoalValue(from: summary), | 216 | appleMoveTimeGoal: Self.appleMoveTimeGoalValue(from: summary), |
| 217 | - appleExerciseTime: summary.appleExerciseTime.doubleValue(for: .second()), | 217 | + appleExerciseTime: summary.appleExerciseTime.doubleValue(for: .minute()), |
| 218 | exerciseTimeGoal: Self.exerciseTimeGoalValue(from: summary), | 218 | exerciseTimeGoal: Self.exerciseTimeGoalValue(from: summary), |
| 219 | appleStandHours: summary.appleStandHours.doubleValue(for: .count()), | 219 | appleStandHours: summary.appleStandHours.doubleValue(for: .count()), |
| 220 | standHoursGoal: Self.standHoursGoalValue(from: summary) | 220 | standHoursGoal: Self.standHoursGoalValue(from: summary) |
| @@ -244,7 +244,7 @@ final class HealthKitQueryReader { | @@ -244,7 +244,7 @@ final class HealthKitQueryReader { | ||
| 244 | 244 | ||
| 245 | private static func exerciseTimeGoalValue(from summary: HKActivitySummary) -> Double? { | 245 | private static func exerciseTimeGoalValue(from summary: HKActivitySummary) -> Double? { |
| 246 | if #available(iOS 16.0, *) { | 246 | if #available(iOS 16.0, *) { |
| 247 | - return summary.exerciseTimeGoal?.doubleValue(for: .second()) | 247 | + return summary.exerciseTimeGoal?.doubleValue(for: .minute()) |
| 248 | } | 248 | } |
| 249 | return nil | 249 | return nil |
| 250 | } | 250 | } |
| @@ -60,6 +60,7 @@ void registerUserSessionDeps() { | @@ -60,6 +60,7 @@ void registerUserSessionDeps() { | ||
| 60 | Get.put(PushService(Get.find<UserApi>()), permanent: true); | 60 | Get.put(PushService(Get.find<UserApi>()), permanent: true); |
| 61 | Get.put( | 61 | Get.put( |
| 62 | HealthRawDataCoreService( | 62 | HealthRawDataCoreService( |
| 63 | + environmentConfig: Get.find<AppEnvironmentConfig>(), | ||
| 63 | userIdProvider: () => userPrefs.preferences.value.meUserInfo?.id ?? 0, | 64 | userIdProvider: () => userPrefs.preferences.value.meUserInfo?.id ?? 0, |
| 64 | ), | 65 | ), |
| 65 | permanent: true, | 66 | permanent: true, |
| 1 | import 'package:doublefeel_flutter/core/network/api/health_api.dart'; | 1 | import 'package:doublefeel_flutter/core/network/api/health_api.dart'; |
| 2 | +import 'package:doublefeel_flutter/core/services/health_raw_data_core_service.dart'; | ||
| 3 | +import 'package:doublefeel_flutter/core/services/user_state_service.dart'; | ||
| 2 | import 'package:doublefeel_flutter/data/datasource/health/health_datasource.dart'; | 4 | import 'package:doublefeel_flutter/data/datasource/health/health_datasource.dart'; |
| 3 | import 'package:doublefeel_flutter/data/datasource/health/health_datasource_wrapper.dart'; | 5 | import 'package:doublefeel_flutter/data/datasource/health/health_datasource_wrapper.dart'; |
| 4 | import 'package:doublefeel_flutter/data/datasource/health/health_local_datasource.dart'; | 6 | import 'package:doublefeel_flutter/data/datasource/health/health_local_datasource.dart'; |
| @@ -29,12 +31,16 @@ class ActivityBurnReportLogic extends ReportPeriodLogic { | @@ -29,12 +31,16 @@ class ActivityBurnReportLogic extends ReportPeriodLogic { | ||
| 29 | 31 | ||
| 30 | static HealthDataSource _healthDataSource() => HealthDataSourceWrapper( | 32 | static HealthDataSource _healthDataSource() => HealthDataSourceWrapper( |
| 31 | remoteDataSource: RemoteHealthDataSource(Get.find<HealthApi>()), | 33 | remoteDataSource: RemoteHealthDataSource(Get.find<HealthApi>()), |
| 32 | - localDataSource: const LocalHealthDataSource(), | 34 | + localDataSource: LocalHealthDataSource( |
| 35 | + coreService: Get.find<HealthRawDataCoreService>()), | ||
| 33 | ); | 36 | ); |
| 34 | 37 | ||
| 35 | final report = Rxn<ActivityBurnReport>(); | 38 | final report = Rxn<ActivityBurnReport>(); |
| 36 | final weeklyReport = Rxn<WeeklyActivityBurnReport>(); | 39 | final weeklyReport = Rxn<WeeklyActivityBurnReport>(); |
| 37 | final monthlyReport = Rxn<MonthlyActivityBurnReport>(); | 40 | final monthlyReport = Rxn<MonthlyActivityBurnReport>(); |
| 41 | + final _myUserId = Get.find<UserStateService>().userId; | ||
| 42 | + bool get isMySelf => | ||
| 43 | + targetUserId.value == null || _myUserId == targetUserId.value; | ||
| 38 | 44 | ||
| 39 | @override | 45 | @override |
| 40 | List<ReportPeriod> get supportedPeriods => const [ | 46 | List<ReportPeriod> get supportedPeriods => const [ |
| @@ -45,7 +51,8 @@ class ActivityBurnReportLogic extends ReportPeriodLogic { | @@ -45,7 +51,8 @@ class ActivityBurnReportLogic extends ReportPeriodLogic { | ||
| 45 | 51 | ||
| 46 | @override | 52 | @override |
| 47 | Future<void> loadReport() async { | 53 | Future<void> loadReport() async { |
| 48 | - isLoading.value = true; | 54 | + isLoading.value = !isMySelf; |
| 55 | + | ||
| 49 | try { | 56 | try { |
| 50 | if (selectedPeriod.value == ReportPeriod.week) { | 57 | if (selectedPeriod.value == ReportPeriod.week) { |
| 51 | weeklyReport.value = await repository.getWeeklyReport( | 58 | weeklyReport.value = await repository.getWeeklyReport( |
| 1 | import 'package:doublefeel_flutter/core/network/api/health_api.dart'; | 1 | import 'package:doublefeel_flutter/core/network/api/health_api.dart'; |
| 2 | +import 'package:doublefeel_flutter/core/services/health_raw_data_core_service.dart'; | ||
| 3 | +import 'package:doublefeel_flutter/core/services/user_state_service.dart'; | ||
| 2 | import 'package:doublefeel_flutter/data/datasource/health/health_datasource.dart'; | 4 | import 'package:doublefeel_flutter/data/datasource/health/health_datasource.dart'; |
| 3 | import 'package:doublefeel_flutter/data/datasource/health/health_datasource_wrapper.dart'; | 5 | import 'package:doublefeel_flutter/data/datasource/health/health_datasource_wrapper.dart'; |
| 4 | import 'package:doublefeel_flutter/data/datasource/health/health_local_datasource.dart'; | 6 | import 'package:doublefeel_flutter/data/datasource/health/health_local_datasource.dart'; |
| @@ -28,10 +30,14 @@ class HrvReportLogic extends ReportPeriodLogic { | @@ -28,10 +30,14 @@ class HrvReportLogic extends ReportPeriodLogic { | ||
| 28 | final monthlyReport = Rxn<MonthlyHrvReport>(); | 30 | final monthlyReport = Rxn<MonthlyHrvReport>(); |
| 29 | final yearlyReport = Rxn<YearlyHrvReport>(); | 31 | final yearlyReport = Rxn<YearlyHrvReport>(); |
| 30 | final HrvReportRepository repository; | 32 | final HrvReportRepository repository; |
| 33 | + final _myUserId = Get.find<UserStateService>().userId; | ||
| 34 | + bool get isMySelf => | ||
| 35 | + targetUserId.value == null || _myUserId == targetUserId.value; | ||
| 31 | 36 | ||
| 32 | static HealthDataSource _healthDataSource() => HealthDataSourceWrapper( | 37 | static HealthDataSource _healthDataSource() => HealthDataSourceWrapper( |
| 33 | remoteDataSource: RemoteHealthDataSource(Get.find<HealthApi>()), | 38 | remoteDataSource: RemoteHealthDataSource(Get.find<HealthApi>()), |
| 34 | - localDataSource: const LocalHealthDataSource(), | 39 | + localDataSource: LocalHealthDataSource( |
| 40 | + coreService: Get.find<HealthRawDataCoreService>()), | ||
| 35 | ); | 41 | ); |
| 36 | 42 | ||
| 37 | @override | 43 | @override |
| @@ -43,7 +49,8 @@ class HrvReportLogic extends ReportPeriodLogic { | @@ -43,7 +49,8 @@ class HrvReportLogic extends ReportPeriodLogic { | ||
| 43 | 49 | ||
| 44 | @override | 50 | @override |
| 45 | Future<void> loadReport() async { | 51 | Future<void> loadReport() async { |
| 46 | - isLoading.value = true; | 52 | + isLoading.value = !isMySelf; |
| 53 | + | ||
| 47 | try { | 54 | try { |
| 48 | if (selectedPeriod.value == ReportPeriod.year) { | 55 | if (selectedPeriod.value == ReportPeriod.year) { |
| 49 | yearlyReport.value = await repository.getYearlyReport( | 56 | yearlyReport.value = await repository.getYearlyReport( |
| 1 | import 'package:doublefeel_flutter/core/network/api/health_api.dart'; | 1 | import 'package:doublefeel_flutter/core/network/api/health_api.dart'; |
| 2 | +import 'package:doublefeel_flutter/core/services/health_raw_data_core_service.dart'; | ||
| 3 | +import 'package:doublefeel_flutter/core/services/user_state_service.dart'; | ||
| 2 | import 'package:doublefeel_flutter/data/datasource/health/health_datasource.dart'; | 4 | import 'package:doublefeel_flutter/data/datasource/health/health_datasource.dart'; |
| 3 | import 'package:doublefeel_flutter/data/datasource/health/health_datasource_wrapper.dart'; | 5 | import 'package:doublefeel_flutter/data/datasource/health/health_datasource_wrapper.dart'; |
| 4 | import 'package:doublefeel_flutter/data/datasource/health/health_local_datasource.dart'; | 6 | import 'package:doublefeel_flutter/data/datasource/health/health_local_datasource.dart'; |
| @@ -28,12 +30,16 @@ class SleepReportLogic extends ReportPeriodLogic { | @@ -28,12 +30,16 @@ class SleepReportLogic extends ReportPeriodLogic { | ||
| 28 | 30 | ||
| 29 | static HealthDataSource _healthDataSource() => HealthDataSourceWrapper( | 31 | static HealthDataSource _healthDataSource() => HealthDataSourceWrapper( |
| 30 | remoteDataSource: RemoteHealthDataSource(Get.find<HealthApi>()), | 32 | remoteDataSource: RemoteHealthDataSource(Get.find<HealthApi>()), |
| 31 | - localDataSource: const LocalHealthDataSource(), | 33 | + localDataSource: LocalHealthDataSource( |
| 34 | + coreService: Get.find<HealthRawDataCoreService>()), | ||
| 32 | ); | 35 | ); |
| 33 | 36 | ||
| 34 | final report = Rxn<SleepReport>(); | 37 | final report = Rxn<SleepReport>(); |
| 35 | final weeklyReport = Rxn<WeeklySleepReport>(); | 38 | final weeklyReport = Rxn<WeeklySleepReport>(); |
| 36 | final monthlyReport = Rxn<MonthlySleepReport>(); | 39 | final monthlyReport = Rxn<MonthlySleepReport>(); |
| 40 | + final _myUserId = Get.find<UserStateService>().userId; | ||
| 41 | + bool get isMySelf => | ||
| 42 | + targetUserId.value == null || _myUserId == targetUserId.value; | ||
| 37 | 43 | ||
| 38 | @override | 44 | @override |
| 39 | List<ReportPeriod> get supportedPeriods => const [ | 45 | List<ReportPeriod> get supportedPeriods => const [ |
| @@ -54,7 +60,7 @@ class SleepReportLogic extends ReportPeriodLogic { | @@ -54,7 +60,7 @@ class SleepReportLogic extends ReportPeriodLogic { | ||
| 54 | } | 60 | } |
| 55 | 61 | ||
| 56 | Future<void> loadDailyReport() async { | 62 | Future<void> loadDailyReport() async { |
| 57 | - isLoading.value = true; | 63 | + isLoading.value = !isMySelf; |
| 58 | try { | 64 | try { |
| 59 | report.value = await repository.getDailyReport( | 65 | report.value = await repository.getDailyReport( |
| 60 | selectedDate.value, | 66 | selectedDate.value, |
| @@ -66,7 +72,7 @@ class SleepReportLogic extends ReportPeriodLogic { | @@ -66,7 +72,7 @@ class SleepReportLogic extends ReportPeriodLogic { | ||
| 66 | } | 72 | } |
| 67 | 73 | ||
| 68 | Future<void> loadWeeklyReport() async { | 74 | Future<void> loadWeeklyReport() async { |
| 69 | - isLoading.value = true; | 75 | + isLoading.value = !isMySelf; |
| 70 | try { | 76 | try { |
| 71 | weeklyReport.value = await repository.getWeeklyReport( | 77 | weeklyReport.value = await repository.getWeeklyReport( |
| 72 | weekStart, | 78 | weekStart, |
| @@ -78,7 +84,7 @@ class SleepReportLogic extends ReportPeriodLogic { | @@ -78,7 +84,7 @@ class SleepReportLogic extends ReportPeriodLogic { | ||
| 78 | } | 84 | } |
| 79 | 85 | ||
| 80 | Future<void> loadMonthlyReport() async { | 86 | Future<void> loadMonthlyReport() async { |
| 81 | - isLoading.value = true; | 87 | + isLoading.value = !isMySelf; |
| 82 | monthlyReport.value = null; | 88 | monthlyReport.value = null; |
| 83 | try { | 89 | try { |
| 84 | monthlyReport.value = await repository.getMonthlyReport( | 90 | monthlyReport.value = await repository.getMonthlyReport( |
| @@ -7,23 +7,32 @@ import 'package:path_provider/path_provider.dart'; | @@ -7,23 +7,32 @@ import 'package:path_provider/path_provider.dart'; | ||
| 7 | import 'package:sqflite/sqflite.dart'; | 7 | import 'package:sqflite/sqflite.dart'; |
| 8 | 8 | ||
| 9 | import '../../data/models/enums/app_enums.dart'; | 9 | import '../../data/models/enums/app_enums.dart'; |
| 10 | +import '../../pigeon/health_kit_api.g.dart'; | ||
| 10 | import '../../pigeon/health_kit_raw_data_api.g.dart'; | 11 | import '../../pigeon/health_kit_raw_data_api.g.dart'; |
| 12 | +import '../config/app_environment_config.dart'; | ||
| 11 | import '../logging/app_logger.dart'; | 13 | import '../logging/app_logger.dart'; |
| 14 | +import '../util/app_toast.dart'; | ||
| 12 | 15 | ||
| 13 | class HealthRawDataCoreService { | 16 | class HealthRawDataCoreService { |
| 14 | static const int defaultLookbackDays = 183; | 17 | static const int defaultLookbackDays = 183; |
| 15 | static const int defaultReadChunkDays = 7; | 18 | static const int defaultReadChunkDays = 7; |
| 16 | 19 | ||
| 17 | HealthRawDataCoreService({ | 20 | HealthRawDataCoreService({ |
| 21 | + HealthKitHostApi? healthApi, | ||
| 18 | HealthKitRawDataHostApi? rawDataApi, | 22 | HealthKitRawDataHostApi? rawDataApi, |
| 19 | HealthRawStressLocalStore? localStore, | 23 | HealthRawStressLocalStore? localStore, |
| 24 | + AppEnvironmentConfig? environmentConfig, | ||
| 20 | int Function()? userIdProvider, | 25 | int Function()? userIdProvider, |
| 21 | - }) : _rawDataApi = rawDataApi ?? HealthKitRawDataHostApi(), | 26 | + }) : _healthApi = healthApi ?? HealthKitHostApi(), |
| 27 | + _rawDataApi = rawDataApi ?? HealthKitRawDataHostApi(), | ||
| 22 | _localStore = localStore ?? HealthRawStressLocalStore(), | 28 | _localStore = localStore ?? HealthRawStressLocalStore(), |
| 29 | + _environmentConfig = environmentConfig, | ||
| 23 | _userIdProvider = userIdProvider; | 30 | _userIdProvider = userIdProvider; |
| 24 | 31 | ||
| 32 | + final HealthKitHostApi _healthApi; | ||
| 25 | final HealthKitRawDataHostApi _rawDataApi; | 33 | final HealthKitRawDataHostApi _rawDataApi; |
| 26 | final HealthRawStressLocalStore _localStore; | 34 | final HealthRawStressLocalStore _localStore; |
| 35 | + final AppEnvironmentConfig? _environmentConfig; | ||
| 27 | final int Function()? _userIdProvider; | 36 | final int Function()? _userIdProvider; |
| 28 | 37 | ||
| 29 | int get _userId { | 38 | int get _userId { |
| @@ -108,8 +117,16 @@ class HealthRawDataCoreService { | @@ -108,8 +117,16 @@ class HealthRawDataCoreService { | ||
| 108 | throw ArgumentError.value(readChunkDays, 'readChunkDays'); | 117 | throw ArgumentError.value(readChunkDays, 'readChunkDays'); |
| 109 | } | 118 | } |
| 110 | 119 | ||
| 111 | - final totalWatch = Stopwatch()..start(); | ||
| 112 | final userId = _userId; | 120 | final userId = _userId; |
| 121 | + final hasAuthorization = await _hasHealthReadAuthorization(); | ||
| 122 | + if (!hasAuthorization) { | ||
| 123 | + return HealthRawStressCalculationResult( | ||
| 124 | + userId: userId, | ||
| 125 | + hrvStressPoints: const <HealthRawHrvStressPoint>[], | ||
| 126 | + realtimeStressPoints: const <HealthRawRealtimeStressPoint>[], | ||
| 127 | + ); | ||
| 128 | + } | ||
| 129 | + final totalWatch = Stopwatch()..start(); | ||
| 113 | await _localStore.ensureReadable(userId); | 130 | await _localStore.ensureReadable(userId); |
| 114 | final readWatch = Stopwatch()..start(); | 131 | final readWatch = Stopwatch()..start(); |
| 115 | final effectiveEndTime = | 132 | final effectiveEndTime = |
| @@ -127,6 +144,8 @@ class HealthRawDataCoreService { | @@ -127,6 +144,8 @@ class HealthRawDataCoreService { | ||
| 127 | final hrvContextStart = await _localStore.latestHrvSourceStartTime(userId); | 144 | final hrvContextStart = await _localStore.latestHrvSourceStartTime(userId); |
| 128 | final realtimeContextStart = | 145 | final realtimeContextStart = |
| 129 | await _localStore.latestRealtimeSourceStartTime(userId); | 146 | await _localStore.latestRealtimeSourceStartTime(userId); |
| 147 | + final isFirstCalculation = | ||
| 148 | + hrvContextStart == null && realtimeContextStart == null; | ||
| 130 | final hrvStartTime = math.max( | 149 | final hrvStartTime = math.max( |
| 131 | hrvContextStart ?? requestedStartTime, | 150 | hrvContextStart ?? requestedStartTime, |
| 132 | earliestStartTime, | 151 | earliestStartTime, |
| @@ -190,6 +209,11 @@ class HealthRawDataCoreService { | @@ -190,6 +209,11 @@ class HealthRawDataCoreService { | ||
| 190 | await _localStore.upsertResult(result); | 209 | await _localStore.upsertResult(result); |
| 191 | calculateAndStoreWatch.stop(); | 210 | calculateAndStoreWatch.stop(); |
| 192 | totalWatch.stop(); | 211 | totalWatch.stop(); |
| 212 | + if (isFirstCalculation && (_environmentConfig?.isDebug ?? false)) { | ||
| 213 | + AppToast.show( | ||
| 214 | + '首次计算完成, 总耗时${_formatDurationMinutesSeconds(totalWatch.elapsed)}', | ||
| 215 | + ); | ||
| 216 | + } | ||
| 193 | 217 | ||
| 194 | _logInfo( | 218 | _logInfo( |
| 195 | 'timing userId=$userId ' | 219 | 'timing userId=$userId ' |
| @@ -214,6 +238,11 @@ class HealthRawDataCoreService { | @@ -214,6 +238,11 @@ class HealthRawDataCoreService { | ||
| 214 | return result; | 238 | return result; |
| 215 | } | 239 | } |
| 216 | 240 | ||
| 241 | + Future<bool> _hasHealthReadAuthorization() async { | ||
| 242 | + final authorization = await _healthApi.checkHealthAppAuthorization(); | ||
| 243 | + return authorization.status == 1; | ||
| 244 | + } | ||
| 245 | + | ||
| 217 | Stream<HealthKitRawDataPoint> streamRawData({ | 246 | Stream<HealthKitRawDataPoint> streamRawData({ |
| 218 | required int dataType, | 247 | required int dataType, |
| 219 | required int startTime, | 248 | required int startTime, |
| @@ -307,6 +336,55 @@ class HealthRawDataCoreService { | @@ -307,6 +336,55 @@ class HealthRawDataCoreService { | ||
| 307 | ); | 336 | ); |
| 308 | } | 337 | } |
| 309 | 338 | ||
| 339 | + Future<List<HealthKitRawDataPoint>> queryRawDataPoints({ | ||
| 340 | + required int dataType, | ||
| 341 | + required int startTime, | ||
| 342 | + required int endTime, | ||
| 343 | + int readChunkDays = defaultReadChunkDays, | ||
| 344 | + }) async { | ||
| 345 | + if (!await _hasHealthReadAuthorization()) { | ||
| 346 | + return const <HealthKitRawDataPoint>[]; | ||
| 347 | + } | ||
| 348 | + return _fetchRawDataInChunks( | ||
| 349 | + dataType, | ||
| 350 | + startTime, | ||
| 351 | + endTime, | ||
| 352 | + readChunkDays: readChunkDays, | ||
| 353 | + ); | ||
| 354 | + } | ||
| 355 | + | ||
| 356 | + Future<List<HealthKitRawDataPoint>> queryRawSleepIntervals({ | ||
| 357 | + required int startTime, | ||
| 358 | + required int endTime, | ||
| 359 | + int readChunkDays = defaultReadChunkDays, | ||
| 360 | + }) async { | ||
| 361 | + if (!await _hasHealthReadAuthorization()) { | ||
| 362 | + return const <HealthKitRawDataPoint>[]; | ||
| 363 | + } | ||
| 364 | + return _fetchSleepIntervalsInChunks( | ||
| 365 | + startTime, | ||
| 366 | + endTime, | ||
| 367 | + readChunkDays: readChunkDays, | ||
| 368 | + ); | ||
| 369 | + } | ||
| 370 | + | ||
| 371 | + Future<List<HealthKitRawActivityDataPoint>> queryRawActivitySummaries({ | ||
| 372 | + required int startTime, | ||
| 373 | + required int endTime, | ||
| 374 | + }) async { | ||
| 375 | + if (!await _hasHealthReadAuthorization()) { | ||
| 376 | + return const <HealthKitRawActivityDataPoint>[]; | ||
| 377 | + } | ||
| 378 | + final points = await _rawDataApi.getHealthKitRawActivityData( | ||
| 379 | + startTime, | ||
| 380 | + endTime, | ||
| 381 | + ); | ||
| 382 | + return points | ||
| 383 | + .where((e) => e.endTime >= startTime && e.endTime <= endTime) | ||
| 384 | + .toList() | ||
| 385 | + ..sort((a, b) => a.endTime.compareTo(b.endTime)); | ||
| 386 | + } | ||
| 387 | + | ||
| 310 | Future<void> markHrvStressUploaded({ | 388 | Future<void> markHrvStressUploaded({ |
| 311 | required Iterable<int> rawEndTimes, | 389 | required Iterable<int> rawEndTimes, |
| 312 | }) { | 390 | }) { |
| @@ -963,6 +1041,12 @@ class HealthRawDataCoreService { | @@ -963,6 +1041,12 @@ class HealthRawDataCoreService { | ||
| 963 | .toIso8601String(); | 1041 | .toIso8601String(); |
| 964 | } | 1042 | } |
| 965 | 1043 | ||
| 1044 | + static String _formatDurationMinutesSeconds(Duration duration) { | ||
| 1045 | + final minutes = duration.inMinutes; | ||
| 1046 | + final seconds = duration.inSeconds.remainder(60); | ||
| 1047 | + return '$minutes分钟$seconds秒'; | ||
| 1048 | + } | ||
| 1049 | + | ||
| 966 | static void _logInfo(String message) { | 1050 | static void _logInfo(String message) { |
| 967 | final tagged = 'HealthRawDataCoreService $message'; | 1051 | final tagged = 'HealthRawDataCoreService $message'; |
| 968 | developer.log(tagged, name: 'HealthRawDataCoreService'); | 1052 | developer.log(tagged, name: 'HealthRawDataCoreService'); |
| 1 | import 'package:doublefeel_flutter/core/result/app_result.dart'; | 1 | import 'package:doublefeel_flutter/core/result/app_result.dart'; |
| 2 | +import 'package:doublefeel_flutter/core/services/user_state_service.dart'; | ||
| 2 | import 'package:doublefeel_flutter/data/models/health/activity/activity_burn_statistics_data_v2.dart'; | 3 | import 'package:doublefeel_flutter/data/models/health/activity/activity_burn_statistics_data_v2.dart'; |
| 3 | import 'package:doublefeel_flutter/data/models/health/hrv/hrv_statistics_data.dart'; | 4 | import 'package:doublefeel_flutter/data/models/health/hrv/hrv_statistics_data.dart'; |
| 4 | import 'package:doublefeel_flutter/data/models/health/sleep/sleep_statistics_data.dart'; | 5 | import 'package:doublefeel_flutter/data/models/health/sleep/sleep_statistics_data.dart'; |
| 6 | +import 'package:get/get_core/src/get_main.dart'; | ||
| 7 | +import 'package:get/get_instance/src/extension_instance.dart'; | ||
| 5 | 8 | ||
| 6 | import 'health_datasource.dart'; | 9 | import 'health_datasource.dart'; |
| 7 | 10 | ||
| 8 | /// Routes each report type to its current source of truth. | 11 | /// Routes each report type to its current source of truth. |
| 9 | class HealthDataSourceWrapper implements HealthDataSource { | 12 | class HealthDataSourceWrapper implements HealthDataSource { |
| 10 | - const HealthDataSourceWrapper({ | 13 | + HealthDataSourceWrapper({ |
| 11 | required this.remoteDataSource, | 14 | required this.remoteDataSource, |
| 12 | required this.localDataSource, | 15 | required this.localDataSource, |
| 13 | }); | 16 | }); |
| 14 | 17 | ||
| 15 | final HealthDataSource remoteDataSource; | 18 | final HealthDataSource remoteDataSource; |
| 16 | final HealthDataSource localDataSource; | 19 | final HealthDataSource localDataSource; |
| 20 | + final _myUserId = Get.find<UserStateService>().userId; | ||
| 17 | 21 | ||
| 18 | @override | 22 | @override |
| 19 | Future<AppResult<SleepStatisticsData>> getSleepStatistics( | 23 | Future<AppResult<SleepStatisticsData>> getSleepStatistics( |
| @@ -21,7 +25,12 @@ class HealthDataSourceWrapper implements HealthDataSource { | @@ -21,7 +25,12 @@ class HealthDataSourceWrapper implements HealthDataSource { | ||
| 21 | int startDate, { | 25 | int startDate, { |
| 22 | int? queryUserId, | 26 | int? queryUserId, |
| 23 | }) { | 27 | }) { |
| 24 | - return remoteDataSource.getSleepStatistics( | 28 | + HealthDataSource dataSource = remoteDataSource; |
| 29 | + if (queryUserId == null || _myUserId == queryUserId) { | ||
| 30 | + dataSource = localDataSource; | ||
| 31 | + } | ||
| 32 | + | ||
| 33 | + return dataSource.getSleepStatistics( | ||
| 25 | dateRangeType, | 34 | dateRangeType, |
| 26 | startDate, | 35 | startDate, |
| 27 | queryUserId: queryUserId, | 36 | queryUserId: queryUserId, |
| @@ -34,7 +43,12 @@ class HealthDataSourceWrapper implements HealthDataSource { | @@ -34,7 +43,12 @@ class HealthDataSourceWrapper implements HealthDataSource { | ||
| 34 | int startDate, { | 43 | int startDate, { |
| 35 | int? queryUserId, | 44 | int? queryUserId, |
| 36 | }) { | 45 | }) { |
| 37 | - return localDataSource.getActivityBurnStatistics( | 46 | + HealthDataSource dataSource = remoteDataSource; |
| 47 | + if (queryUserId == null || _myUserId == queryUserId) { | ||
| 48 | + dataSource = localDataSource; | ||
| 49 | + } | ||
| 50 | + | ||
| 51 | + return dataSource.getActivityBurnStatistics( | ||
| 38 | dateRangeType, | 52 | dateRangeType, |
| 39 | startDate, | 53 | startDate, |
| 40 | queryUserId: queryUserId, | 54 | queryUserId: queryUserId, |
| @@ -47,7 +61,12 @@ class HealthDataSourceWrapper implements HealthDataSource { | @@ -47,7 +61,12 @@ class HealthDataSourceWrapper implements HealthDataSource { | ||
| 47 | int startDate, { | 61 | int startDate, { |
| 48 | int? queryUserId, | 62 | int? queryUserId, |
| 49 | }) { | 63 | }) { |
| 50 | - return localDataSource.getHrvStatistics( | 64 | + HealthDataSource dataSource = remoteDataSource; |
| 65 | + if (queryUserId == null || _myUserId == queryUserId) { | ||
| 66 | + dataSource = localDataSource; | ||
| 67 | + } | ||
| 68 | + | ||
| 69 | + return dataSource.getHrvStatistics( | ||
| 51 | dateRangeType, | 70 | dateRangeType, |
| 52 | startDate, | 71 | startDate, |
| 53 | queryUserId: queryUserId, | 72 | queryUserId: queryUserId, |
| 1 | +import 'dart:math' as math; | ||
| 2 | + | ||
| 3 | +import 'package:doublefeel_flutter/core/error/app_error.dart'; | ||
| 1 | import 'package:doublefeel_flutter/core/result/app_result.dart'; | 4 | import 'package:doublefeel_flutter/core/result/app_result.dart'; |
| 5 | +import 'package:doublefeel_flutter/core/services/health_raw_data_core_service.dart'; | ||
| 6 | +import 'package:doublefeel_flutter/data/models/enums/app_enums.dart'; | ||
| 2 | import 'package:doublefeel_flutter/data/models/health/activity/activity_burn_statistics_data_v2.dart'; | 7 | import 'package:doublefeel_flutter/data/models/health/activity/activity_burn_statistics_data_v2.dart'; |
| 3 | import 'package:doublefeel_flutter/data/models/health/hrv/hrv_statistics_data.dart'; | 8 | import 'package:doublefeel_flutter/data/models/health/hrv/hrv_statistics_data.dart'; |
| 4 | import 'package:doublefeel_flutter/data/models/health/sleep/sleep_statistics_data.dart'; | 9 | import 'package:doublefeel_flutter/data/models/health/sleep/sleep_statistics_data.dart'; |
| 10 | +import 'package:doublefeel_flutter/pigeon/health_kit_raw_data_api.g.dart'; | ||
| 5 | 11 | ||
| 6 | import 'health_datasource.dart'; | 12 | import 'health_datasource.dart'; |
| 7 | 13 | ||
| 8 | -/// Placeholder for the local health-data store. | 14 | +const _sleepTypeInBed = 0; |
| 15 | +const _sleepTypeAsleepUnspecified = 1; | ||
| 16 | +const _sleepTypeAwake = 2; | ||
| 17 | +const _sleepTypeAsleepCore = 3; | ||
| 18 | +const _sleepTypeAsleepDeep = 4; | ||
| 19 | +const _sleepTypeAsleepRem = 5; | ||
| 20 | +const _sleepGoalMinutes = 8 * 60.0; | ||
| 21 | + | ||
| 9 | class LocalHealthDataSource implements HealthDataSource { | 22 | class LocalHealthDataSource implements HealthDataSource { |
| 10 | - const LocalHealthDataSource(); | 23 | + const LocalHealthDataSource({required this.coreService}); |
| 11 | 24 | ||
| 12 | - Never _notImplemented() => throw UnimplementedError( | ||
| 13 | - 'Local health data source has not been implemented.', | ||
| 14 | - ); | 25 | + final HealthRawDataCoreService coreService; |
| 15 | 26 | ||
| 16 | @override | 27 | @override |
| 17 | Future<AppResult<SleepStatisticsData>> getSleepStatistics( | 28 | Future<AppResult<SleepStatisticsData>> getSleepStatistics( |
| 18 | int dateRangeType, | 29 | int dateRangeType, |
| 19 | int startDate, { | 30 | int startDate, { |
| 20 | int? queryUserId, | 31 | int? queryUserId, |
| 21 | - }) => | ||
| 22 | - _notImplemented(); | 32 | + }) async { |
| 33 | + try { | ||
| 34 | + final days = _rangeDays(dateRangeType, startDate); | ||
| 35 | + if (days.isEmpty) return AppSuccess(SleepStatisticsData()); | ||
| 36 | + final previousDays = _previousRangeDays(dateRangeType, startDate); | ||
| 37 | + final allDays = [...previousDays, ...days]; | ||
| 38 | + final queryStart = _unixSeconds( | ||
| 39 | + allDays.first.subtract(const Duration(hours: 12)), | ||
| 40 | + ); | ||
| 41 | + final queryEnd = _unixSeconds( | ||
| 42 | + allDays.last.add(const Duration(days: 1, hours: 12)), | ||
| 43 | + ); | ||
| 44 | + final sleepIntervals = await coreService.queryRawSleepIntervals( | ||
| 45 | + startTime: queryStart, | ||
| 46 | + endTime: queryEnd, | ||
| 47 | + ); | ||
| 48 | + final heartRate = await coreService.queryRawDataPoints( | ||
| 49 | + dataType: HealthDataUploadType.heartRate.type, | ||
| 50 | + startTime: queryStart, | ||
| 51 | + endTime: queryEnd, | ||
| 52 | + ); | ||
| 53 | + | ||
| 54 | + final daily = [ | ||
| 55 | + for (final day in days) | ||
| 56 | + _sleepDaySummary( | ||
| 57 | + day, | ||
| 58 | + sleepIntervals, | ||
| 59 | + heartRate, | ||
| 60 | + ), | ||
| 61 | + ]; | ||
| 62 | + final validSleep = daily.where((e) => e.durationSeconds > 0).toList(); | ||
| 63 | + final previousDaily = [ | ||
| 64 | + for (final day in previousDays) | ||
| 65 | + _sleepDaySummary( | ||
| 66 | + day, | ||
| 67 | + sleepIntervals, | ||
| 68 | + heartRate, | ||
| 69 | + ), | ||
| 70 | + ]; | ||
| 71 | + final previousValidSleep = | ||
| 72 | + previousDaily.where((e) => e.durationSeconds > 0).toList(); | ||
| 73 | + final allSleepHr = validSleep.expand((e) => e.sleepHr).toList(); | ||
| 74 | + | ||
| 75 | + return AppSuccess( | ||
| 76 | + SleepStatisticsData( | ||
| 77 | + avgSleepDuration: _averageOrNull( | ||
| 78 | + validSleep.map((e) => e.durationSeconds).toList(), | ||
| 79 | + ), | ||
| 80 | + avgSleepScore: _averageOrNull( | ||
| 81 | + validSleep.map((e) => e.score).whereType<num>().toList(), | ||
| 82 | + ), | ||
| 83 | + avgSleepEvaluate: _averageOrNull( | ||
| 84 | + validSleep.map((e) => e.evaluate).whereType<num>().toList(), | ||
| 85 | + ), | ||
| 86 | + qoqAvgSleepDuration: _averageOrNull( | ||
| 87 | + previousValidSleep.map((e) => e.durationSeconds).toList(), | ||
| 88 | + ), | ||
| 89 | + qoqSleepScore: _averageOrNull( | ||
| 90 | + previousValidSleep.map((e) => e.score).whereType<num>().toList(), | ||
| 91 | + ), | ||
| 92 | + qoqSleepEvaluate: _averageOrNull( | ||
| 93 | + previousValidSleep.map((e) => e.evaluate).whereType<num>().toList(), | ||
| 94 | + ), | ||
| 95 | + sleepTrendList: [ | ||
| 96 | + for (final item in daily) | ||
| 97 | + SleepTrendList( | ||
| 98 | + timeKey: _dateKey(item.day), | ||
| 99 | + totalTime: item.durationSeconds, | ||
| 100 | + score: item.score, | ||
| 101 | + sleepEvaluate: item.evaluate, | ||
| 102 | + ), | ||
| 103 | + ], | ||
| 104 | + asleepTimeTrendList: [ | ||
| 105 | + for (final item in daily) | ||
| 106 | + AsleepTimeTrendList( | ||
| 107 | + timeKey: _dateKey(item.day), | ||
| 108 | + asleepTime: item.asleepTime, | ||
| 109 | + ), | ||
| 110 | + ], | ||
| 111 | + bestSleepInfo: _bestSleepInfo(validSleep), | ||
| 112 | + worstSleepInfo: _worstSleepInfo(validSleep), | ||
| 113 | + earliestSleepInfo: _earliestSleepInfo(validSleep), | ||
| 114 | + latestSleepInfo: _latestSleepInfo(validSleep), | ||
| 115 | + hrList: [ | ||
| 116 | + for (final point in allSleepHr) | ||
| 117 | + SleepHrItem(time: point.endTime, value: point.value), | ||
| 118 | + ], | ||
| 119 | + avgHr: _averageOrNull( | ||
| 120 | + allSleepHr.map((e) => e.value).whereType<num>().toList(), | ||
| 121 | + ), | ||
| 122 | + maxHr: _maxOrNull( | ||
| 123 | + allSleepHr.map((e) => e.value).whereType<num>().toList(), | ||
| 124 | + ), | ||
| 125 | + minHr: _minOrNull( | ||
| 126 | + allSleepHr.map((e) => e.value).whereType<num>().toList(), | ||
| 127 | + ), | ||
| 128 | + ), | ||
| 129 | + ); | ||
| 130 | + } catch (error) { | ||
| 131 | + return AppFailure(AppUnknownError(error)); | ||
| 132 | + } | ||
| 133 | + } | ||
| 23 | 134 | ||
| 24 | @override | 135 | @override |
| 25 | Future<AppResult<ActivityBurnStatisticsDataV2>> getActivityBurnStatistics( | 136 | Future<AppResult<ActivityBurnStatisticsDataV2>> getActivityBurnStatistics( |
| 26 | int dateRangeType, | 137 | int dateRangeType, |
| 27 | int startDate, { | 138 | int startDate, { |
| 28 | int? queryUserId, | 139 | int? queryUserId, |
| 29 | - }) => | ||
| 30 | - _notImplemented(); | 140 | + }) async { |
| 141 | + try { | ||
| 142 | + final days = _rangeDays(dateRangeType, startDate); | ||
| 143 | + if (days.isEmpty) return AppSuccess(ActivityBurnStatisticsDataV2()); | ||
| 144 | + final previousDays = _previousRangeDays(dateRangeType, startDate); | ||
| 145 | + final allDays = [...previousDays, ...days]; | ||
| 146 | + final queryStart = _unixSeconds(allDays.first); | ||
| 147 | + final queryEnd = _unixSeconds(days.last.add(const Duration(days: 1))); | ||
| 148 | + final activity = await coreService.queryRawActivitySummaries( | ||
| 149 | + startTime: queryStart, | ||
| 150 | + endTime: queryEnd, | ||
| 151 | + ); | ||
| 152 | + final heartRate = await coreService.queryRawDataPoints( | ||
| 153 | + dataType: HealthDataUploadType.heartRate.type, | ||
| 154 | + startTime: queryStart, | ||
| 155 | + endTime: queryEnd, | ||
| 156 | + ); | ||
| 157 | + final sleepIntervals = await coreService.queryRawSleepIntervals( | ||
| 158 | + startTime: queryStart, | ||
| 159 | + endTime: queryEnd, | ||
| 160 | + ); | ||
| 161 | + final daily = [ | ||
| 162 | + for (final day in days) | ||
| 163 | + _activityDaySummary( | ||
| 164 | + day, | ||
| 165 | + activity, | ||
| 166 | + ), | ||
| 167 | + ]; | ||
| 168 | + final previousDaily = [ | ||
| 169 | + for (final day in previousDays) | ||
| 170 | + _activityDaySummary( | ||
| 171 | + day, | ||
| 172 | + activity, | ||
| 173 | + ), | ||
| 174 | + ]; | ||
| 175 | + final latestWithGoal = _latestActivitySummaryWithGoal(daily); | ||
| 176 | + | ||
| 177 | + final data = ActivityBurnStatisticsDataV2( | ||
| 178 | + totalMove: _sum(daily.map((e) => e.move).toList()), | ||
| 179 | + totalSteps: _sum(daily.map((e) => e.steps).toList()), | ||
| 180 | + totalStand: _sum(daily.map((e) => e.standHours).toList()), | ||
| 181 | + totalExercise: _sum(daily.map((e) => e.exerciseSeconds).toList()), | ||
| 182 | + avgMove: _averageOrNull(daily.map((e) => e.move).toList()), | ||
| 183 | + qoqAvgMove: _averageOrNull(previousDaily.map((e) => e.move).toList()), | ||
| 184 | + activityTargetInfo: _activityTargetInfo(latestWithGoal), | ||
| 185 | + moveTrendList: [ | ||
| 186 | + for (final item in daily) | ||
| 187 | + MoveTrendList( | ||
| 188 | + timeKey: _dateKey(item.day).toString(), | ||
| 189 | + value: item.move, | ||
| 190 | + ), | ||
| 191 | + ], | ||
| 192 | + overallList: [ | ||
| 193 | + for (final item in daily) | ||
| 194 | + OverallList( | ||
| 195 | + timeKey: _dateKey(item.day), | ||
| 196 | + value: Value( | ||
| 197 | + move: item.move, | ||
| 198 | + stand: item.standHours, | ||
| 199 | + exercise: item.exerciseSeconds, | ||
| 200 | + ), | ||
| 201 | + ), | ||
| 202 | + ], | ||
| 203 | + hrList: [ | ||
| 204 | + for (final point in heartRate) | ||
| 205 | + HrList( | ||
| 206 | + dataTime: point.endTime, | ||
| 207 | + date: _dateKey(_dayOf(point.endTime)), | ||
| 208 | + dataType: HealthDataUploadType.heartRate.type, | ||
| 209 | + value: point.value, | ||
| 210 | + isAsleep: _isInIntervals(point.endTime, sleepIntervals) ? 1 : 0, | ||
| 211 | + ), | ||
| 212 | + ], | ||
| 213 | + sleepTimeList: [ | ||
| 214 | + for (final interval in sleepIntervals) | ||
| 215 | + SleepTimeList( | ||
| 216 | + fromTime: interval.startTime, | ||
| 217 | + toTime: interval.endTime, | ||
| 218 | + ), | ||
| 219 | + ], | ||
| 220 | + )..maxHr = _maxOrNull( | ||
| 221 | + heartRate.map((e) => e.value).whereType<num>().toList(), | ||
| 222 | + ); | ||
| 223 | + return AppSuccess(data); | ||
| 224 | + } catch (error) { | ||
| 225 | + return AppFailure(AppUnknownError(error)); | ||
| 226 | + } | ||
| 227 | + } | ||
| 31 | 228 | ||
| 32 | @override | 229 | @override |
| 33 | Future<AppResult<HrvStatisticsDataV2>> getHrvStatistics( | 230 | Future<AppResult<HrvStatisticsDataV2>> getHrvStatistics( |
| 34 | int dateRangeType, | 231 | int dateRangeType, |
| 35 | int startDate, { | 232 | int startDate, { |
| 36 | int? queryUserId, | 233 | int? queryUserId, |
| 37 | - }) => | ||
| 38 | - _notImplemented(); | 234 | + }) async { |
| 235 | + try { | ||
| 236 | + final days = _rangeDays(dateRangeType, startDate); | ||
| 237 | + if (days.isEmpty) return AppSuccess(HrvStatisticsDataV2()); | ||
| 238 | + final queryStart = _unixSeconds(days.first); | ||
| 239 | + final queryEnd = _unixSeconds(days.last.add(const Duration(days: 1))); | ||
| 240 | + final hrvPoints = await coreService.queryHrvStressPoints( | ||
| 241 | + startTime: queryStart, | ||
| 242 | + endTime: queryEnd, | ||
| 243 | + ); | ||
| 244 | + final realtimePoints = await coreService.queryRealtimeStressPoints( | ||
| 245 | + startTime: queryStart, | ||
| 246 | + endTime: queryEnd, | ||
| 247 | + ); | ||
| 248 | + | ||
| 249 | + final daily = [ | ||
| 250 | + for (final day in days) _hrvDaySummary(day, hrvPoints, realtimePoints), | ||
| 251 | + ]; | ||
| 252 | + final validDaily = daily.where((e) => e.hrvAverage != null).toList(); | ||
| 253 | + final trendList = dateRangeType == 2 | ||
| 254 | + ? _monthlyHrvTrend(validDaily) | ||
| 255 | + : [ | ||
| 256 | + for (final item in daily) | ||
| 257 | + HrvTrendList( | ||
| 258 | + timeKey: _dateKey(item.day), | ||
| 259 | + hrvAverage: item.hrvAverage, | ||
| 260 | + hrAverage: item.hrAverage, | ||
| 261 | + state: item.state, | ||
| 262 | + ), | ||
| 263 | + ]; | ||
| 264 | + | ||
| 265 | + final distribution = _hrvDistribution(validDaily); | ||
| 266 | + final minDay = _extremeHrv(validDaily, min: true); | ||
| 267 | + final maxDay = _extremeHrv(validDaily, min: false); | ||
| 268 | + | ||
| 269 | + return AppSuccess( | ||
| 270 | + HrvStatisticsDataV2( | ||
| 271 | + hrvTrendList: trendList, | ||
| 272 | + hrvDistributionList: [ | ||
| 273 | + for (final entry in distribution.entries) | ||
| 274 | + HrvDistributionList( | ||
| 275 | + stressId: entry.key, | ||
| 276 | + dayCounts: entry.value, | ||
| 277 | + ), | ||
| 278 | + ], | ||
| 279 | + dailyDistributionList: [ | ||
| 280 | + for (final item in validDaily) | ||
| 281 | + DailyDistributionList( | ||
| 282 | + date: _dateKey(item.day), | ||
| 283 | + hrvLevel: item.state, | ||
| 284 | + ), | ||
| 285 | + ], | ||
| 286 | + hrvMin: minDay == null | ||
| 287 | + ? null | ||
| 288 | + : HrvMin( | ||
| 289 | + value: minDay.hrvAverage, | ||
| 290 | + timeList: [_dateKey(minDay.day)], | ||
| 291 | + ), | ||
| 292 | + hrvMax: maxDay == null | ||
| 293 | + ? null | ||
| 294 | + : HrvMax( | ||
| 295 | + value: maxDay.hrvAverage, | ||
| 296 | + timeList: [_dateKey(maxDay.day)], | ||
| 297 | + ), | ||
| 298 | + ), | ||
| 299 | + ); | ||
| 300 | + } catch (error) { | ||
| 301 | + return AppFailure(AppUnknownError(error)); | ||
| 302 | + } | ||
| 303 | + } | ||
| 304 | + | ||
| 305 | + List<DateTime> _rangeDays(int dateRangeType, int startDate) { | ||
| 306 | + final start = _dateFromKey(startDate); | ||
| 307 | + final count = switch (dateRangeType) { | ||
| 308 | + 0 => 7, | ||
| 309 | + 1 => DateTime(start.year, start.month + 1, 0).day, | ||
| 310 | + 2 => DateTime(start.year + 1).difference(DateTime(start.year)).inDays, | ||
| 311 | + 3 => 1, | ||
| 312 | + _ => 1, | ||
| 313 | + }; | ||
| 314 | + final first = dateRangeType == 2 ? DateTime(start.year) : start; | ||
| 315 | + return [for (var i = 0; i < count; i++) first.add(Duration(days: i))]; | ||
| 316 | + } | ||
| 317 | + | ||
| 318 | + List<DateTime> _previousRangeDays(int dateRangeType, int startDate) { | ||
| 319 | + final start = _dateFromKey(startDate); | ||
| 320 | + final previousStart = switch (dateRangeType) { | ||
| 321 | + 0 => start.subtract(const Duration(days: 7)), | ||
| 322 | + 1 => DateTime(start.year, start.month - 1), | ||
| 323 | + _ => null, | ||
| 324 | + }; | ||
| 325 | + if (previousStart == null) return const <DateTime>[]; | ||
| 326 | + return _rangeDays(dateRangeType, _dateKey(previousStart)); | ||
| 327 | + } | ||
| 328 | + | ||
| 329 | + _SleepDaySummary _sleepDaySummary( | ||
| 330 | + DateTime day, | ||
| 331 | + List<HealthKitRawDataPoint> sleepIntervals, | ||
| 332 | + List<HealthKitRawDataPoint> heartRate, | ||
| 333 | + ) { | ||
| 334 | + final windowStart = _unixSeconds(day.subtract(const Duration(hours: 12))); | ||
| 335 | + final windowEnd = _unixSeconds(day.add(const Duration(hours: 12))); | ||
| 336 | + final intervals = sleepIntervals | ||
| 337 | + .where((e) => e.endTime > windowStart && e.startTime < windowEnd) | ||
| 338 | + .toList(); | ||
| 339 | + final summary = _sleepSummary(intervals, windowStart, windowEnd); | ||
| 340 | + final score = _sleepScore(summary); | ||
| 341 | + final sleepStageIntervals = intervals | ||
| 342 | + .where((e) => _isAsleepSleepType(e.dataType)) | ||
| 343 | + .toList(growable: false); | ||
| 344 | + final heartRateIntervals = | ||
| 345 | + sleepStageIntervals.isEmpty ? intervals : sleepStageIntervals; | ||
| 346 | + final sleepHr = heartRate | ||
| 347 | + .where((point) => heartRateIntervals.any((interval) => | ||
| 348 | + point.endTime > interval.startTime && | ||
| 349 | + point.endTime <= interval.endTime)) | ||
| 350 | + .toList(); | ||
| 351 | + return _SleepDaySummary( | ||
| 352 | + day: day, | ||
| 353 | + durationSeconds: (summary.totalAsleepMinutes * 60).round(), | ||
| 354 | + asleepTime: intervals.isEmpty | ||
| 355 | + ? null | ||
| 356 | + : intervals.map((e) => e.startTime).reduce(math.min), | ||
| 357 | + score: score == 0 ? null : score, | ||
| 358 | + evaluate: _sleepEvaluate(score), | ||
| 359 | + sleepHr: sleepHr, | ||
| 360 | + ); | ||
| 361 | + } | ||
| 362 | + | ||
| 363 | + _ActivityDaySummary _activityDaySummary( | ||
| 364 | + DateTime day, | ||
| 365 | + List<HealthKitRawActivityDataPoint> activity, | ||
| 366 | + ) { | ||
| 367 | + final start = _unixSeconds(day); | ||
| 368 | + final end = _unixSeconds(day.add(const Duration(days: 1))); | ||
| 369 | + final dayActivity = | ||
| 370 | + activity.where((e) => e.endTime >= start && e.endTime < end).toList(); | ||
| 371 | + final latest = dayActivity.isEmpty ? null : dayActivity.last; | ||
| 372 | + return _ActivityDaySummary( | ||
| 373 | + day: day, | ||
| 374 | + move: latest?.activeEnergyBurned ?? 0, | ||
| 375 | + steps: latest?.appleMoveTime ?? 0, | ||
| 376 | + standHours: latest?.appleStandHours ?? 0, | ||
| 377 | + exerciseSeconds: (latest?.appleExerciseTime ?? 0) * 60, | ||
| 378 | + moveGoal: latest?.activeEnergyBurnedGoal, | ||
| 379 | + stepGoal: latest?.appleMoveTimeGoal, | ||
| 380 | + standGoal: latest?.standHoursGoal, | ||
| 381 | + exerciseGoalSeconds: latest?.exerciseTimeGoal == null | ||
| 382 | + ? null | ||
| 383 | + : latest!.exerciseTimeGoal! * 60, | ||
| 384 | + ); | ||
| 385 | + } | ||
| 386 | + | ||
| 387 | + _ActivityDaySummary? _latestActivitySummaryWithGoal( | ||
| 388 | + List<_ActivityDaySummary> daily, | ||
| 389 | + ) { | ||
| 390 | + for (final item in daily.reversed) { | ||
| 391 | + if (item.hasGoal) return item; | ||
| 392 | + } | ||
| 393 | + return null; | ||
| 394 | + } | ||
| 395 | + | ||
| 396 | + ActivityTargetInfo? _activityTargetInfo( | ||
| 397 | + _ActivityDaySummary? summary, | ||
| 398 | + ) { | ||
| 399 | + if (summary == null || !summary.hasGoal) return null; | ||
| 400 | + return ActivityTargetInfo( | ||
| 401 | + move: summary.moveGoal, | ||
| 402 | + step: summary.stepGoal, | ||
| 403 | + stand: summary.standGoal, | ||
| 404 | + exercise: summary.exerciseGoalSeconds, | ||
| 405 | + ); | ||
| 406 | + } | ||
| 407 | + | ||
| 408 | + _HrvDaySummary _hrvDaySummary( | ||
| 409 | + DateTime day, | ||
| 410 | + List<HealthRawHrvStressPoint> hrv, | ||
| 411 | + List<HealthRawRealtimeStressPoint> realtime, | ||
| 412 | + ) { | ||
| 413 | + final start = _unixSeconds(day); | ||
| 414 | + final end = _unixSeconds(day.add(const Duration(days: 1))); | ||
| 415 | + final dayHrv = | ||
| 416 | + hrv.where((e) => e.rawEndTime >= start && e.rawEndTime < end).toList(); | ||
| 417 | + final dayRealtime = realtime | ||
| 418 | + .where((e) => e.rawEndTime >= start && e.rawEndTime < end) | ||
| 419 | + .toList(); | ||
| 420 | + final hrvAverage = _averageOrNull(dayHrv.map((e) => e.result).toList()); | ||
| 421 | + return _HrvDaySummary( | ||
| 422 | + day: day, | ||
| 423 | + hrvAverage: hrvAverage?.toDouble(), | ||
| 424 | + hrAverage: | ||
| 425 | + _averageOrNull(dayRealtime.map((e) => e.result).toList())?.toDouble(), | ||
| 426 | + state: hrvAverage == null ? null : _hrvStateFromValue(hrvAverage), | ||
| 427 | + ); | ||
| 428 | + } | ||
| 429 | + | ||
| 430 | + List<HrvTrendList> _monthlyHrvTrend(List<_HrvDaySummary> daily) { | ||
| 431 | + final byMonth = <int, List<_HrvDaySummary>>{}; | ||
| 432 | + for (final item in daily) { | ||
| 433 | + byMonth.putIfAbsent(item.day.month, () => <_HrvDaySummary>[]).add(item); | ||
| 434 | + } | ||
| 435 | + return [ | ||
| 436 | + for (final entry in byMonth.entries) | ||
| 437 | + HrvTrendList( | ||
| 438 | + timeKey: entry.key, | ||
| 439 | + hrvAverage: _averageOrNull( | ||
| 440 | + entry.value.map((e) => e.hrvAverage).whereType<num>().toList(), | ||
| 441 | + )?.toDouble(), | ||
| 442 | + hrAverage: _averageOrNull( | ||
| 443 | + entry.value.map((e) => e.hrAverage).whereType<num>().toList(), | ||
| 444 | + )?.toDouble(), | ||
| 445 | + state: _modeState(entry.value.map((e) => e.state).whereType<int>()), | ||
| 446 | + ), | ||
| 447 | + ]; | ||
| 448 | + } | ||
| 449 | + | ||
| 450 | + Map<int, int> _hrvDistribution(List<_HrvDaySummary> daily) { | ||
| 451 | + final result = <int, int>{}; | ||
| 452 | + for (final item in daily) { | ||
| 453 | + final state = item.state; | ||
| 454 | + if (state == null) continue; | ||
| 455 | + result[state] = (result[state] ?? 0) + 1; | ||
| 456 | + } | ||
| 457 | + return result; | ||
| 458 | + } | ||
| 459 | + | ||
| 460 | + _HrvDaySummary? _extremeHrv(List<_HrvDaySummary> daily, {required bool min}) { | ||
| 461 | + _HrvDaySummary? result; | ||
| 462 | + for (final item in daily) { | ||
| 463 | + if (item.hrvAverage == null) continue; | ||
| 464 | + if (result == null) { | ||
| 465 | + result = item; | ||
| 466 | + continue; | ||
| 467 | + } | ||
| 468 | + if (min && item.hrvAverage! < result.hrvAverage!) result = item; | ||
| 469 | + if (!min && item.hrvAverage! > result.hrvAverage!) result = item; | ||
| 470 | + } | ||
| 471 | + return result; | ||
| 472 | + } | ||
| 473 | + | ||
| 474 | + BestSleepInfo? _bestSleepInfo(List<_SleepDaySummary> daily) { | ||
| 475 | + final item = daily.where((e) => e.score != null).fold<_SleepDaySummary?>( | ||
| 476 | + null, | ||
| 477 | + (best, item) => | ||
| 478 | + best == null || item.score! > best.score! ? item : best, | ||
| 479 | + ); | ||
| 480 | + return item == null | ||
| 481 | + ? null | ||
| 482 | + : BestSleepInfo( | ||
| 483 | + timeKey: _dateKey(item.day), | ||
| 484 | + totalTime: item.durationSeconds, | ||
| 485 | + score: item.score, | ||
| 486 | + ); | ||
| 487 | + } | ||
| 488 | + | ||
| 489 | + WorstSleepInfo? _worstSleepInfo(List<_SleepDaySummary> daily) { | ||
| 490 | + final item = daily.where((e) => e.score != null).fold<_SleepDaySummary?>( | ||
| 491 | + null, | ||
| 492 | + (worst, item) => | ||
| 493 | + worst == null || item.score! < worst.score! ? item : worst, | ||
| 494 | + ); | ||
| 495 | + return item == null | ||
| 496 | + ? null | ||
| 497 | + : WorstSleepInfo( | ||
| 498 | + timeKey: _dateKey(item.day), | ||
| 499 | + totalTime: item.durationSeconds, | ||
| 500 | + score: item.score, | ||
| 501 | + ); | ||
| 502 | + } | ||
| 503 | + | ||
| 504 | + EarliestSleepInfo? _earliestSleepInfo(List<_SleepDaySummary> daily) { | ||
| 505 | + final sorted = daily.where((e) => e.asleepTime != null).toList() | ||
| 506 | + ..sort((a, b) => a.asleepTime!.compareTo(b.asleepTime!)); | ||
| 507 | + final item = sorted.isEmpty ? null : sorted.first; | ||
| 508 | + return item == null | ||
| 509 | + ? null | ||
| 510 | + : EarliestSleepInfo( | ||
| 511 | + timeKey: _dateKey(item.day), | ||
| 512 | + asleepTime: item.asleepTime, | ||
| 513 | + ); | ||
| 514 | + } | ||
| 515 | + | ||
| 516 | + LatestSleepInfo? _latestSleepInfo(List<_SleepDaySummary> daily) { | ||
| 517 | + final sorted = daily.where((e) => e.asleepTime != null).toList() | ||
| 518 | + ..sort((a, b) => b.asleepTime!.compareTo(a.asleepTime!)); | ||
| 519 | + final item = sorted.isEmpty ? null : sorted.first; | ||
| 520 | + return item == null | ||
| 521 | + ? null | ||
| 522 | + : LatestSleepInfo( | ||
| 523 | + timeKey: _dateKey(item.day), | ||
| 524 | + asleepTime: item.asleepTime, | ||
| 525 | + ); | ||
| 526 | + } | ||
| 527 | + | ||
| 528 | + _SleepSummary _sleepSummary( | ||
| 529 | + List<HealthKitRawDataPoint> intervals, | ||
| 530 | + int windowStart, | ||
| 531 | + int windowEnd, | ||
| 532 | + ) { | ||
| 533 | + var inBedMinutes = 0.0; | ||
| 534 | + var asleepMinutes = 0.0; | ||
| 535 | + var awakeMinutes = 0.0; | ||
| 536 | + var coreMinutes = 0.0; | ||
| 537 | + var deepMinutes = 0.0; | ||
| 538 | + var remMinutes = 0.0; | ||
| 539 | + var wakeCount = 0; | ||
| 540 | + var earliestStart = windowEnd; | ||
| 541 | + var latestEnd = windowStart; | ||
| 542 | + | ||
| 543 | + for (final interval in intervals) { | ||
| 544 | + final clippedStart = math.max(interval.startTime, windowStart); | ||
| 545 | + final clippedEnd = math.min(interval.endTime, windowEnd); | ||
| 546 | + final minutes = math.max(0, clippedEnd - clippedStart) / 60.0; | ||
| 547 | + if (minutes <= 0) continue; | ||
| 548 | + earliestStart = math.min(earliestStart, clippedStart); | ||
| 549 | + latestEnd = math.max(latestEnd, clippedEnd); | ||
| 550 | + switch (interval.dataType) { | ||
| 551 | + case _sleepTypeInBed: | ||
| 552 | + inBedMinutes += minutes; | ||
| 553 | + break; | ||
| 554 | + case _sleepTypeAwake: | ||
| 555 | + awakeMinutes += minutes; | ||
| 556 | + wakeCount += 1; | ||
| 557 | + break; | ||
| 558 | + case _sleepTypeAsleepCore: | ||
| 559 | + coreMinutes += minutes; | ||
| 560 | + asleepMinutes += minutes; | ||
| 561 | + break; | ||
| 562 | + case _sleepTypeAsleepDeep: | ||
| 563 | + deepMinutes += minutes; | ||
| 564 | + asleepMinutes += minutes; | ||
| 565 | + break; | ||
| 566 | + case _sleepTypeAsleepRem: | ||
| 567 | + remMinutes += minutes; | ||
| 568 | + asleepMinutes += minutes; | ||
| 569 | + break; | ||
| 570 | + case _sleepTypeAsleepUnspecified: | ||
| 571 | + asleepMinutes += minutes; | ||
| 572 | + break; | ||
| 573 | + default: | ||
| 574 | + asleepMinutes += minutes; | ||
| 575 | + break; | ||
| 576 | + } | ||
| 577 | + } | ||
| 578 | + | ||
| 579 | + if (inBedMinutes <= 0 && latestEnd > earliestStart) { | ||
| 580 | + inBedMinutes = (latestEnd - earliestStart) / 60.0; | ||
| 581 | + } | ||
| 582 | + if (inBedMinutes <= 0) { | ||
| 583 | + inBedMinutes = asleepMinutes + awakeMinutes; | ||
| 584 | + } | ||
| 585 | + | ||
| 586 | + return _SleepSummary( | ||
| 587 | + timeInBedMinutes: inBedMinutes, | ||
| 588 | + totalAsleepMinutes: asleepMinutes, | ||
| 589 | + awakeMinutes: awakeMinutes, | ||
| 590 | + coreMinutes: coreMinutes, | ||
| 591 | + deepMinutes: deepMinutes, | ||
| 592 | + remMinutes: remMinutes, | ||
| 593 | + wakeCount: wakeCount, | ||
| 594 | + sleepGoalMinutes: _sleepGoalMinutes, | ||
| 595 | + ); | ||
| 596 | + } | ||
| 597 | + | ||
| 598 | + int _sleepScore(_SleepSummary sleep) { | ||
| 599 | + if (sleep.timeInBedMinutes <= 0 || sleep.totalAsleepMinutes <= 0) return 0; | ||
| 600 | + final durationRatio = math.min( | ||
| 601 | + sleep.totalAsleepMinutes / sleep.sleepGoalMinutes, | ||
| 602 | + 1.0, | ||
| 603 | + ); | ||
| 604 | + final durationScore = durationRatio * 40; | ||
| 605 | + final efficiency = sleep.totalAsleepMinutes / sleep.timeInBedMinutes; | ||
| 606 | + final efficiencyScore = math.min(efficiency / 0.9, 1.0) * 25; | ||
| 607 | + final deepRatio = sleep.deepMinutes / sleep.totalAsleepMinutes; | ||
| 608 | + final deepScore = math.min(deepRatio / 0.18, 1.0) * 15; | ||
| 609 | + final remRatio = sleep.remMinutes / sleep.totalAsleepMinutes; | ||
| 610 | + final remScore = math.min(remRatio / 0.22, 1.0) * 10; | ||
| 611 | + final awakePenalty = math.min( | ||
| 612 | + sleep.wakeCount * 2 + sleep.awakeMinutes / 10, | ||
| 613 | + 10, | ||
| 614 | + ); | ||
| 615 | + final rawScore = | ||
| 616 | + durationScore + efficiencyScore + deepScore + remScore - awakePenalty; | ||
| 617 | + return _mappedSleepScore(rawScore); | ||
| 618 | + } | ||
| 619 | + | ||
| 620 | + int _mappedSleepScore(num rawScore) { | ||
| 621 | + final clamped = rawScore.clamp(0, 100).toDouble(); | ||
| 622 | + final mapped = switch (clamped) { | ||
| 623 | + < 64 => clamped / 64 * 60, | ||
| 624 | + < 74 => 60 + (clamped - 64) / 10 * 25, | ||
| 625 | + _ => math.min(math.max(86, 85 + (clamped - 74) / 26 * 15), 100), | ||
| 626 | + }; | ||
| 627 | + return mapped.round().clamp(0, 100); | ||
| 628 | + } | ||
| 629 | + | ||
| 630 | + num? _sleepEvaluate(int score) { | ||
| 631 | + if (score <= 0) return null; | ||
| 632 | + if (score >= 85) return 1; | ||
| 633 | + if (score >= 60) return 2; | ||
| 634 | + return 3; | ||
| 635 | + } | ||
| 636 | + | ||
| 637 | + bool _isAsleepSleepType(int type) { | ||
| 638 | + return type == _sleepTypeAsleepUnspecified || | ||
| 639 | + type == _sleepTypeAsleepCore || | ||
| 640 | + type == _sleepTypeAsleepDeep || | ||
| 641 | + type == _sleepTypeAsleepRem; | ||
| 642 | + } | ||
| 643 | + | ||
| 644 | + int _hrvStateFromValue(num value) { | ||
| 645 | + if (value >= 30) return 4; | ||
| 646 | + if (value >= 21) return 3; | ||
| 647 | + if (value >= 17) return 2; | ||
| 648 | + return 1; | ||
| 649 | + } | ||
| 650 | + | ||
| 651 | + int? _modeState(Iterable<int> states) { | ||
| 652 | + final counts = <int, int>{}; | ||
| 653 | + for (final state in states) { | ||
| 654 | + counts[state] = (counts[state] ?? 0) + 1; | ||
| 655 | + } | ||
| 656 | + if (counts.isEmpty) return null; | ||
| 657 | + return counts.entries.reduce((a, b) => a.value >= b.value ? a : b).key; | ||
| 658 | + } | ||
| 659 | + | ||
| 660 | + bool _isInIntervals(int time, List<HealthKitRawDataPoint> intervals) { | ||
| 661 | + return intervals.any((e) => time > e.startTime && time <= e.endTime); | ||
| 662 | + } | ||
| 663 | + | ||
| 664 | + DateTime _dateFromKey(int key) { | ||
| 665 | + final year = key ~/ 10000; | ||
| 666 | + final month = (key ~/ 100) % 100; | ||
| 667 | + final day = key % 100; | ||
| 668 | + return DateTime(year, month, day); | ||
| 669 | + } | ||
| 670 | + | ||
| 671 | + DateTime _dayOf(int seconds) { | ||
| 672 | + final date = DateTime.fromMillisecondsSinceEpoch(seconds * 1000); | ||
| 673 | + return DateTime(date.year, date.month, date.day); | ||
| 674 | + } | ||
| 675 | + | ||
| 676 | + int _dateKey(DateTime date) => | ||
| 677 | + date.year * 10000 + date.month * 100 + date.day; | ||
| 678 | + | ||
| 679 | + int _unixSeconds(DateTime date) => date.millisecondsSinceEpoch ~/ 1000; | ||
| 680 | + | ||
| 681 | + num _sum(List<num?> values) { | ||
| 682 | + return values.whereType<num>().fold<num>(0, (sum, value) => sum + value); | ||
| 683 | + } | ||
| 684 | + | ||
| 685 | + num? _averageOrNull(List<num> values) { | ||
| 686 | + if (values.isEmpty) return null; | ||
| 687 | + return _sum(values) / values.length; | ||
| 688 | + } | ||
| 689 | + | ||
| 690 | + num? _maxOrNull(List<num> values) { | ||
| 691 | + if (values.isEmpty) return null; | ||
| 692 | + return values.reduce(math.max); | ||
| 693 | + } | ||
| 694 | + | ||
| 695 | + num? _minOrNull(List<num> values) { | ||
| 696 | + if (values.isEmpty) return null; | ||
| 697 | + return values.reduce(math.min); | ||
| 698 | + } | ||
| 699 | +} | ||
| 700 | + | ||
| 701 | +class _SleepDaySummary { | ||
| 702 | + const _SleepDaySummary({ | ||
| 703 | + required this.day, | ||
| 704 | + required this.durationSeconds, | ||
| 705 | + required this.asleepTime, | ||
| 706 | + required this.score, | ||
| 707 | + required this.evaluate, | ||
| 708 | + required this.sleepHr, | ||
| 709 | + }); | ||
| 710 | + | ||
| 711 | + final DateTime day; | ||
| 712 | + final num durationSeconds; | ||
| 713 | + final num? asleepTime; | ||
| 714 | + final num? score; | ||
| 715 | + final num? evaluate; | ||
| 716 | + final List<HealthKitRawDataPoint> sleepHr; | ||
| 717 | +} | ||
| 718 | + | ||
| 719 | +class _SleepSummary { | ||
| 720 | + const _SleepSummary({ | ||
| 721 | + required this.timeInBedMinutes, | ||
| 722 | + required this.totalAsleepMinutes, | ||
| 723 | + required this.awakeMinutes, | ||
| 724 | + required this.coreMinutes, | ||
| 725 | + required this.deepMinutes, | ||
| 726 | + required this.remMinutes, | ||
| 727 | + required this.wakeCount, | ||
| 728 | + required this.sleepGoalMinutes, | ||
| 729 | + }); | ||
| 730 | + | ||
| 731 | + final double timeInBedMinutes; | ||
| 732 | + final double totalAsleepMinutes; | ||
| 733 | + final double awakeMinutes; | ||
| 734 | + final double coreMinutes; | ||
| 735 | + final double deepMinutes; | ||
| 736 | + final double remMinutes; | ||
| 737 | + final int wakeCount; | ||
| 738 | + final double sleepGoalMinutes; | ||
| 739 | +} | ||
| 740 | + | ||
| 741 | +class _ActivityDaySummary { | ||
| 742 | + const _ActivityDaySummary({ | ||
| 743 | + required this.day, | ||
| 744 | + required this.move, | ||
| 745 | + required this.steps, | ||
| 746 | + required this.standHours, | ||
| 747 | + required this.exerciseSeconds, | ||
| 748 | + required this.moveGoal, | ||
| 749 | + required this.stepGoal, | ||
| 750 | + required this.standGoal, | ||
| 751 | + required this.exerciseGoalSeconds, | ||
| 752 | + }); | ||
| 753 | + | ||
| 754 | + final DateTime day; | ||
| 755 | + final num move; | ||
| 756 | + final num steps; | ||
| 757 | + final num standHours; | ||
| 758 | + final num exerciseSeconds; | ||
| 759 | + final num? moveGoal; | ||
| 760 | + final num? stepGoal; | ||
| 761 | + final num? standGoal; | ||
| 762 | + final num? exerciseGoalSeconds; | ||
| 763 | + | ||
| 764 | + bool get hasGoal => | ||
| 765 | + moveGoal != null || | ||
| 766 | + stepGoal != null || | ||
| 767 | + standGoal != null || | ||
| 768 | + exerciseGoalSeconds != null; | ||
| 769 | +} | ||
| 770 | + | ||
| 771 | +class _HrvDaySummary { | ||
| 772 | + const _HrvDaySummary({ | ||
| 773 | + required this.day, | ||
| 774 | + required this.hrvAverage, | ||
| 775 | + required this.hrAverage, | ||
| 776 | + required this.state, | ||
| 777 | + }); | ||
| 778 | + | ||
| 779 | + final DateTime day; | ||
| 780 | + final double? hrvAverage; | ||
| 781 | + final double? hrAverage; | ||
| 782 | + final int? state; | ||
| 39 | } | 783 | } |
| 1 | import 'package:doublefeel_flutter/core/services/health_raw_data_core_service.dart'; | 1 | import 'package:doublefeel_flutter/core/services/health_raw_data_core_service.dart'; |
| 2 | import 'package:doublefeel_flutter/data/models/enums/app_enums.dart'; | 2 | import 'package:doublefeel_flutter/data/models/enums/app_enums.dart'; |
| 3 | +import 'package:doublefeel_flutter/pigeon/health_kit_api.g.dart'; | ||
| 3 | import 'package:doublefeel_flutter/pigeon/health_kit_raw_data_api.g.dart'; | 4 | import 'package:doublefeel_flutter/pigeon/health_kit_raw_data_api.g.dart'; |
| 4 | import 'package:flutter_test/flutter_test.dart'; | 5 | import 'package:flutter_test/flutter_test.dart'; |
| 5 | 6 | ||
| @@ -37,6 +38,7 @@ void main() { | @@ -37,6 +38,7 @@ void main() { | ||
| 37 | 38 | ||
| 38 | final store = _MemoryHealthRawStressLocalStore(); | 39 | final store = _MemoryHealthRawStressLocalStore(); |
| 39 | final service = HealthRawDataCoreService( | 40 | final service = HealthRawDataCoreService( |
| 41 | + healthApi: _FakeHealthKitHostApi(), | ||
| 40 | rawDataApi: api, | 42 | rawDataApi: api, |
| 41 | localStore: store, | 43 | localStore: store, |
| 42 | userIdProvider: () => 42, | 44 | userIdProvider: () => 42, |
| @@ -115,6 +117,25 @@ void main() { | @@ -115,6 +117,25 @@ void main() { | ||
| 115 | isTrue, | 117 | isTrue, |
| 116 | ); | 118 | ); |
| 117 | }); | 119 | }); |
| 120 | + | ||
| 121 | + test('startCoreCaculate skips raw reads when health auth is missing', | ||
| 122 | + () async { | ||
| 123 | + final api = _FakeHealthKitRawDataHostApi(); | ||
| 124 | + final service = HealthRawDataCoreService( | ||
| 125 | + healthApi: _FakeHealthKitHostApi(status: 2), | ||
| 126 | + rawDataApi: api, | ||
| 127 | + localStore: _MemoryHealthRawStressLocalStore(), | ||
| 128 | + userIdProvider: () => 42, | ||
| 129 | + ); | ||
| 130 | + | ||
| 131 | + final result = await service.startCoreCaculate(readChunkDays: 1); | ||
| 132 | + | ||
| 133 | + expect(result.hrvStressPoints, isEmpty); | ||
| 134 | + expect(result.realtimeStressPoints, isEmpty); | ||
| 135 | + expect(api.calls, isEmpty); | ||
| 136 | + expect(api.sleepCallCount, 0); | ||
| 137 | + expect(api.workoutCallCount, 0); | ||
| 138 | + }); | ||
| 118 | } | 139 | } |
| 119 | 140 | ||
| 120 | HealthKitRawDataPoint _point( | 141 | HealthKitRawDataPoint _point( |
| @@ -135,6 +156,8 @@ class _FakeHealthKitRawDataHostApi extends HealthKitRawDataHostApi { | @@ -135,6 +156,8 @@ class _FakeHealthKitRawDataHostApi extends HealthKitRawDataHostApi { | ||
| 135 | final Map<int, List<HealthKitRawDataPoint>> _pointsByType = {}; | 156 | final Map<int, List<HealthKitRawDataPoint>> _pointsByType = {}; |
| 136 | final List<HealthKitRawWorkoutDataPoint> _workoutPoints = []; | 157 | final List<HealthKitRawWorkoutDataPoint> _workoutPoints = []; |
| 137 | final List<_ReadCall> calls = []; | 158 | final List<_ReadCall> calls = []; |
| 159 | + var sleepCallCount = 0; | ||
| 160 | + var workoutCallCount = 0; | ||
| 138 | 161 | ||
| 139 | void setPoints(int dataType, List<HealthKitRawDataPoint> points) { | 162 | void setPoints(int dataType, List<HealthKitRawDataPoint> points) { |
| 140 | _pointsByType[dataType] = points; | 163 | _pointsByType[dataType] = points; |
| @@ -167,6 +190,7 @@ class _FakeHealthKitRawDataHostApi extends HealthKitRawDataHostApi { | @@ -167,6 +190,7 @@ class _FakeHealthKitRawDataHostApi extends HealthKitRawDataHostApi { | ||
| 167 | int startTime, | 190 | int startTime, |
| 168 | int endTime, | 191 | int endTime, |
| 169 | ) async { | 192 | ) async { |
| 193 | + sleepCallCount += 1; | ||
| 170 | return const <HealthKitRawSleepDataPoint>[]; | 194 | return const <HealthKitRawSleepDataPoint>[]; |
| 171 | } | 195 | } |
| 172 | 196 | ||
| @@ -175,12 +199,24 @@ class _FakeHealthKitRawDataHostApi extends HealthKitRawDataHostApi { | @@ -175,12 +199,24 @@ class _FakeHealthKitRawDataHostApi extends HealthKitRawDataHostApi { | ||
| 175 | int startTime, | 199 | int startTime, |
| 176 | int endTime, | 200 | int endTime, |
| 177 | ) async { | 201 | ) async { |
| 202 | + workoutCallCount += 1; | ||
| 178 | return _workoutPoints | 203 | return _workoutPoints |
| 179 | .where((e) => e.endTime >= startTime && e.startTime <= endTime) | 204 | .where((e) => e.endTime >= startTime && e.startTime <= endTime) |
| 180 | .toList(); | 205 | .toList(); |
| 181 | } | 206 | } |
| 182 | } | 207 | } |
| 183 | 208 | ||
| 209 | +class _FakeHealthKitHostApi extends HealthKitHostApi { | ||
| 210 | + _FakeHealthKitHostApi({this.status = 1}); | ||
| 211 | + | ||
| 212 | + final int status; | ||
| 213 | + | ||
| 214 | + @override | ||
| 215 | + Future<HealthAuthorization> checkHealthAppAuthorization() async { | ||
| 216 | + return HealthAuthorization(status: status, hasData: status == 1); | ||
| 217 | + } | ||
| 218 | +} | ||
| 219 | + | ||
| 184 | class _ReadCall { | 220 | class _ReadCall { |
| 185 | const _ReadCall(this.dataType, this.startTime, this.endTime); | 221 | const _ReadCall(this.dataType, this.startTime, this.endTime); |
| 186 | 222 |
-
Please register or login to post a comment