|
|
|
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;
|
|
|
|
} |
...
|
...
|
|