Commit 528702eaf6a0fec12e7b6ab85d33395879511349

Authored by 权海
1 parent 516f5dfb

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

... ... @@ -214,7 +214,7 @@ final class HealthKitQueryReader {
activeEnergyBurnedGoal: summary.activeEnergyBurnedGoal.doubleValue(for: .kilocalorie()),
appleMoveTime: Self.appleMoveTimeValue(from: summary),
appleMoveTimeGoal: Self.appleMoveTimeGoalValue(from: summary),
appleExerciseTime: summary.appleExerciseTime.doubleValue(for: .second()),
appleExerciseTime: summary.appleExerciseTime.doubleValue(for: .minute()),
exerciseTimeGoal: Self.exerciseTimeGoalValue(from: summary),
appleStandHours: summary.appleStandHours.doubleValue(for: .count()),
standHoursGoal: Self.standHoursGoalValue(from: summary)
... ... @@ -244,7 +244,7 @@ final class HealthKitQueryReader {
private static func exerciseTimeGoalValue(from summary: HKActivitySummary) -> Double? {
if #available(iOS 16.0, *) {
return summary.exerciseTimeGoal?.doubleValue(for: .second())
return summary.exerciseTimeGoal?.doubleValue(for: .minute())
}
return nil
}
... ...
... ... @@ -60,6 +60,7 @@ void registerUserSessionDeps() {
Get.put(PushService(Get.find<UserApi>()), permanent: true);
Get.put(
HealthRawDataCoreService(
environmentConfig: Get.find<AppEnvironmentConfig>(),
userIdProvider: () => userPrefs.preferences.value.meUserInfo?.id ?? 0,
),
permanent: true,
... ...
import 'package:doublefeel_flutter/core/network/api/health_api.dart';
import 'package:doublefeel_flutter/core/services/health_raw_data_core_service.dart';
import 'package:doublefeel_flutter/core/services/user_state_service.dart';
import 'package:doublefeel_flutter/data/datasource/health/health_datasource.dart';
import 'package:doublefeel_flutter/data/datasource/health/health_datasource_wrapper.dart';
import 'package:doublefeel_flutter/data/datasource/health/health_local_datasource.dart';
... ... @@ -29,12 +31,16 @@ class ActivityBurnReportLogic extends ReportPeriodLogic {
static HealthDataSource _healthDataSource() => HealthDataSourceWrapper(
remoteDataSource: RemoteHealthDataSource(Get.find<HealthApi>()),
localDataSource: const LocalHealthDataSource(),
localDataSource: LocalHealthDataSource(
coreService: Get.find<HealthRawDataCoreService>()),
);
final report = Rxn<ActivityBurnReport>();
final weeklyReport = Rxn<WeeklyActivityBurnReport>();
final monthlyReport = Rxn<MonthlyActivityBurnReport>();
final _myUserId = Get.find<UserStateService>().userId;
bool get isMySelf =>
targetUserId.value == null || _myUserId == targetUserId.value;
@override
List<ReportPeriod> get supportedPeriods => const [
... ... @@ -45,7 +51,8 @@ class ActivityBurnReportLogic extends ReportPeriodLogic {
@override
Future<void> loadReport() async {
isLoading.value = true;
isLoading.value = !isMySelf;
try {
if (selectedPeriod.value == ReportPeriod.week) {
weeklyReport.value = await repository.getWeeklyReport(
... ...
import 'package:doublefeel_flutter/core/network/api/health_api.dart';
import 'package:doublefeel_flutter/core/services/health_raw_data_core_service.dart';
import 'package:doublefeel_flutter/core/services/user_state_service.dart';
import 'package:doublefeel_flutter/data/datasource/health/health_datasource.dart';
import 'package:doublefeel_flutter/data/datasource/health/health_datasource_wrapper.dart';
import 'package:doublefeel_flutter/data/datasource/health/health_local_datasource.dart';
... ... @@ -28,10 +30,14 @@ class HrvReportLogic extends ReportPeriodLogic {
final monthlyReport = Rxn<MonthlyHrvReport>();
final yearlyReport = Rxn<YearlyHrvReport>();
final HrvReportRepository repository;
final _myUserId = Get.find<UserStateService>().userId;
bool get isMySelf =>
targetUserId.value == null || _myUserId == targetUserId.value;
static HealthDataSource _healthDataSource() => HealthDataSourceWrapper(
remoteDataSource: RemoteHealthDataSource(Get.find<HealthApi>()),
localDataSource: const LocalHealthDataSource(),
localDataSource: LocalHealthDataSource(
coreService: Get.find<HealthRawDataCoreService>()),
);
@override
... ... @@ -43,7 +49,8 @@ class HrvReportLogic extends ReportPeriodLogic {
@override
Future<void> loadReport() async {
isLoading.value = true;
isLoading.value = !isMySelf;
try {
if (selectedPeriod.value == ReportPeriod.year) {
yearlyReport.value = await repository.getYearlyReport(
... ...
import 'package:doublefeel_flutter/core/network/api/health_api.dart';
import 'package:doublefeel_flutter/core/services/health_raw_data_core_service.dart';
import 'package:doublefeel_flutter/core/services/user_state_service.dart';
import 'package:doublefeel_flutter/data/datasource/health/health_datasource.dart';
import 'package:doublefeel_flutter/data/datasource/health/health_datasource_wrapper.dart';
import 'package:doublefeel_flutter/data/datasource/health/health_local_datasource.dart';
... ... @@ -28,12 +30,16 @@ class SleepReportLogic extends ReportPeriodLogic {
static HealthDataSource _healthDataSource() => HealthDataSourceWrapper(
remoteDataSource: RemoteHealthDataSource(Get.find<HealthApi>()),
localDataSource: const LocalHealthDataSource(),
localDataSource: LocalHealthDataSource(
coreService: Get.find<HealthRawDataCoreService>()),
);
final report = Rxn<SleepReport>();
final weeklyReport = Rxn<WeeklySleepReport>();
final monthlyReport = Rxn<MonthlySleepReport>();
final _myUserId = Get.find<UserStateService>().userId;
bool get isMySelf =>
targetUserId.value == null || _myUserId == targetUserId.value;
@override
List<ReportPeriod> get supportedPeriods => const [
... ... @@ -54,7 +60,7 @@ class SleepReportLogic extends ReportPeriodLogic {
}
Future<void> loadDailyReport() async {
isLoading.value = true;
isLoading.value = !isMySelf;
try {
report.value = await repository.getDailyReport(
selectedDate.value,
... ... @@ -66,7 +72,7 @@ class SleepReportLogic extends ReportPeriodLogic {
}
Future<void> loadWeeklyReport() async {
isLoading.value = true;
isLoading.value = !isMySelf;
try {
weeklyReport.value = await repository.getWeeklyReport(
weekStart,
... ... @@ -78,7 +84,7 @@ class SleepReportLogic extends ReportPeriodLogic {
}
Future<void> loadMonthlyReport() async {
isLoading.value = true;
isLoading.value = !isMySelf;
monthlyReport.value = null;
try {
monthlyReport.value = await repository.getMonthlyReport(
... ...
... ... @@ -7,23 +7,32 @@ import 'package:path_provider/path_provider.dart';
import 'package:sqflite/sqflite.dart';
import '../../data/models/enums/app_enums.dart';
import '../../pigeon/health_kit_api.g.dart';
import '../../pigeon/health_kit_raw_data_api.g.dart';
import '../config/app_environment_config.dart';
import '../logging/app_logger.dart';
import '../util/app_toast.dart';
class HealthRawDataCoreService {
static const int defaultLookbackDays = 183;
static const int defaultReadChunkDays = 7;
HealthRawDataCoreService({
HealthKitHostApi? healthApi,
HealthKitRawDataHostApi? rawDataApi,
HealthRawStressLocalStore? localStore,
AppEnvironmentConfig? environmentConfig,
int Function()? userIdProvider,
}) : _rawDataApi = rawDataApi ?? HealthKitRawDataHostApi(),
}) : _healthApi = healthApi ?? HealthKitHostApi(),
_rawDataApi = rawDataApi ?? HealthKitRawDataHostApi(),
_localStore = localStore ?? HealthRawStressLocalStore(),
_environmentConfig = environmentConfig,
_userIdProvider = userIdProvider;
final HealthKitHostApi _healthApi;
final HealthKitRawDataHostApi _rawDataApi;
final HealthRawStressLocalStore _localStore;
final AppEnvironmentConfig? _environmentConfig;
final int Function()? _userIdProvider;
int get _userId {
... ... @@ -108,8 +117,16 @@ class HealthRawDataCoreService {
throw ArgumentError.value(readChunkDays, 'readChunkDays');
}
final totalWatch = Stopwatch()..start();
final userId = _userId;
final hasAuthorization = await _hasHealthReadAuthorization();
if (!hasAuthorization) {
return HealthRawStressCalculationResult(
userId: userId,
hrvStressPoints: const <HealthRawHrvStressPoint>[],
realtimeStressPoints: const <HealthRawRealtimeStressPoint>[],
);
}
final totalWatch = Stopwatch()..start();
await _localStore.ensureReadable(userId);
final readWatch = Stopwatch()..start();
final effectiveEndTime =
... ... @@ -127,6 +144,8 @@ class HealthRawDataCoreService {
final hrvContextStart = await _localStore.latestHrvSourceStartTime(userId);
final realtimeContextStart =
await _localStore.latestRealtimeSourceStartTime(userId);
final isFirstCalculation =
hrvContextStart == null && realtimeContextStart == null;
final hrvStartTime = math.max(
hrvContextStart ?? requestedStartTime,
earliestStartTime,
... ... @@ -190,6 +209,11 @@ class HealthRawDataCoreService {
await _localStore.upsertResult(result);
calculateAndStoreWatch.stop();
totalWatch.stop();
if (isFirstCalculation && (_environmentConfig?.isDebug ?? false)) {
AppToast.show(
'首次计算完成, 总耗时${_formatDurationMinutesSeconds(totalWatch.elapsed)}',
);
}
_logInfo(
'timing userId=$userId '
... ... @@ -214,6 +238,11 @@ class HealthRawDataCoreService {
return result;
}
Future<bool> _hasHealthReadAuthorization() async {
final authorization = await _healthApi.checkHealthAppAuthorization();
return authorization.status == 1;
}
Stream<HealthKitRawDataPoint> streamRawData({
required int dataType,
required int startTime,
... ... @@ -307,6 +336,55 @@ class HealthRawDataCoreService {
);
}
Future<List<HealthKitRawDataPoint>> queryRawDataPoints({
required int dataType,
required int startTime,
required int endTime,
int readChunkDays = defaultReadChunkDays,
}) async {
if (!await _hasHealthReadAuthorization()) {
return const <HealthKitRawDataPoint>[];
}
return _fetchRawDataInChunks(
dataType,
startTime,
endTime,
readChunkDays: readChunkDays,
);
}
Future<List<HealthKitRawDataPoint>> queryRawSleepIntervals({
required int startTime,
required int endTime,
int readChunkDays = defaultReadChunkDays,
}) async {
if (!await _hasHealthReadAuthorization()) {
return const <HealthKitRawDataPoint>[];
}
return _fetchSleepIntervalsInChunks(
startTime,
endTime,
readChunkDays: readChunkDays,
);
}
Future<List<HealthKitRawActivityDataPoint>> queryRawActivitySummaries({
required int startTime,
required int endTime,
}) async {
if (!await _hasHealthReadAuthorization()) {
return const <HealthKitRawActivityDataPoint>[];
}
final points = await _rawDataApi.getHealthKitRawActivityData(
startTime,
endTime,
);
return points
.where((e) => e.endTime >= startTime && e.endTime <= endTime)
.toList()
..sort((a, b) => a.endTime.compareTo(b.endTime));
}
Future<void> markHrvStressUploaded({
required Iterable<int> rawEndTimes,
}) {
... ... @@ -963,6 +1041,12 @@ class HealthRawDataCoreService {
.toIso8601String();
}
static String _formatDurationMinutesSeconds(Duration duration) {
final minutes = duration.inMinutes;
final seconds = duration.inSeconds.remainder(60);
return '$minutes分钟$seconds秒';
}
static void _logInfo(String message) {
final tagged = 'HealthRawDataCoreService $message';
developer.log(tagged, name: 'HealthRawDataCoreService');
... ...
import 'package:doublefeel_flutter/core/result/app_result.dart';
import 'package:doublefeel_flutter/core/services/user_state_service.dart';
import 'package:doublefeel_flutter/data/models/health/activity/activity_burn_statistics_data_v2.dart';
import 'package:doublefeel_flutter/data/models/health/hrv/hrv_statistics_data.dart';
import 'package:doublefeel_flutter/data/models/health/sleep/sleep_statistics_data.dart';
import 'package:get/get_core/src/get_main.dart';
import 'package:get/get_instance/src/extension_instance.dart';
import 'health_datasource.dart';
/// Routes each report type to its current source of truth.
class HealthDataSourceWrapper implements HealthDataSource {
const HealthDataSourceWrapper({
HealthDataSourceWrapper({
required this.remoteDataSource,
required this.localDataSource,
});
final HealthDataSource remoteDataSource;
final HealthDataSource localDataSource;
final _myUserId = Get.find<UserStateService>().userId;
@override
Future<AppResult<SleepStatisticsData>> getSleepStatistics(
... ... @@ -21,7 +25,12 @@ class HealthDataSourceWrapper implements HealthDataSource {
int startDate, {
int? queryUserId,
}) {
return remoteDataSource.getSleepStatistics(
HealthDataSource dataSource = remoteDataSource;
if (queryUserId == null || _myUserId == queryUserId) {
dataSource = localDataSource;
}
return dataSource.getSleepStatistics(
dateRangeType,
startDate,
queryUserId: queryUserId,
... ... @@ -34,7 +43,12 @@ class HealthDataSourceWrapper implements HealthDataSource {
int startDate, {
int? queryUserId,
}) {
return localDataSource.getActivityBurnStatistics(
HealthDataSource dataSource = remoteDataSource;
if (queryUserId == null || _myUserId == queryUserId) {
dataSource = localDataSource;
}
return dataSource.getActivityBurnStatistics(
dateRangeType,
startDate,
queryUserId: queryUserId,
... ... @@ -47,7 +61,12 @@ class HealthDataSourceWrapper implements HealthDataSource {
int startDate, {
int? queryUserId,
}) {
return localDataSource.getHrvStatistics(
HealthDataSource dataSource = remoteDataSource;
if (queryUserId == null || _myUserId == queryUserId) {
dataSource = localDataSource;
}
return dataSource.getHrvStatistics(
dateRangeType,
startDate,
queryUserId: queryUserId,
... ...
import 'dart:math' as math;
import 'package:doublefeel_flutter/core/error/app_error.dart';
import 'package:doublefeel_flutter/core/result/app_result.dart';
import 'package:doublefeel_flutter/core/services/health_raw_data_core_service.dart';
import 'package:doublefeel_flutter/data/models/enums/app_enums.dart';
import 'package:doublefeel_flutter/data/models/health/activity/activity_burn_statistics_data_v2.dart';
import 'package:doublefeel_flutter/data/models/health/hrv/hrv_statistics_data.dart';
import 'package:doublefeel_flutter/data/models/health/sleep/sleep_statistics_data.dart';
import 'package:doublefeel_flutter/pigeon/health_kit_raw_data_api.g.dart';
import 'health_datasource.dart';
/// Placeholder for the local health-data store.
const _sleepTypeInBed = 0;
const _sleepTypeAsleepUnspecified = 1;
const _sleepTypeAwake = 2;
const _sleepTypeAsleepCore = 3;
const _sleepTypeAsleepDeep = 4;
const _sleepTypeAsleepRem = 5;
const _sleepGoalMinutes = 8 * 60.0;
class LocalHealthDataSource implements HealthDataSource {
const LocalHealthDataSource();
const LocalHealthDataSource({required this.coreService});
Never _notImplemented() => throw UnimplementedError(
'Local health data source has not been implemented.',
);
final HealthRawDataCoreService coreService;
@override
Future<AppResult<SleepStatisticsData>> getSleepStatistics(
int dateRangeType,
int startDate, {
int? queryUserId,
}) =>
_notImplemented();
}) async {
try {
final days = _rangeDays(dateRangeType, startDate);
if (days.isEmpty) return AppSuccess(SleepStatisticsData());
final previousDays = _previousRangeDays(dateRangeType, startDate);
final allDays = [...previousDays, ...days];
final queryStart = _unixSeconds(
allDays.first.subtract(const Duration(hours: 12)),
);
final queryEnd = _unixSeconds(
allDays.last.add(const Duration(days: 1, hours: 12)),
);
final sleepIntervals = await coreService.queryRawSleepIntervals(
startTime: queryStart,
endTime: queryEnd,
);
final heartRate = await coreService.queryRawDataPoints(
dataType: HealthDataUploadType.heartRate.type,
startTime: queryStart,
endTime: queryEnd,
);
final daily = [
for (final day in days)
_sleepDaySummary(
day,
sleepIntervals,
heartRate,
),
];
final validSleep = daily.where((e) => e.durationSeconds > 0).toList();
final previousDaily = [
for (final day in previousDays)
_sleepDaySummary(
day,
sleepIntervals,
heartRate,
),
];
final previousValidSleep =
previousDaily.where((e) => e.durationSeconds > 0).toList();
final allSleepHr = validSleep.expand((e) => e.sleepHr).toList();
return AppSuccess(
SleepStatisticsData(
avgSleepDuration: _averageOrNull(
validSleep.map((e) => e.durationSeconds).toList(),
),
avgSleepScore: _averageOrNull(
validSleep.map((e) => e.score).whereType<num>().toList(),
),
avgSleepEvaluate: _averageOrNull(
validSleep.map((e) => e.evaluate).whereType<num>().toList(),
),
qoqAvgSleepDuration: _averageOrNull(
previousValidSleep.map((e) => e.durationSeconds).toList(),
),
qoqSleepScore: _averageOrNull(
previousValidSleep.map((e) => e.score).whereType<num>().toList(),
),
qoqSleepEvaluate: _averageOrNull(
previousValidSleep.map((e) => e.evaluate).whereType<num>().toList(),
),
sleepTrendList: [
for (final item in daily)
SleepTrendList(
timeKey: _dateKey(item.day),
totalTime: item.durationSeconds,
score: item.score,
sleepEvaluate: item.evaluate,
),
],
asleepTimeTrendList: [
for (final item in daily)
AsleepTimeTrendList(
timeKey: _dateKey(item.day),
asleepTime: item.asleepTime,
),
],
bestSleepInfo: _bestSleepInfo(validSleep),
worstSleepInfo: _worstSleepInfo(validSleep),
earliestSleepInfo: _earliestSleepInfo(validSleep),
latestSleepInfo: _latestSleepInfo(validSleep),
hrList: [
for (final point in allSleepHr)
SleepHrItem(time: point.endTime, value: point.value),
],
avgHr: _averageOrNull(
allSleepHr.map((e) => e.value).whereType<num>().toList(),
),
maxHr: _maxOrNull(
allSleepHr.map((e) => e.value).whereType<num>().toList(),
),
minHr: _minOrNull(
allSleepHr.map((e) => e.value).whereType<num>().toList(),
),
),
);
} catch (error) {
return AppFailure(AppUnknownError(error));
}
}
@override
Future<AppResult<ActivityBurnStatisticsDataV2>> getActivityBurnStatistics(
int dateRangeType,
int startDate, {
int? queryUserId,
}) =>
_notImplemented();
}) async {
try {
final days = _rangeDays(dateRangeType, startDate);
if (days.isEmpty) return AppSuccess(ActivityBurnStatisticsDataV2());
final previousDays = _previousRangeDays(dateRangeType, startDate);
final allDays = [...previousDays, ...days];
final queryStart = _unixSeconds(allDays.first);
final queryEnd = _unixSeconds(days.last.add(const Duration(days: 1)));
final activity = await coreService.queryRawActivitySummaries(
startTime: queryStart,
endTime: queryEnd,
);
final heartRate = await coreService.queryRawDataPoints(
dataType: HealthDataUploadType.heartRate.type,
startTime: queryStart,
endTime: queryEnd,
);
final sleepIntervals = await coreService.queryRawSleepIntervals(
startTime: queryStart,
endTime: queryEnd,
);
final daily = [
for (final day in days)
_activityDaySummary(
day,
activity,
),
];
final previousDaily = [
for (final day in previousDays)
_activityDaySummary(
day,
activity,
),
];
final latestWithGoal = _latestActivitySummaryWithGoal(daily);
final data = ActivityBurnStatisticsDataV2(
totalMove: _sum(daily.map((e) => e.move).toList()),
totalSteps: _sum(daily.map((e) => e.steps).toList()),
totalStand: _sum(daily.map((e) => e.standHours).toList()),
totalExercise: _sum(daily.map((e) => e.exerciseSeconds).toList()),
avgMove: _averageOrNull(daily.map((e) => e.move).toList()),
qoqAvgMove: _averageOrNull(previousDaily.map((e) => e.move).toList()),
activityTargetInfo: _activityTargetInfo(latestWithGoal),
moveTrendList: [
for (final item in daily)
MoveTrendList(
timeKey: _dateKey(item.day).toString(),
value: item.move,
),
],
overallList: [
for (final item in daily)
OverallList(
timeKey: _dateKey(item.day),
value: Value(
move: item.move,
stand: item.standHours,
exercise: item.exerciseSeconds,
),
),
],
hrList: [
for (final point in heartRate)
HrList(
dataTime: point.endTime,
date: _dateKey(_dayOf(point.endTime)),
dataType: HealthDataUploadType.heartRate.type,
value: point.value,
isAsleep: _isInIntervals(point.endTime, sleepIntervals) ? 1 : 0,
),
],
sleepTimeList: [
for (final interval in sleepIntervals)
SleepTimeList(
fromTime: interval.startTime,
toTime: interval.endTime,
),
],
)..maxHr = _maxOrNull(
heartRate.map((e) => e.value).whereType<num>().toList(),
);
return AppSuccess(data);
} catch (error) {
return AppFailure(AppUnknownError(error));
}
}
@override
Future<AppResult<HrvStatisticsDataV2>> getHrvStatistics(
int dateRangeType,
int startDate, {
int? queryUserId,
}) =>
_notImplemented();
}) async {
try {
final days = _rangeDays(dateRangeType, startDate);
if (days.isEmpty) return AppSuccess(HrvStatisticsDataV2());
final queryStart = _unixSeconds(days.first);
final queryEnd = _unixSeconds(days.last.add(const Duration(days: 1)));
final hrvPoints = await coreService.queryHrvStressPoints(
startTime: queryStart,
endTime: queryEnd,
);
final realtimePoints = await coreService.queryRealtimeStressPoints(
startTime: queryStart,
endTime: queryEnd,
);
final daily = [
for (final day in days) _hrvDaySummary(day, hrvPoints, realtimePoints),
];
final validDaily = daily.where((e) => e.hrvAverage != null).toList();
final trendList = dateRangeType == 2
? _monthlyHrvTrend(validDaily)
: [
for (final item in daily)
HrvTrendList(
timeKey: _dateKey(item.day),
hrvAverage: item.hrvAverage,
hrAverage: item.hrAverage,
state: item.state,
),
];
final distribution = _hrvDistribution(validDaily);
final minDay = _extremeHrv(validDaily, min: true);
final maxDay = _extremeHrv(validDaily, min: false);
return AppSuccess(
HrvStatisticsDataV2(
hrvTrendList: trendList,
hrvDistributionList: [
for (final entry in distribution.entries)
HrvDistributionList(
stressId: entry.key,
dayCounts: entry.value,
),
],
dailyDistributionList: [
for (final item in validDaily)
DailyDistributionList(
date: _dateKey(item.day),
hrvLevel: item.state,
),
],
hrvMin: minDay == null
? null
: HrvMin(
value: minDay.hrvAverage,
timeList: [_dateKey(minDay.day)],
),
hrvMax: maxDay == null
? null
: HrvMax(
value: maxDay.hrvAverage,
timeList: [_dateKey(maxDay.day)],
),
),
);
} catch (error) {
return AppFailure(AppUnknownError(error));
}
}
List<DateTime> _rangeDays(int dateRangeType, int startDate) {
final start = _dateFromKey(startDate);
final count = switch (dateRangeType) {
0 => 7,
1 => DateTime(start.year, start.month + 1, 0).day,
2 => DateTime(start.year + 1).difference(DateTime(start.year)).inDays,
3 => 1,
_ => 1,
};
final first = dateRangeType == 2 ? DateTime(start.year) : start;
return [for (var i = 0; i < count; i++) first.add(Duration(days: i))];
}
List<DateTime> _previousRangeDays(int dateRangeType, int startDate) {
final start = _dateFromKey(startDate);
final previousStart = switch (dateRangeType) {
0 => start.subtract(const Duration(days: 7)),
1 => DateTime(start.year, start.month - 1),
_ => null,
};
if (previousStart == null) return const <DateTime>[];
return _rangeDays(dateRangeType, _dateKey(previousStart));
}
_SleepDaySummary _sleepDaySummary(
DateTime day,
List<HealthKitRawDataPoint> sleepIntervals,
List<HealthKitRawDataPoint> heartRate,
) {
final windowStart = _unixSeconds(day.subtract(const Duration(hours: 12)));
final windowEnd = _unixSeconds(day.add(const Duration(hours: 12)));
final intervals = sleepIntervals
.where((e) => e.endTime > windowStart && e.startTime < windowEnd)
.toList();
final summary = _sleepSummary(intervals, windowStart, windowEnd);
final score = _sleepScore(summary);
final sleepStageIntervals = intervals
.where((e) => _isAsleepSleepType(e.dataType))
.toList(growable: false);
final heartRateIntervals =
sleepStageIntervals.isEmpty ? intervals : sleepStageIntervals;
final sleepHr = heartRate
.where((point) => heartRateIntervals.any((interval) =>
point.endTime > interval.startTime &&
point.endTime <= interval.endTime))
.toList();
return _SleepDaySummary(
day: day,
durationSeconds: (summary.totalAsleepMinutes * 60).round(),
asleepTime: intervals.isEmpty
? null
: intervals.map((e) => e.startTime).reduce(math.min),
score: score == 0 ? null : score,
evaluate: _sleepEvaluate(score),
sleepHr: sleepHr,
);
}
_ActivityDaySummary _activityDaySummary(
DateTime day,
List<HealthKitRawActivityDataPoint> activity,
) {
final start = _unixSeconds(day);
final end = _unixSeconds(day.add(const Duration(days: 1)));
final dayActivity =
activity.where((e) => e.endTime >= start && e.endTime < end).toList();
final latest = dayActivity.isEmpty ? null : dayActivity.last;
return _ActivityDaySummary(
day: day,
move: latest?.activeEnergyBurned ?? 0,
steps: latest?.appleMoveTime ?? 0,
standHours: latest?.appleStandHours ?? 0,
exerciseSeconds: (latest?.appleExerciseTime ?? 0) * 60,
moveGoal: latest?.activeEnergyBurnedGoal,
stepGoal: latest?.appleMoveTimeGoal,
standGoal: latest?.standHoursGoal,
exerciseGoalSeconds: latest?.exerciseTimeGoal == null
? null
: latest!.exerciseTimeGoal! * 60,
);
}
_ActivityDaySummary? _latestActivitySummaryWithGoal(
List<_ActivityDaySummary> daily,
) {
for (final item in daily.reversed) {
if (item.hasGoal) return item;
}
return null;
}
ActivityTargetInfo? _activityTargetInfo(
_ActivityDaySummary? summary,
) {
if (summary == null || !summary.hasGoal) return null;
return ActivityTargetInfo(
move: summary.moveGoal,
step: summary.stepGoal,
stand: summary.standGoal,
exercise: summary.exerciseGoalSeconds,
);
}
_HrvDaySummary _hrvDaySummary(
DateTime day,
List<HealthRawHrvStressPoint> hrv,
List<HealthRawRealtimeStressPoint> realtime,
) {
final start = _unixSeconds(day);
final end = _unixSeconds(day.add(const Duration(days: 1)));
final dayHrv =
hrv.where((e) => e.rawEndTime >= start && e.rawEndTime < end).toList();
final dayRealtime = realtime
.where((e) => e.rawEndTime >= start && e.rawEndTime < end)
.toList();
final hrvAverage = _averageOrNull(dayHrv.map((e) => e.result).toList());
return _HrvDaySummary(
day: day,
hrvAverage: hrvAverage?.toDouble(),
hrAverage:
_averageOrNull(dayRealtime.map((e) => e.result).toList())?.toDouble(),
state: hrvAverage == null ? null : _hrvStateFromValue(hrvAverage),
);
}
List<HrvTrendList> _monthlyHrvTrend(List<_HrvDaySummary> daily) {
final byMonth = <int, List<_HrvDaySummary>>{};
for (final item in daily) {
byMonth.putIfAbsent(item.day.month, () => <_HrvDaySummary>[]).add(item);
}
return [
for (final entry in byMonth.entries)
HrvTrendList(
timeKey: entry.key,
hrvAverage: _averageOrNull(
entry.value.map((e) => e.hrvAverage).whereType<num>().toList(),
)?.toDouble(),
hrAverage: _averageOrNull(
entry.value.map((e) => e.hrAverage).whereType<num>().toList(),
)?.toDouble(),
state: _modeState(entry.value.map((e) => e.state).whereType<int>()),
),
];
}
Map<int, int> _hrvDistribution(List<_HrvDaySummary> daily) {
final result = <int, int>{};
for (final item in daily) {
final state = item.state;
if (state == null) continue;
result[state] = (result[state] ?? 0) + 1;
}
return result;
}
_HrvDaySummary? _extremeHrv(List<_HrvDaySummary> daily, {required bool min}) {
_HrvDaySummary? result;
for (final item in daily) {
if (item.hrvAverage == null) continue;
if (result == null) {
result = item;
continue;
}
if (min && item.hrvAverage! < result.hrvAverage!) result = item;
if (!min && item.hrvAverage! > result.hrvAverage!) result = item;
}
return result;
}
BestSleepInfo? _bestSleepInfo(List<_SleepDaySummary> daily) {
final item = daily.where((e) => e.score != null).fold<_SleepDaySummary?>(
null,
(best, item) =>
best == null || item.score! > best.score! ? item : best,
);
return item == null
? null
: BestSleepInfo(
timeKey: _dateKey(item.day),
totalTime: item.durationSeconds,
score: item.score,
);
}
WorstSleepInfo? _worstSleepInfo(List<_SleepDaySummary> daily) {
final item = daily.where((e) => e.score != null).fold<_SleepDaySummary?>(
null,
(worst, item) =>
worst == null || item.score! < worst.score! ? item : worst,
);
return item == null
? null
: WorstSleepInfo(
timeKey: _dateKey(item.day),
totalTime: item.durationSeconds,
score: item.score,
);
}
EarliestSleepInfo? _earliestSleepInfo(List<_SleepDaySummary> daily) {
final sorted = daily.where((e) => e.asleepTime != null).toList()
..sort((a, b) => a.asleepTime!.compareTo(b.asleepTime!));
final item = sorted.isEmpty ? null : sorted.first;
return item == null
? null
: EarliestSleepInfo(
timeKey: _dateKey(item.day),
asleepTime: item.asleepTime,
);
}
LatestSleepInfo? _latestSleepInfo(List<_SleepDaySummary> daily) {
final sorted = daily.where((e) => e.asleepTime != null).toList()
..sort((a, b) => b.asleepTime!.compareTo(a.asleepTime!));
final item = sorted.isEmpty ? null : sorted.first;
return item == null
? null
: LatestSleepInfo(
timeKey: _dateKey(item.day),
asleepTime: item.asleepTime,
);
}
_SleepSummary _sleepSummary(
List<HealthKitRawDataPoint> intervals,
int windowStart,
int windowEnd,
) {
var inBedMinutes = 0.0;
var asleepMinutes = 0.0;
var awakeMinutes = 0.0;
var coreMinutes = 0.0;
var deepMinutes = 0.0;
var remMinutes = 0.0;
var wakeCount = 0;
var earliestStart = windowEnd;
var latestEnd = windowStart;
for (final interval in intervals) {
final clippedStart = math.max(interval.startTime, windowStart);
final clippedEnd = math.min(interval.endTime, windowEnd);
final minutes = math.max(0, clippedEnd - clippedStart) / 60.0;
if (minutes <= 0) continue;
earliestStart = math.min(earliestStart, clippedStart);
latestEnd = math.max(latestEnd, clippedEnd);
switch (interval.dataType) {
case _sleepTypeInBed:
inBedMinutes += minutes;
break;
case _sleepTypeAwake:
awakeMinutes += minutes;
wakeCount += 1;
break;
case _sleepTypeAsleepCore:
coreMinutes += minutes;
asleepMinutes += minutes;
break;
case _sleepTypeAsleepDeep:
deepMinutes += minutes;
asleepMinutes += minutes;
break;
case _sleepTypeAsleepRem:
remMinutes += minutes;
asleepMinutes += minutes;
break;
case _sleepTypeAsleepUnspecified:
asleepMinutes += minutes;
break;
default:
asleepMinutes += minutes;
break;
}
}
if (inBedMinutes <= 0 && latestEnd > earliestStart) {
inBedMinutes = (latestEnd - earliestStart) / 60.0;
}
if (inBedMinutes <= 0) {
inBedMinutes = asleepMinutes + awakeMinutes;
}
return _SleepSummary(
timeInBedMinutes: inBedMinutes,
totalAsleepMinutes: asleepMinutes,
awakeMinutes: awakeMinutes,
coreMinutes: coreMinutes,
deepMinutes: deepMinutes,
remMinutes: remMinutes,
wakeCount: wakeCount,
sleepGoalMinutes: _sleepGoalMinutes,
);
}
int _sleepScore(_SleepSummary sleep) {
if (sleep.timeInBedMinutes <= 0 || sleep.totalAsleepMinutes <= 0) return 0;
final durationRatio = math.min(
sleep.totalAsleepMinutes / sleep.sleepGoalMinutes,
1.0,
);
final durationScore = durationRatio * 40;
final efficiency = sleep.totalAsleepMinutes / sleep.timeInBedMinutes;
final efficiencyScore = math.min(efficiency / 0.9, 1.0) * 25;
final deepRatio = sleep.deepMinutes / sleep.totalAsleepMinutes;
final deepScore = math.min(deepRatio / 0.18, 1.0) * 15;
final remRatio = sleep.remMinutes / sleep.totalAsleepMinutes;
final remScore = math.min(remRatio / 0.22, 1.0) * 10;
final awakePenalty = math.min(
sleep.wakeCount * 2 + sleep.awakeMinutes / 10,
10,
);
final rawScore =
durationScore + efficiencyScore + deepScore + remScore - awakePenalty;
return _mappedSleepScore(rawScore);
}
int _mappedSleepScore(num rawScore) {
final clamped = rawScore.clamp(0, 100).toDouble();
final mapped = switch (clamped) {
< 64 => clamped / 64 * 60,
< 74 => 60 + (clamped - 64) / 10 * 25,
_ => math.min(math.max(86, 85 + (clamped - 74) / 26 * 15), 100),
};
return mapped.round().clamp(0, 100);
}
num? _sleepEvaluate(int score) {
if (score <= 0) return null;
if (score >= 85) return 1;
if (score >= 60) return 2;
return 3;
}
bool _isAsleepSleepType(int type) {
return type == _sleepTypeAsleepUnspecified ||
type == _sleepTypeAsleepCore ||
type == _sleepTypeAsleepDeep ||
type == _sleepTypeAsleepRem;
}
int _hrvStateFromValue(num value) {
if (value >= 30) return 4;
if (value >= 21) return 3;
if (value >= 17) return 2;
return 1;
}
int? _modeState(Iterable<int> states) {
final counts = <int, int>{};
for (final state in states) {
counts[state] = (counts[state] ?? 0) + 1;
}
if (counts.isEmpty) return null;
return counts.entries.reduce((a, b) => a.value >= b.value ? a : b).key;
}
bool _isInIntervals(int time, List<HealthKitRawDataPoint> intervals) {
return intervals.any((e) => time > e.startTime && time <= e.endTime);
}
DateTime _dateFromKey(int key) {
final year = key ~/ 10000;
final month = (key ~/ 100) % 100;
final day = key % 100;
return DateTime(year, month, day);
}
DateTime _dayOf(int seconds) {
final date = DateTime.fromMillisecondsSinceEpoch(seconds * 1000);
return DateTime(date.year, date.month, date.day);
}
int _dateKey(DateTime date) =>
date.year * 10000 + date.month * 100 + date.day;
int _unixSeconds(DateTime date) => date.millisecondsSinceEpoch ~/ 1000;
num _sum(List<num?> values) {
return values.whereType<num>().fold<num>(0, (sum, value) => sum + value);
}
num? _averageOrNull(List<num> values) {
if (values.isEmpty) return null;
return _sum(values) / values.length;
}
num? _maxOrNull(List<num> values) {
if (values.isEmpty) return null;
return values.reduce(math.max);
}
num? _minOrNull(List<num> values) {
if (values.isEmpty) return null;
return values.reduce(math.min);
}
}
class _SleepDaySummary {
const _SleepDaySummary({
required this.day,
required this.durationSeconds,
required this.asleepTime,
required this.score,
required this.evaluate,
required this.sleepHr,
});
final DateTime day;
final num durationSeconds;
final num? asleepTime;
final num? score;
final num? evaluate;
final List<HealthKitRawDataPoint> sleepHr;
}
class _SleepSummary {
const _SleepSummary({
required this.timeInBedMinutes,
required this.totalAsleepMinutes,
required this.awakeMinutes,
required this.coreMinutes,
required this.deepMinutes,
required this.remMinutes,
required this.wakeCount,
required this.sleepGoalMinutes,
});
final double timeInBedMinutes;
final double totalAsleepMinutes;
final double awakeMinutes;
final double coreMinutes;
final double deepMinutes;
final double remMinutes;
final int wakeCount;
final double sleepGoalMinutes;
}
class _ActivityDaySummary {
const _ActivityDaySummary({
required this.day,
required this.move,
required this.steps,
required this.standHours,
required this.exerciseSeconds,
required this.moveGoal,
required this.stepGoal,
required this.standGoal,
required this.exerciseGoalSeconds,
});
final DateTime day;
final num move;
final num steps;
final num standHours;
final num exerciseSeconds;
final num? moveGoal;
final num? stepGoal;
final num? standGoal;
final num? exerciseGoalSeconds;
bool get hasGoal =>
moveGoal != null ||
stepGoal != null ||
standGoal != null ||
exerciseGoalSeconds != null;
}
class _HrvDaySummary {
const _HrvDaySummary({
required this.day,
required this.hrvAverage,
required this.hrAverage,
required this.state,
});
final DateTime day;
final double? hrvAverage;
final double? hrAverage;
final int? state;
}
... ...
import 'package:doublefeel_flutter/core/services/health_raw_data_core_service.dart';
import 'package:doublefeel_flutter/data/models/enums/app_enums.dart';
import 'package:doublefeel_flutter/pigeon/health_kit_api.g.dart';
import 'package:doublefeel_flutter/pigeon/health_kit_raw_data_api.g.dart';
import 'package:flutter_test/flutter_test.dart';
... ... @@ -37,6 +38,7 @@ void main() {
final store = _MemoryHealthRawStressLocalStore();
final service = HealthRawDataCoreService(
healthApi: _FakeHealthKitHostApi(),
rawDataApi: api,
localStore: store,
userIdProvider: () => 42,
... ... @@ -115,6 +117,25 @@ void main() {
isTrue,
);
});
test('startCoreCaculate skips raw reads when health auth is missing',
() async {
final api = _FakeHealthKitRawDataHostApi();
final service = HealthRawDataCoreService(
healthApi: _FakeHealthKitHostApi(status: 2),
rawDataApi: api,
localStore: _MemoryHealthRawStressLocalStore(),
userIdProvider: () => 42,
);
final result = await service.startCoreCaculate(readChunkDays: 1);
expect(result.hrvStressPoints, isEmpty);
expect(result.realtimeStressPoints, isEmpty);
expect(api.calls, isEmpty);
expect(api.sleepCallCount, 0);
expect(api.workoutCallCount, 0);
});
}
HealthKitRawDataPoint _point(
... ... @@ -135,6 +156,8 @@ class _FakeHealthKitRawDataHostApi extends HealthKitRawDataHostApi {
final Map<int, List<HealthKitRawDataPoint>> _pointsByType = {};
final List<HealthKitRawWorkoutDataPoint> _workoutPoints = [];
final List<_ReadCall> calls = [];
var sleepCallCount = 0;
var workoutCallCount = 0;
void setPoints(int dataType, List<HealthKitRawDataPoint> points) {
_pointsByType[dataType] = points;
... ... @@ -167,6 +190,7 @@ class _FakeHealthKitRawDataHostApi extends HealthKitRawDataHostApi {
int startTime,
int endTime,
) async {
sleepCallCount += 1;
return const <HealthKitRawSleepDataPoint>[];
}
... ... @@ -175,12 +199,24 @@ class _FakeHealthKitRawDataHostApi extends HealthKitRawDataHostApi {
int startTime,
int endTime,
) async {
workoutCallCount += 1;
return _workoutPoints
.where((e) => e.endTime >= startTime && e.startTime <= endTime)
.toList();
}
}
class _FakeHealthKitHostApi extends HealthKitHostApi {
_FakeHealthKitHostApi({this.status = 1});
final int status;
@override
Future<HealthAuthorization> checkHealthAppAuthorization() async {
return HealthAuthorization(status: status, hasData: status == 1);
}
}
class _ReadCall {
const _ReadCall(this.dataType, this.startTime, this.endTime);
... ...