Commit 528702eaf6a0fec12e7b6ab85d33395879511349

Authored by 权海
1 parent 516f5dfb

feat(ui):增加本地数据计算

@@ -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