Commit 18097cd429c71ce097d7bbc6d7375261a634ab72

Authored by 权海
1 parent d5b95558

feat(ui):修改睡眠截取跨天

import Foundation
final class HealthKitRawDataHostApiImpl: HealthKitRawDataHostApi {
private let service: HealthKitService
init(service: HealthKitService = .shared) {
... ... @@ -14,6 +13,13 @@ final class HealthKitRawDataHostApiImpl: HealthKitRawDataHostApi {
completion(.success(hasData))
}
}
func performHealthDataUpload(completion: @escaping (Result<Bool, any Error>) -> Void) {
Task {
let summary = await AnchoredHealthDataUploader.shared.uploadAll()
completion(.success(true))
}
}
func getHealthKitRawData(
dataType: Int64,
... ...
... ... @@ -10,7 +10,6 @@ import 'package:doublefeel_flutter/app/routes/app_pages.dart';
import 'package:doublefeel_flutter/core/constants/intent_keys.dart';
import 'package:doublefeel_flutter/core/logging/app_logger.dart';
import 'package:doublefeel_flutter/core/network/api/friend_api.dart';
import 'package:doublefeel_flutter/core/network/api/health_api.dart';
import 'package:doublefeel_flutter/core/network/api/pay_api.dart';
import 'package:doublefeel_flutter/core/network/api/vip_api.dart';
import 'package:doublefeel_flutter/core/result/app_result.dart';
... ... @@ -27,6 +26,7 @@ import 'package:doublefeel_flutter/data/models/local/user_preferences.dart';
import 'package:doublefeel_flutter/data/models/pay/pay_models.dart';
import 'package:doublefeel_flutter/l10n/l10n_extensions.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:doublefeel_flutter/pigeon/platform_api.g.dart';
import 'package:doublefeel_flutter/pigeon/wear_engine_api.g.dart';
import 'package:flutter/material.dart';
... ... @@ -73,6 +73,7 @@ class TodayController extends GetMaterialController {
final FriendApi _friendApi;
final UserStateService _userStateService;
final HealthKitHostApi _hostApi = HealthKitHostApi();
final HealthKitRawDataHostApi _rawDataApi = HealthKitRawDataHostApi();
final HealthRawDataCoreService _healthRawDataCoreService =
Get.find<HealthRawDataCoreService>();
... ... @@ -142,9 +143,6 @@ class TodayController extends GetMaterialController {
void onInit() {
super.onInit();
// 测试
unawaited(_startCoreCaculateForDebug());
targetFriendInfo.value = _initialFriendInfo;
final today = DateUtils.dateOnly(DateTime.now());
firstSelectableDay.value = DateUtils.dateOnly(
... ... @@ -184,18 +182,6 @@ class TodayController extends GetMaterialController {
}
}
Future<void> _startCoreCaculateForDebug() async {
try {
await _healthRawDataCoreService.startCoreCaculate();
} catch (error, stackTrace) {
AppLogger.e(
'TodayController.startCoreCaculate failed',
error,
stackTrace,
);
}
}
void checkAddFriendVisible() {
if (isFriend) {
showPartnerAdBanner.value = false;
... ... @@ -275,6 +261,7 @@ class TodayController extends GetMaterialController {
AppLogger.d("requestHealthClientAuthorization : $success");
if (success) {
// await LoadingService.instance.run(() async {
_rawDataApi.performHealthDataUpload();
_startCoreCaculate();
// });
// result.status = 1;
... ... @@ -290,6 +277,7 @@ class TodayController extends GetMaterialController {
return;
} else if (result.status == 1) {
// await LoadingService.instance.run(() async {
_rawDataApi.performHealthDataUpload();
_startCoreCaculate();
// });
if (isFromNoPermissionPage) {
... ...
... ... @@ -197,7 +197,7 @@ class ApiSleepReportDataSource implements SleepReportDataSource {
) {
final date = _parseDate(timeKey);
if (date == null) return null;
final asleepTime = _parseDateTime(asleepTimestamp, fallbackDate: date);
final asleepTime = _parseDateTime(asleepTimestamp);
if (asleepTime == null) return null;
final report = reports[date] ?? SleepReport.empty(date);
final heartRate = report.heartRate;
... ... @@ -246,10 +246,7 @@ class ApiSleepReportDataSource implements SleepReportDataSource {
asleepTrend = asleepTrends.first;
}
final asleepTime = _parseDateTime(
asleepTrend?.asleepTime,
fallbackDate: date,
);
final asleepTime = _parseDateTime(asleepTrend?.asleepTime);
final points = <SleepHeartRatePoint>[];
for (final item in data.hrList ?? const <SleepHrItem>[]) {
final time = _parseDateTime(item.time);
... ... @@ -301,8 +298,7 @@ class ApiSleepReportDataSource implements SleepReportDataSource {
for (final item
in data.asleepTimeTrendList ?? const <AsleepTimeTrendList>[])
if (_parseDate(item.timeKey) case final date?)
if (_parseDateTime(item.asleepTime, fallbackDate: date)
case final asleepTime?)
if (_parseDateTime(item.asleepTime) case final asleepTime?)
date: asleepTime,
};
final pointsByDate = <DateTime, List<SleepHeartRatePoint>>{};
... ...
... ... @@ -310,22 +310,7 @@ class SleepHeartRateCard extends StatelessWidget {
List<SleepHeartRatePoint> get _visibleDatePoints {
if (summary.points.isEmpty) return const <SleepHeartRatePoint>[];
final selectedDate = date;
final points = selectedDate == null
? summary.points
: summary.points.where((point) {
final dayStart = DateTime(
selectedDate.year,
selectedDate.month,
selectedDate.day,
);
final nextDay = dayStart.add(const Duration(days: 1));
return !point.time.isBefore(dayStart) &&
point.time.isBefore(nextDay);
}).toList();
final result = points.isEmpty ? summary.points : points;
return [...result]..sort((a, b) => a.time.compareTo(b.time));
return [...summary.points]..sort((a, b) => a.time.compareTo(b.time));
}
}
... ...
... ... @@ -6,6 +6,7 @@ import 'package:doublefeel_flutter/core/services/user_state_service.dart';
import 'package:doublefeel_flutter/data/local/user_account_storage.dart';
import 'package:doublefeel_flutter/data/local/user_preferences_storage.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:get/get.dart';
import 'package:permission_handler/permission_handler.dart';
... ... @@ -219,6 +220,7 @@ class UserOnboardingController extends GetxController {
/// 计算 Apple Health 新数据。
Future<void> _performDataUpload() async {
try {
HealthKitRawDataHostApi().performHealthDataUpload();
await Get.find<HealthRawDataCoreService>().startCoreCaculate();
} catch (error, stackTrace) {
AppLogger.e('Apple Health calculate failed', error, stackTrace);
... ...
import 'dart:io';
import 'package:path_provider/path_provider.dart';
import 'package:sqflite/sqflite.dart';
import '../../data/models/enums/app_enums.dart';
import '../../pigeon/health_kit_raw_data_api.g.dart';
import 'health_raw_data_core_service.dart';
import 'health_raw_stress_calculator.dart';
typedef FriendRawDataPageFetcher = Future<List<HealthKitRawDataPoint>>
Function({
required int userId,
required int dataType,
required int? startTime,
required int endTime,
required int page,
required int pageSize,
});
class FriendHealthRawDataCoreService {
FriendHealthRawDataCoreService({
Directory? rootDirectory,
DatabaseFactory? databaseFactory,
HealthRawStressLocalStore? stressLocalStore,
FriendRawDataPageFetcher? remoteRawDataPageFetcher,
}) : _rootDirectory = rootDirectory,
_databaseFactory = databaseFactory,
_stressLocalStore = stressLocalStore ?? HealthRawStressLocalStore(),
_remoteRawDataPageFetcher = remoteRawDataPageFetcher;
final Directory? _rootDirectory;
final DatabaseFactory? _databaseFactory;
final HealthRawStressLocalStore _stressLocalStore;
final FriendRawDataPageFetcher? _remoteRawDataPageFetcher;
final Map<int, Database> _opened = <int, Database>{};
Future<void> openDatabase(int userId) async {
await _database(userId);
}
Future<void> closeDatabase({int? userId}) async {
if (userId != null) {
final db = _opened.remove(userId);
if (db != null) {
await db.close();
}
return;
}
final databases = _opened.values.toList();
_opened.clear();
for (final db in databases) {
await db.close();
}
}
Future<String> rawDatabaseFilePath(int userId) async {
final dir = _rootDirectory ?? await getApplicationDocumentsDirectory();
return '${dir.path}/friend_health_raw_data_$userId.sqlite';
}
Future<void> storeRawData({
required int userId,
required int dataType,
required Iterable<HealthKitRawDataPoint> points,
}) async {
_checkUserId(userId);
final db = await _database(userId);
final table = _rawDataTable(dataType);
await _ensureRawDataTable(db, table);
final rows = points.toList()
..sort((a, b) => a.endTime.compareTo(b.endTime));
if (rows.isEmpty) return;
await db.transaction((txn) async {
for (final point in rows) {
await txn.insert(
table,
_rawDataRow(userId: userId, dataType: dataType, point: point),
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
});
}
Future<void> storeRawDataByType({
required int userId,
required Map<int, Iterable<HealthKitRawDataPoint>> pointsByType,
}) async {
for (final entry in pointsByType.entries) {
await storeRawData(
userId: userId,
dataType: entry.key,
points: entry.value,
);
}
}
Future<List<HealthKitRawDataPoint>> queryRawData({
required int userId,
required int dataType,
int? startTime,
required int endTime,
}) async {
_checkUserId(userId);
final db = await _database(userId);
final table = _rawDataTable(dataType);
await _ensureRawDataTable(db, table);
final where = startTime == null
? 'user_id = ? AND end_time <= ?'
: 'user_id = ? AND end_time >= ? AND end_time <= ?';
final whereArgs = startTime == null
? <Object?>[userId, endTime]
: <Object?>[userId, startTime, endTime];
final rows = await db.query(
table,
where: where,
whereArgs: whereArgs,
orderBy: 'end_time ASC',
);
return rows.map(_rawDataPointFromRow).toList();
}
Future<int?> latestRawDataEndTime({
required int userId,
required int dataType,
}) async {
_checkUserId(userId);
final db = await _database(userId);
final table = _rawDataTable(dataType);
await _ensureRawDataTable(db, table);
final rows = await db.query(
table,
columns: ['end_time'],
where: 'user_id = ?',
whereArgs: [userId],
orderBy: 'end_time DESC',
limit: 1,
);
if (rows.isEmpty) return null;
return rows.first['end_time'] as int;
}
Future<void> pullRemoteRawData({
required int userId,
required Iterable<int> readTypes,
int? endTime,
int pageSize = 500,
}) async {
_checkUserId(userId);
final effectiveEndTime =
endTime ?? DateTime.now().millisecondsSinceEpoch ~/ 1000;
for (final dataType in readTypes) {
final startTime = await latestRawDataEndTime(
userId: userId,
dataType: dataType,
);
var page = 1;
while (true) {
final points = await _fetchRemoteRawDataPage(
userId: userId,
dataType: dataType,
startTime: startTime,
endTime: effectiveEndTime,
page: page,
pageSize: pageSize,
);
if (points.isEmpty) break;
await storeRawData(
userId: userId,
dataType: dataType,
points: points,
);
if (points.length < pageSize) break;
page += 1;
}
}
}
Future<HealthRawStressCalculationResult> calculate({
required int userId,
required List<HealthKitRawDataPoint> hrvPoints,
required List<HealthKitRawDataPoint> heartRatePoints,
required List<HealthKitRawDataPoint> restingHeartRatePoints,
List<HealthKitRawDataPoint> sleepIntervals =
const <HealthKitRawDataPoint>[],
List<HealthKitRawWorkoutDataPoint> workoutIntervals =
const <HealthKitRawWorkoutDataPoint>[],
required int startTime,
required int endTime,
}) {
_checkUserId(userId);
return Future.value(HealthRawStressCalculator(userId: userId).calculate(
hrvPoints: hrvPoints,
heartRatePoints: heartRatePoints,
restingHeartRatePoints: restingHeartRatePoints,
sleepIntervals: sleepIntervals,
workoutIntervals: workoutIntervals,
startTime: startTime,
endTime: endTime,
));
}
Future<HealthRawStressCalculationResult> calculateStoredRawData({
required int userId,
required int startTime,
required int endTime,
List<HealthKitRawDataPoint> sleepIntervals =
const <HealthKitRawDataPoint>[],
List<HealthKitRawWorkoutDataPoint> workoutIntervals =
const <HealthKitRawWorkoutDataPoint>[],
}) async {
final hrvPoints = await queryRawData(
userId: userId,
dataType: HealthDataUploadType.hrv.type,
startTime: startTime,
endTime: endTime,
);
final heartRatePoints = await queryRawData(
userId: userId,
dataType: HealthDataUploadType.heartRate.type,
startTime: startTime,
endTime: endTime,
);
final restingHeartRatePoints = await queryRawData(
userId: userId,
dataType: HealthDataUploadType.restingHeartRate.type,
startTime: startTime,
endTime: endTime,
);
return calculate(
userId: userId,
hrvPoints: hrvPoints,
heartRatePoints: heartRatePoints,
restingHeartRatePoints: restingHeartRatePoints,
sleepIntervals: sleepIntervals,
workoutIntervals: workoutIntervals,
startTime: startTime,
endTime: endTime,
);
}
Future<HealthRawStressCalculationResult> calculateAndStoreStressResult({
required int userId,
required List<HealthKitRawDataPoint> hrvPoints,
required List<HealthKitRawDataPoint> heartRatePoints,
required List<HealthKitRawDataPoint> restingHeartRatePoints,
List<HealthKitRawDataPoint> sleepIntervals =
const <HealthKitRawDataPoint>[],
List<HealthKitRawWorkoutDataPoint> workoutIntervals =
const <HealthKitRawWorkoutDataPoint>[],
required int startTime,
required int endTime,
}) async {
final result = await calculate(
userId: userId,
hrvPoints: hrvPoints,
heartRatePoints: heartRatePoints,
restingHeartRatePoints: restingHeartRatePoints,
sleepIntervals: sleepIntervals,
workoutIntervals: workoutIntervals,
startTime: startTime,
endTime: endTime,
);
await _stressLocalStore.upsertResult(result);
return result;
}
Future<HealthRawStressCalculationResult>
calculateStoredRawDataAndStoreResult({
required int userId,
required int startTime,
required int endTime,
List<HealthKitRawDataPoint> sleepIntervals =
const <HealthKitRawDataPoint>[],
List<HealthKitRawWorkoutDataPoint> workoutIntervals =
const <HealthKitRawWorkoutDataPoint>[],
}) async {
final result = await calculateStoredRawData(
userId: userId,
startTime: startTime,
endTime: endTime,
sleepIntervals: sleepIntervals,
workoutIntervals: workoutIntervals,
);
await _stressLocalStore.upsertResult(result);
return result;
}
Future<List<HealthKitRawDataPoint>> _fetchRemoteRawDataPage({
required int userId,
required int dataType,
required int? startTime,
required int endTime,
required int page,
required int pageSize,
}) {
final fetcher = _remoteRawDataPageFetcher;
if (fetcher == null) {
return Future.value(const <HealthKitRawDataPoint>[]);
}
return fetcher(
userId: userId,
dataType: dataType,
startTime: startTime,
endTime: endTime,
page: page,
pageSize: pageSize,
);
}
Future<Database> _database(int userId) async {
_checkUserId(userId);
final existing = _opened[userId];
if (existing != null && existing.isOpen) return existing;
final path = await rawDatabaseFilePath(userId);
final parent = File(path).parent;
if (!await parent.exists()) {
await parent.create(recursive: true);
}
final factory = _databaseFactory ?? databaseFactory;
final db = await factory.openDatabase(
path,
options: OpenDatabaseOptions(version: 1),
);
_opened[userId] = db;
return db;
}
Future<void> _ensureRawDataTable(DatabaseExecutor db, String table) {
return db.execute('''
CREATE TABLE IF NOT EXISTS $table (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
data_type INTEGER NOT NULL,
start_time INTEGER NOT NULL,
end_time INTEGER NOT NULL,
value REAL,
is_motion_like INTEGER,
UNIQUE(user_id, data_type, start_time, end_time)
)
''');
}
String _rawDataTable(int dataType) {
if (dataType <= 0) {
throw ArgumentError.value(dataType, 'dataType');
}
return 'friend_raw_data_type_$dataType';
}
Map<String, Object?> _rawDataRow({
required int userId,
required int dataType,
required HealthKitRawDataPoint point,
}) {
return <String, Object?>{
'user_id': userId,
'data_type': dataType,
'start_time': point.startTime,
'end_time': point.endTime,
'value': point.value,
'is_motion_like': switch (point.isMotionLike) {
true => 1,
false => 0,
null => null,
},
};
}
HealthKitRawDataPoint _rawDataPointFromRow(Map<String, Object?> row) {
final isMotionLike = row['is_motion_like'] as int?;
return HealthKitRawDataPoint(
dataType: row['data_type'] as int,
startTime: row['start_time'] as int,
endTime: row['end_time'] as int,
value: (row['value'] as num?)?.toDouble(),
isMotionLike: isMotionLike == null ? null : isMotionLike == 1,
);
}
void _checkUserId(int userId) {
if (userId <= 0) {
throw ArgumentError.value(userId, 'userId');
}
}
}
... ...
... ... @@ -13,6 +13,7 @@ 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';
import 'health_raw_stress_calculator.dart';
class HealthRawDataCoreService {
static const int defaultLookbackDays = 183;
... ... @@ -469,50 +470,15 @@ class HealthRawDataCoreService {
required int startTime,
required int endTime,
}) {
final userId = _userId;
final marked = _markRawData(
return HealthRawStressCalculator(userId: _userId).calculate(
hrvPoints: hrvPoints,
heartRatePoints: heartRatePoints,
restingHeartRatePoints: restingHeartRatePoints,
sleepIntervals: sleepIntervals,
workoutIntervals: workoutIntervals,
);
final sortedHrv = marked.hrv.where((e) {
final value = e.value;
return value != null && _isValidHrv(value);
}).toList()
..sort((a, b) => a.endTime.compareTo(b.endTime));
final sortedHr = marked.heartRate.where((e) {
final value = e.value;
return value != null && _isValidHr(value);
}).toList()
..sort((a, b) => a.endTime.compareTo(b.endTime));
final sortedRestingHr = marked.restingHeartRate.where((e) {
final value = e.value;
return value != null && _isValidHr(value);
}).toList()
..sort((a, b) => a.endTime.compareTo(b.endTime));
final hrvStressPoints = _calculateHrvStressPoints(
userId: userId,
hrvPoints: sortedHrv,
heartRatePoints: sortedHr,
restingHeartRatePoints: sortedRestingHr,
);
final realtimeStressPoints = _calculateRealtimeStressPoints(
userId: userId,
heartRatePoints: sortedHr,
restingHeartRatePoints: sortedRestingHr,
hrvStressPoints: hrvStressPoints,
startTime: startTime,
endTime: endTime,
);
return HealthRawStressCalculationResult(
userId: userId,
hrvStressPoints: hrvStressPoints,
realtimeStressPoints: realtimeStressPoints,
);
}
Future<List<HealthKitRawDataPoint>> _fetchRawDataInChunks(
... ... @@ -568,527 +534,6 @@ class HealthRawDataCoreService {
return points;
}
HealthRawMarkedDataSet _markRawData({
required List<HealthKitRawDataPoint> hrvPoints,
required List<HealthKitRawDataPoint> heartRatePoints,
required List<HealthKitRawDataPoint> restingHeartRatePoints,
required List<HealthKitRawDataPoint> sleepIntervals,
required List<HealthKitRawWorkoutDataPoint> workoutIntervals,
}) {
final hrv = hrvPoints
.map((point) => HealthRawMarkedDataPoint(
raw: point,
flags: _flagsForPoint(
point,
sleepIntervals: sleepIntervals,
workoutIntervals: workoutIntervals,
),
))
.toList()
..sort((a, b) => a.endTime.compareTo(b.endTime));
final heartRate = heartRatePoints
.map((point) => HealthRawMarkedDataPoint(
raw: point,
flags: _flagsForPoint(
point,
sleepIntervals: sleepIntervals,
workoutIntervals: workoutIntervals,
),
))
.toList()
..sort((a, b) => a.endTime.compareTo(b.endTime));
final restingHeartRate = restingHeartRatePoints
.map((point) => HealthRawMarkedDataPoint(
raw: point,
flags: _flagsForPoint(
point,
sleepIntervals: sleepIntervals,
workoutIntervals: workoutIntervals,
),
))
.toList()
..sort((a, b) => a.endTime.compareTo(b.endTime));
return HealthRawMarkedDataSet(
hrv: hrv,
heartRate: heartRate,
restingHeartRate: restingHeartRate,
);
}
HealthRawPointFlags _flagsForPoint(
HealthKitRawDataPoint point, {
required List<HealthKitRawDataPoint> sleepIntervals,
required List<HealthKitRawWorkoutDataPoint> workoutIntervals,
}) {
final isWorkout = workoutIntervals.any(
(workout) =>
point.endTime > workout.startTime && point.endTime <= workout.endTime,
);
final isWorkoutRecovery = !isWorkout &&
workoutIntervals.any(
(workout) =>
point.endTime > workout.endTime &&
point.endTime <= workout.endTime + 30 * 60,
);
return HealthRawPointFlags(
isWorkout: isWorkout,
isWorkoutRecovery: isWorkoutRecovery,
isSleepLikely: sleepIntervals.any(
(sleep) =>
point.endTime > sleep.startTime && point.endTime <= sleep.endTime,
),
isSuspectedActivity: point.isMotionLike == true,
);
}
List<HealthRawHrvStressPoint> _calculateHrvStressPoints({
required int userId,
required List<HealthRawMarkedDataPoint> hrvPoints,
required List<HealthRawMarkedDataPoint> heartRatePoints,
required List<HealthRawMarkedDataPoint> restingHeartRatePoints,
}) {
final validHrvHistory = <HealthRawMarkedDataPoint>[];
final results = <HealthRawHrvStressPoint>[];
for (final point in hrvPoints) {
final pointValue = point.value!;
final awakeHrvHistory = _latestBefore(
validHrvHistory.where((e) => e.flags.isAwakeBaselineEligible).toList(),
point.endTime,
limit: 50,
);
final sleepHrvHistory = _latestBefore(
validHrvHistory
.where((e) => e.flags.isSleepHrvBaselineEligible)
.toList(),
point.endTime,
limit: 50,
);
final baselineAwakeHrv = _baseline(
awakeHrvHistory.map((e) => e.value!).toList(),
requiredCount: 50,
fallback: 50,
);
final isSleep = point.flags.isSleepLikely &&
!point.flags.isWorkout &&
!point.flags.isWorkoutRecovery;
final baselineSleepHrv = _baseline(
sleepHrvHistory.map((e) => e.value!).toList(),
requiredCount: 50,
fallback: baselineAwakeHrv,
);
final baselineHrv = isSleep ? baselineSleepHrv : baselineAwakeHrv;
final restingHistory = _latestBefore(
restingHeartRatePoints
.where((e) => e.flags.isAwakeHrBaselineEligible)
.toList(),
point.endTime,
limit: 50,
);
final baselineRestingHr = _baseline(
restingHistory.map((e) => e.value!).toList(),
requiredCount: 50,
fallback: 65,
);
final hrWindow = _pointsInRange(
heartRatePoints,
point.endTime - 5 * 60,
point.endTime,
).where((e) => e.flags.isCurrentAwakeHrEligible).toList();
final currentHr = _median(hrWindow.map((e) => e.value!).toList());
final adjustedHrvState = pointValue >= baselineHrv
? baselineHrv + (pointValue - baselineHrv) * 0.8
: baselineHrv + (pointValue - baselineHrv);
final double trendHrv;
if (currentHr != null) {
final hrRatio = currentHr / baselineRestingHr;
final double hrFactor;
if (hrRatio >= 1) {
hrFactor = _clamp(1 - 0.5 * (hrRatio - 1), 0.75, 1.10);
} else if (pointValue >= baselineHrv) {
hrFactor = _clamp(1 + 0.3 * (1 - hrRatio), 0.75, 1.10);
} else {
hrFactor = 1;
}
final hrvRatio = pointValue / baselineHrv;
final mismatch = math.max(hrRatio - hrvRatio, 0).toDouble();
final matchFactor = _clamp(1 - 0.5 * mismatch, 0.90, 1.00);
trendHrv = _clamp(
adjustedHrvState * hrFactor * matchFactor,
5,
baselineHrv * 1.8,
);
} else {
trendHrv = _clamp(adjustedHrvState, 5, baselineHrv * 1.8);
}
final sourceRange = _timeRange([
...awakeHrvHistory,
...sleepHrvHistory,
point,
...hrWindow,
...restingHistory,
]);
results.add(
HealthRawHrvStressPoint(
userId: userId,
rawEndTime: point.endTime,
rawHrv: pointValue,
result: trendHrv,
sourceStartTime: sourceRange.start,
sourceEndTime: sourceRange.end,
state: _hrvState(trendHrv),
baselineHrv: baselineHrv,
baselineAwakeHrv: baselineAwakeHrv,
baselineSleepHrv: isSleep ? baselineSleepHrv : null,
baselineRestingHr: baselineRestingHr,
flags: point.flags,
),
);
validHrvHistory.add(point);
}
return results;
}
List<HealthRawRealtimeStressPoint> _calculateRealtimeStressPoints({
required int userId,
required List<HealthRawMarkedDataPoint> heartRatePoints,
required List<HealthRawMarkedDataPoint> restingHeartRatePoints,
required List<HealthRawHrvStressPoint> hrvStressPoints,
required int startTime,
required int endTime,
}) {
final results = <HealthRawRealtimeStressPoint>[];
final days = _daysInRange(startTime, endTime);
double? previousAwakeStress;
int? previousAwakeStressTime;
double? previousSleepStress;
int? previousSleepStressTime;
double? awakeHrvAnchorStress;
double? sleepHrvAnchorStress;
for (final dayStart in days) {
final dayEnd = math.min(dayStart + Duration.secondsPerDay, endTime);
final dayHr = heartRatePoints
.where((e) => e.endTime >= dayStart && e.endTime <= dayEnd)
.toList();
if (dayHr.isEmpty) continue;
var pointTime = dayHr.first.endTime;
while (pointTime <= dayEnd) {
final hrWindow = _pointsInRange(
dayHr,
pointTime - 6 * 60 + 1,
pointTime,
);
final currentRawPoint = hrWindow.isEmpty ? null : hrWindow.last;
final context = currentRawPoint?.flags.context;
final contextWindow = context == null
? <HealthRawMarkedDataPoint>[]
: hrWindow.where((e) => e.flags.matchesContext(context)).toList();
final currentHr = _median(contextWindow.map((e) => e.value!).toList());
if (currentRawPoint != null && currentHr != null) {
final isSleep = context == HealthRawPointContext.sleep;
final isActivity = context == HealthRawPointContext.workout ||
context == HealthRawPointContext.workoutRecovery ||
context == HealthRawPointContext.suspectedActivity;
final awakeHistory = _latestBefore(
restingHeartRatePoints
.where((e) => e.flags.isAwakeHrBaselineEligible)
.toList(),
pointTime,
limit: 50,
);
final baselineAwakeHr = _baseline(
awakeHistory.map((e) => e.value!).toList(),
requiredCount: 50,
fallback: 65,
);
final sleepHistory = _latestBefore(
restingHeartRatePoints
.where((e) => e.flags.isSleepHrBaselineEligible)
.toList(),
pointTime,
limit: 50,
);
final baselineSleepHr = _sleepBaseline(
sleepHistory.map((e) => e.value!).toList(),
awakeHistoryCount: awakeHistory.length,
baselineAwakeHr: baselineAwakeHr,
);
final restingHistory = _latestBefore(
restingHeartRatePoints,
pointTime,
limit: 50,
);
final baselineRestingHr = isSleep ? baselineSleepHr : baselineAwakeHr;
final hrChange =
(currentHr - baselineRestingHr) / baselineRestingHr * 100;
final hrStress = _clamp(_hrStress(hrChange), 1, 100);
final smoothStress = isActivity
? hrStress
: _smoothedStress(
hrStress: hrStress,
pointTime: pointTime,
previousStress:
isSleep ? previousSleepStress : previousAwakeStress,
previousStressTime: isSleep
? previousSleepStressTime
: previousAwakeStressTime,
);
final latestHrv = isActivity
? null
: hrvStressPoints.lastWhereOrNull(
(e) =>
e.rawEndTime <= pointTime && e.flags.context == context,
);
final adjustment = _adjustedStress(
smoothStress: smoothStress,
pointTime: pointTime,
latestHrv: latestHrv,
hrvAnchorStress:
isSleep ? sleepHrvAnchorStress : awakeHrvAnchorStress,
);
if (adjustment.updatedAnchorStress != null) {
if (isSleep) {
sleepHrvAnchorStress = adjustment.updatedAnchorStress;
} else {
awakeHrvAnchorStress = adjustment.updatedAnchorStress;
}
}
final stress = _round(_clamp(adjustment.value, 1, 100), 1);
final sourceRange = _sourceRangeForRealtime(
hrWindow: hrWindow,
restingHistory: restingHistory,
latestHrv: latestHrv,
);
results.add(
HealthRawRealtimeStressPoint(
userId: userId,
rawEndTime: currentRawPoint.endTime,
result: stress,
sourceStartTime: sourceRange.start,
sourceEndTime: sourceRange.end,
flags: currentRawPoint.flags,
),
);
if (!isActivity) {
if (isSleep) {
previousSleepStress = stress;
previousSleepStressTime = pointTime;
} else {
previousAwakeStress = stress;
previousAwakeStressTime = pointTime;
}
}
}
pointTime += 6 * 60;
}
}
return results;
}
static ({int start, int end}) _sourceRangeForRealtime({
required List<HealthRawMarkedDataPoint> hrWindow,
required List<HealthRawMarkedDataPoint> restingHistory,
required HealthRawHrvStressPoint? latestHrv,
}) {
final pointRanges = _timeRange([...hrWindow, ...restingHistory]);
if (latestHrv == null) return pointRanges;
return (
start: math.min(pointRanges.start, latestHrv.sourceStartTime),
end: math.max(pointRanges.end, latestHrv.sourceEndTime),
);
}
static List<T> _latestBefore<T extends HealthRawMarkedDataPoint>(
List<T> points,
int time, {
required int limit,
}) {
final history = points.where((e) => e.endTime < time).toList();
if (history.length <= limit) return history;
return history.sublist(history.length - limit);
}
static List<T> _pointsInRange<T extends HealthRawMarkedDataPoint>(
List<T> points,
int start,
int end,
) {
return points.where((e) => e.endTime >= start && e.endTime <= end).toList();
}
static ({int start, int end}) _timeRange(
List<HealthRawMarkedDataPoint> points) {
if (points.isEmpty) return (start: 0, end: 0);
var start = points.first.startTime;
var end = points.first.endTime;
for (final point in points.skip(1)) {
start = math.min(start, point.startTime);
end = math.max(end, point.endTime);
}
return (start: start, end: end);
}
static bool _isValidHrv(double value) => value >= 5 && value <= 200;
static bool _isValidHr(double value) => value >= 30 && value <= 220;
static double _baseline(
List<double> values, {
required int requiredCount,
required double fallback,
}) {
if (values.length < requiredCount) return fallback;
final suffix = values.sublist(values.length - requiredCount);
return _median(suffix) ?? fallback;
}
static double _sleepBaseline(
List<double> sleepValues, {
required int awakeHistoryCount,
required double baselineAwakeHr,
}) {
if (sleepValues.length >= 50) {
return _median(sleepValues.sublist(sleepValues.length - 50)) ?? 65;
}
if (sleepValues.length >= 10) {
return _median(sleepValues) ?? 65;
}
return awakeHistoryCount >= 50 ? baselineAwakeHr : 65;
}
static double? _median(List<double> values) {
if (values.isEmpty) return null;
final sorted = [...values]..sort();
final middle = sorted.length ~/ 2;
if (sorted.length.isEven) {
return (sorted[middle - 1] + sorted[middle]) / 2;
}
return sorted[middle];
}
static double _hrStress(double hrChange) {
if (hrChange <= -20) return 5;
if (hrChange <= -10) return 5 + (hrChange + 20) * 0.9;
if (hrChange <= 0) return 14 + (hrChange + 10) * 1.4;
if (hrChange <= 10) return 28 + hrChange * 1.6;
if (hrChange <= 20) return 44 + (hrChange - 10) * 1.6;
if (hrChange <= 30) return 60 + (hrChange - 20) * 1.5;
if (hrChange <= 40) return 75 + (hrChange - 30) * 1.3;
if (hrChange < 55) return 88 + (hrChange - 40) * 0.8;
return 100;
}
static double _smoothedStress({
required double hrStress,
required int pointTime,
required double? previousStress,
required int? previousStressTime,
}) {
if (previousStress == null ||
previousStressTime == null ||
pointTime - previousStressTime > 12 * 60) {
return hrStress;
}
final stressDiff = (hrStress - previousStress).abs();
if (stressDiff < 30) {
return hrStress * 0.60 + previousStress * 0.40;
}
return hrStress * 0.80 + previousStress * 0.20;
}
static ({double value, double? updatedAnchorStress}) _adjustedStress({
required double smoothStress,
required int pointTime,
required HealthRawHrvStressPoint? latestHrv,
required double? hrvAnchorStress,
}) {
if (latestHrv == null) {
return (value: smoothStress, updatedAnchorStress: null);
}
final hrvAge = (pointTime - latestHrv.rawEndTime) / 60;
if (hrvAge >= 0 && hrvAge <= 6) {
final adjusted = switch (latestHrv.state) {
HealthRawStressState.excellent => _clamp(smoothStress, 1, 20.9),
HealthRawStressState.normal => _clamp(smoothStress, 21, 60.9),
HealthRawStressState.attention => _clamp(smoothStress, 61, 80.9),
HealthRawStressState.overload => _clamp(smoothStress, 81, 100),
};
return (value: adjusted, updatedAnchorStress: adjusted);
}
if (hrvAge > 6 && hrvAge <= 30) {
return switch (latestHrv.state) {
HealthRawStressState.excellent => (
value: math.min(smoothStress, 60.9).toDouble(),
updatedAnchorStress: null,
),
HealthRawStressState.normal => (
value: math.min(smoothStress, 80.9).toDouble(),
updatedAnchorStress: null,
),
HealthRawStressState.attention => (
value: math.max(smoothStress, 21).toDouble(),
updatedAnchorStress: null,
),
HealthRawStressState.overload => (
value: math.max(smoothStress, 61).toDouble(),
updatedAnchorStress: null,
),
};
}
if (hrvAge > 30 && hrvAge <= 60 && hrvAnchorStress != null) {
final hrvWeight = (60 - hrvAge) / 30 * 0.20;
return (
value: smoothStress * (1 - hrvWeight) + hrvAnchorStress * hrvWeight,
updatedAnchorStress: null,
);
}
return (value: smoothStress, updatedAnchorStress: null);
}
static HealthRawStressState _hrvState(double value) {
if (value >= 30) return HealthRawStressState.excellent;
if (value >= 21) return HealthRawStressState.normal;
if (value >= 17) return HealthRawStressState.attention;
return HealthRawStressState.overload;
}
static double _clamp(double value, double lower, double upper) {
return math.min(math.max(value, lower), upper).toDouble();
}
static double _round(double value, int places) {
final scale = math.pow(10, places).toDouble();
return (value * scale).round() / scale;
}
static List<int> _daysInRange(int startTime, int endTime) {
final start = DateTime.fromMillisecondsSinceEpoch(startTime * 1000);
final end = DateTime.fromMillisecondsSinceEpoch(endTime * 1000);
var day = DateTime(start.year, start.month, start.day);
final finalDay = DateTime(end.year, end.month, end.day);
final days = <int>[];
while (!day.isAfter(finalDay)) {
days.add(day.millisecondsSinceEpoch ~/ 1000);
day = day.add(const Duration(days: 1));
}
return days;
}
static int? _minNullable(int? a, int? b) {
if (a == null) return b;
if (b == null) return a;
... ... @@ -1779,13 +1224,3 @@ CREATE TABLE IF NOT EXISTS $realtimeStressResultsTable (
);
}
}
extension _IterableFirstWhereOrNull<T> on Iterable<T> {
T? lastWhereOrNull(bool Function(T element) test) {
T? result;
for (final element in this) {
if (test(element)) result = element;
}
return result;
}
}
... ...
import 'dart:math' as math;
import '../../pigeon/health_kit_raw_data_api.g.dart';
import 'health_raw_data_core_service.dart';
class HealthRawStressCalculator {
const HealthRawStressCalculator({required this.userId});
final int userId;
HealthRawStressCalculationResult calculate({
required List<HealthKitRawDataPoint> hrvPoints,
required List<HealthKitRawDataPoint> heartRatePoints,
required List<HealthKitRawDataPoint> restingHeartRatePoints,
List<HealthKitRawDataPoint> sleepIntervals =
const <HealthKitRawDataPoint>[],
List<HealthKitRawWorkoutDataPoint> workoutIntervals =
const <HealthKitRawWorkoutDataPoint>[],
required int startTime,
required int endTime,
}) {
final marked = _markRawData(
hrvPoints: hrvPoints,
heartRatePoints: heartRatePoints,
restingHeartRatePoints: restingHeartRatePoints,
sleepIntervals: sleepIntervals,
workoutIntervals: workoutIntervals,
);
final sortedHrv = marked.hrv.where((e) {
final value = e.value;
return value != null && _isValidHrv(value);
}).toList()
..sort((a, b) => a.endTime.compareTo(b.endTime));
final sortedHr = marked.heartRate.where((e) {
final value = e.value;
return value != null && _isValidHr(value);
}).toList()
..sort((a, b) => a.endTime.compareTo(b.endTime));
final sortedRestingHr = marked.restingHeartRate.where((e) {
final value = e.value;
return value != null && _isValidHr(value);
}).toList()
..sort((a, b) => a.endTime.compareTo(b.endTime));
final hrvStressPoints = _calculateHrvStressPoints(
hrvPoints: sortedHrv,
heartRatePoints: sortedHr,
restingHeartRatePoints: sortedRestingHr,
);
final realtimeStressPoints = _calculateRealtimeStressPoints(
heartRatePoints: sortedHr,
restingHeartRatePoints: sortedRestingHr,
hrvStressPoints: hrvStressPoints,
startTime: startTime,
endTime: endTime,
);
return HealthRawStressCalculationResult(
userId: userId,
hrvStressPoints: hrvStressPoints,
realtimeStressPoints: realtimeStressPoints,
);
}
HealthRawMarkedDataSet _markRawData({
required List<HealthKitRawDataPoint> hrvPoints,
required List<HealthKitRawDataPoint> heartRatePoints,
required List<HealthKitRawDataPoint> restingHeartRatePoints,
required List<HealthKitRawDataPoint> sleepIntervals,
required List<HealthKitRawWorkoutDataPoint> workoutIntervals,
}) {
final hrv = hrvPoints
.map((point) => HealthRawMarkedDataPoint(
raw: point,
flags: _flagsForPoint(
point,
sleepIntervals: sleepIntervals,
workoutIntervals: workoutIntervals,
),
))
.toList()
..sort((a, b) => a.endTime.compareTo(b.endTime));
final heartRate = heartRatePoints
.map((point) => HealthRawMarkedDataPoint(
raw: point,
flags: _flagsForPoint(
point,
sleepIntervals: sleepIntervals,
workoutIntervals: workoutIntervals,
),
))
.toList()
..sort((a, b) => a.endTime.compareTo(b.endTime));
final restingHeartRate = restingHeartRatePoints
.map((point) => HealthRawMarkedDataPoint(
raw: point,
flags: _flagsForPoint(
point,
sleepIntervals: sleepIntervals,
workoutIntervals: workoutIntervals,
),
))
.toList()
..sort((a, b) => a.endTime.compareTo(b.endTime));
return HealthRawMarkedDataSet(
hrv: hrv,
heartRate: heartRate,
restingHeartRate: restingHeartRate,
);
}
HealthRawPointFlags _flagsForPoint(
HealthKitRawDataPoint point, {
required List<HealthKitRawDataPoint> sleepIntervals,
required List<HealthKitRawWorkoutDataPoint> workoutIntervals,
}) {
final isWorkout = workoutIntervals.any(
(workout) =>
point.endTime > workout.startTime && point.endTime <= workout.endTime,
);
final isWorkoutRecovery = !isWorkout &&
workoutIntervals.any(
(workout) =>
point.endTime > workout.endTime &&
point.endTime <= workout.endTime + 30 * 60,
);
return HealthRawPointFlags(
isWorkout: isWorkout,
isWorkoutRecovery: isWorkoutRecovery,
isSleepLikely: sleepIntervals.any(
(sleep) =>
point.endTime > sleep.startTime && point.endTime <= sleep.endTime,
),
isSuspectedActivity: point.isMotionLike == true,
);
}
List<HealthRawHrvStressPoint> _calculateHrvStressPoints({
required List<HealthRawMarkedDataPoint> hrvPoints,
required List<HealthRawMarkedDataPoint> heartRatePoints,
required List<HealthRawMarkedDataPoint> restingHeartRatePoints,
}) {
final validHrvHistory = <HealthRawMarkedDataPoint>[];
final results = <HealthRawHrvStressPoint>[];
for (final point in hrvPoints) {
final pointValue = point.value!;
final awakeHrvHistory = _latestBefore(
validHrvHistory.where((e) => e.flags.isAwakeBaselineEligible).toList(),
point.endTime,
limit: 50,
);
final sleepHrvHistory = _latestBefore(
validHrvHistory
.where((e) => e.flags.isSleepHrvBaselineEligible)
.toList(),
point.endTime,
limit: 50,
);
final baselineAwakeHrv = _baseline(
awakeHrvHistory.map((e) => e.value!).toList(),
requiredCount: 50,
fallback: 50,
);
final isSleep = point.flags.isSleepLikely &&
!point.flags.isWorkout &&
!point.flags.isWorkoutRecovery;
final baselineSleepHrv = _baseline(
sleepHrvHistory.map((e) => e.value!).toList(),
requiredCount: 50,
fallback: baselineAwakeHrv,
);
final baselineHrv = isSleep ? baselineSleepHrv : baselineAwakeHrv;
final restingHistory = _latestBefore(
restingHeartRatePoints
.where((e) => e.flags.isAwakeHrBaselineEligible)
.toList(),
point.endTime,
limit: 50,
);
final baselineRestingHr = _baseline(
restingHistory.map((e) => e.value!).toList(),
requiredCount: 50,
fallback: 65,
);
final hrWindow = _pointsInRange(
heartRatePoints,
point.endTime - 5 * 60,
point.endTime,
).where((e) => e.flags.isCurrentAwakeHrEligible).toList();
final currentHr = _median(hrWindow.map((e) => e.value!).toList());
final adjustedHrvState = pointValue >= baselineHrv
? baselineHrv + (pointValue - baselineHrv) * 0.8
: baselineHrv + (pointValue - baselineHrv);
final double trendHrv;
if (currentHr != null) {
final hrRatio = currentHr / baselineRestingHr;
final double hrFactor;
if (hrRatio >= 1) {
hrFactor = _clamp(1 - 0.5 * (hrRatio - 1), 0.75, 1.10);
} else if (pointValue >= baselineHrv) {
hrFactor = _clamp(1 + 0.3 * (1 - hrRatio), 0.75, 1.10);
} else {
hrFactor = 1;
}
final hrvRatio = pointValue / baselineHrv;
final mismatch = math.max(hrRatio - hrvRatio, 0).toDouble();
final matchFactor = _clamp(1 - 0.5 * mismatch, 0.90, 1.00);
trendHrv = _clamp(
adjustedHrvState * hrFactor * matchFactor,
5,
baselineHrv * 1.8,
);
} else {
trendHrv = _clamp(adjustedHrvState, 5, baselineHrv * 1.8);
}
final sourceRange = _timeRange([
...awakeHrvHistory,
...sleepHrvHistory,
point,
...hrWindow,
...restingHistory,
]);
results.add(
HealthRawHrvStressPoint(
userId: userId,
rawEndTime: point.endTime,
rawHrv: pointValue,
result: trendHrv,
sourceStartTime: sourceRange.start,
sourceEndTime: sourceRange.end,
state: _hrvState(trendHrv),
baselineHrv: baselineHrv,
baselineAwakeHrv: baselineAwakeHrv,
baselineSleepHrv: isSleep ? baselineSleepHrv : null,
baselineRestingHr: baselineRestingHr,
flags: point.flags,
),
);
validHrvHistory.add(point);
}
return results;
}
List<HealthRawRealtimeStressPoint> _calculateRealtimeStressPoints({
required List<HealthRawMarkedDataPoint> heartRatePoints,
required List<HealthRawMarkedDataPoint> restingHeartRatePoints,
required List<HealthRawHrvStressPoint> hrvStressPoints,
required int startTime,
required int endTime,
}) {
final results = <HealthRawRealtimeStressPoint>[];
final days = _daysInRange(startTime, endTime);
double? previousAwakeStress;
int? previousAwakeStressTime;
double? previousSleepStress;
int? previousSleepStressTime;
double? awakeHrvAnchorStress;
double? sleepHrvAnchorStress;
for (final dayStart in days) {
final dayEnd = math.min(dayStart + Duration.secondsPerDay, endTime);
final dayHr = heartRatePoints
.where((e) => e.endTime >= dayStart && e.endTime <= dayEnd)
.toList();
if (dayHr.isEmpty) continue;
var pointTime = dayHr.first.endTime;
while (pointTime <= dayEnd) {
final hrWindow = _pointsInRange(
dayHr,
pointTime - 6 * 60 + 1,
pointTime,
);
final currentRawPoint = hrWindow.isEmpty ? null : hrWindow.last;
final context = currentRawPoint?.flags.context;
final contextWindow = context == null
? <HealthRawMarkedDataPoint>[]
: hrWindow.where((e) => e.flags.matchesContext(context)).toList();
final currentHr = _median(contextWindow.map((e) => e.value!).toList());
if (currentRawPoint != null && currentHr != null) {
final isSleep = context == HealthRawPointContext.sleep;
final isActivity = context == HealthRawPointContext.workout ||
context == HealthRawPointContext.workoutRecovery ||
context == HealthRawPointContext.suspectedActivity;
final awakeHistory = _latestBefore(
restingHeartRatePoints
.where((e) => e.flags.isAwakeHrBaselineEligible)
.toList(),
pointTime,
limit: 50,
);
final baselineAwakeHr = _baseline(
awakeHistory.map((e) => e.value!).toList(),
requiredCount: 50,
fallback: 65,
);
final sleepHistory = _latestBefore(
restingHeartRatePoints
.where((e) => e.flags.isSleepHrBaselineEligible)
.toList(),
pointTime,
limit: 50,
);
final baselineSleepHr = _sleepBaseline(
sleepHistory.map((e) => e.value!).toList(),
awakeHistoryCount: awakeHistory.length,
baselineAwakeHr: baselineAwakeHr,
);
final restingHistory = _latestBefore(
restingHeartRatePoints,
pointTime,
limit: 50,
);
final baselineRestingHr = isSleep ? baselineSleepHr : baselineAwakeHr;
final hrChange =
(currentHr - baselineRestingHr) / baselineRestingHr * 100;
final hrStress = _clamp(_hrStress(hrChange), 1, 100);
final smoothStress = isActivity
? hrStress
: _smoothedStress(
hrStress: hrStress,
pointTime: pointTime,
previousStress:
isSleep ? previousSleepStress : previousAwakeStress,
previousStressTime: isSleep
? previousSleepStressTime
: previousAwakeStressTime,
);
final latestHrv = isActivity
? null
: _lastHrvStressWhere(
hrvStressPoints,
(e) =>
e.rawEndTime <= pointTime && e.flags.context == context,
);
final adjustment = _adjustedStress(
smoothStress: smoothStress,
pointTime: pointTime,
latestHrv: latestHrv,
hrvAnchorStress:
isSleep ? sleepHrvAnchorStress : awakeHrvAnchorStress,
);
if (adjustment.updatedAnchorStress != null) {
if (isSleep) {
sleepHrvAnchorStress = adjustment.updatedAnchorStress;
} else {
awakeHrvAnchorStress = adjustment.updatedAnchorStress;
}
}
final stress = _round(_clamp(adjustment.value, 1, 100), 1);
final sourceRange = _sourceRangeForRealtime(
hrWindow: hrWindow,
restingHistory: restingHistory,
latestHrv: latestHrv,
);
results.add(
HealthRawRealtimeStressPoint(
userId: userId,
rawEndTime: currentRawPoint.endTime,
result: stress,
sourceStartTime: sourceRange.start,
sourceEndTime: sourceRange.end,
flags: currentRawPoint.flags,
),
);
if (!isActivity) {
if (isSleep) {
previousSleepStress = stress;
previousSleepStressTime = pointTime;
} else {
previousAwakeStress = stress;
previousAwakeStressTime = pointTime;
}
}
}
pointTime += 6 * 60;
}
}
return results;
}
static ({int start, int end}) _sourceRangeForRealtime({
required List<HealthRawMarkedDataPoint> hrWindow,
required List<HealthRawMarkedDataPoint> restingHistory,
required HealthRawHrvStressPoint? latestHrv,
}) {
final pointRanges = _timeRange([...hrWindow, ...restingHistory]);
if (latestHrv == null) return pointRanges;
return (
start: math.min(pointRanges.start, latestHrv.sourceStartTime),
end: math.max(pointRanges.end, latestHrv.sourceEndTime),
);
}
static List<T> _latestBefore<T extends HealthRawMarkedDataPoint>(
List<T> points,
int time, {
required int limit,
}) {
final history = points.where((e) => e.endTime < time).toList();
if (history.length <= limit) return history;
return history.sublist(history.length - limit);
}
static List<T> _pointsInRange<T extends HealthRawMarkedDataPoint>(
List<T> points,
int start,
int end,
) {
return points.where((e) => e.endTime >= start && e.endTime <= end).toList();
}
static ({int start, int end}) _timeRange(
List<HealthRawMarkedDataPoint> points) {
if (points.isEmpty) return (start: 0, end: 0);
var start = points.first.startTime;
var end = points.first.endTime;
for (final point in points.skip(1)) {
start = math.min(start, point.startTime);
end = math.max(end, point.endTime);
}
return (start: start, end: end);
}
static bool _isValidHrv(double value) => value >= 5 && value <= 200;
static bool _isValidHr(double value) => value >= 30 && value <= 220;
static double _baseline(
List<double> values, {
required int requiredCount,
required double fallback,
}) {
if (values.length < requiredCount) return fallback;
final suffix = values.sublist(values.length - requiredCount);
return _median(suffix) ?? fallback;
}
static double _sleepBaseline(
List<double> sleepValues, {
required int awakeHistoryCount,
required double baselineAwakeHr,
}) {
if (sleepValues.length >= 50) {
return _median(sleepValues.sublist(sleepValues.length - 50)) ?? 65;
}
if (sleepValues.length >= 10) {
return _median(sleepValues) ?? 65;
}
return awakeHistoryCount >= 50 ? baselineAwakeHr : 65;
}
static double? _median(List<double> values) {
if (values.isEmpty) return null;
final sorted = [...values]..sort();
final middle = sorted.length ~/ 2;
if (sorted.length.isEven) {
return (sorted[middle - 1] + sorted[middle]) / 2;
}
return sorted[middle];
}
static double _hrStress(double hrChange) {
if (hrChange <= -20) return 5;
if (hrChange <= -10) return 5 + (hrChange + 20) * 0.9;
if (hrChange <= 0) return 14 + (hrChange + 10) * 1.4;
if (hrChange <= 10) return 28 + hrChange * 1.6;
if (hrChange <= 20) return 44 + (hrChange - 10) * 1.6;
if (hrChange <= 30) return 60 + (hrChange - 20) * 1.5;
if (hrChange <= 40) return 75 + (hrChange - 30) * 1.3;
if (hrChange < 55) return 88 + (hrChange - 40) * 0.8;
return 100;
}
static double _smoothedStress({
required double hrStress,
required int pointTime,
required double? previousStress,
required int? previousStressTime,
}) {
if (previousStress == null ||
previousStressTime == null ||
pointTime - previousStressTime > 12 * 60) {
return hrStress;
}
final stressDiff = (hrStress - previousStress).abs();
if (stressDiff < 30) {
return hrStress * 0.60 + previousStress * 0.40;
}
return hrStress * 0.80 + previousStress * 0.20;
}
static ({double value, double? updatedAnchorStress}) _adjustedStress({
required double smoothStress,
required int pointTime,
required HealthRawHrvStressPoint? latestHrv,
required double? hrvAnchorStress,
}) {
if (latestHrv == null) {
return (value: smoothStress, updatedAnchorStress: null);
}
final hrvAge = (pointTime - latestHrv.rawEndTime) / 60;
if (hrvAge >= 0 && hrvAge <= 6) {
final adjusted = switch (latestHrv.state) {
HealthRawStressState.excellent => _clamp(smoothStress, 1, 20.9),
HealthRawStressState.normal => _clamp(smoothStress, 21, 60.9),
HealthRawStressState.attention => _clamp(smoothStress, 61, 80.9),
HealthRawStressState.overload => _clamp(smoothStress, 81, 100),
};
return (value: adjusted, updatedAnchorStress: adjusted);
}
if (hrvAge > 6 && hrvAge <= 30) {
return switch (latestHrv.state) {
HealthRawStressState.excellent => (
value: math.min(smoothStress, 60.9).toDouble(),
updatedAnchorStress: null,
),
HealthRawStressState.normal => (
value: math.min(smoothStress, 80.9).toDouble(),
updatedAnchorStress: null,
),
HealthRawStressState.attention => (
value: math.max(smoothStress, 21).toDouble(),
updatedAnchorStress: null,
),
HealthRawStressState.overload => (
value: math.max(smoothStress, 61).toDouble(),
updatedAnchorStress: null,
),
};
}
if (hrvAge > 30 && hrvAge <= 60 && hrvAnchorStress != null) {
final hrvWeight = (60 - hrvAge) / 30 * 0.20;
return (
value: smoothStress * (1 - hrvWeight) + hrvAnchorStress * hrvWeight,
updatedAnchorStress: null,
);
}
return (value: smoothStress, updatedAnchorStress: null);
}
static HealthRawStressState _hrvState(double value) {
if (value >= 30) return HealthRawStressState.excellent;
if (value >= 21) return HealthRawStressState.normal;
if (value >= 17) return HealthRawStressState.attention;
return HealthRawStressState.overload;
}
static double _clamp(double value, double lower, double upper) {
return math.min(math.max(value, lower), upper).toDouble();
}
static double _round(double value, int places) {
final scale = math.pow(10, places).toDouble();
return (value * scale).round() / scale;
}
static List<int> _daysInRange(int startTime, int endTime) {
final start = DateTime.fromMillisecondsSinceEpoch(startTime * 1000);
final end = DateTime.fromMillisecondsSinceEpoch(endTime * 1000);
var day = DateTime(start.year, start.month, start.day);
final finalDay = DateTime(end.year, end.month, end.day);
final days = <int>[];
while (!day.isAfter(finalDay)) {
days.add(day.millisecondsSinceEpoch ~/ 1000);
day = day.add(const Duration(days: 1));
}
return days;
}
static HealthRawHrvStressPoint? _lastHrvStressWhere(
Iterable<HealthRawHrvStressPoint> points,
bool Function(HealthRawHrvStressPoint point) test,
) {
HealthRawHrvStressPoint? result;
for (final point in points) {
if (test(point)) result = point;
}
return result;
}
}
... ...
... ... @@ -30,7 +30,6 @@ class HealthDataSourceWrapper implements HealthDataSource {
if (queryUserId == null || _myUserId == queryUserId) {
dataSource = localDataSource;
}
return dataSource.getSleepStatistics(
dateRangeType,
startDate,
... ...
... ... @@ -15,6 +15,7 @@ const _sleepTypeAsleepCore = 3;
const _sleepTypeAsleepDeep = 4;
const _sleepTypeAsleepRem = 5;
const _sleepGoalMinutes = 8 * 60.0;
const _sleepContinuityToleranceSeconds = 1;
class LocalHealthDataConvert {
const LocalHealthDataConvert._();
... ... @@ -50,8 +51,9 @@ class LocalHealthDataConvert {
final dayStart = unixSeconds(day);
final dayEnd = unixSeconds(day.add(const Duration(days: 1)));
final queryStart = unixSeconds(day.subtract(const Duration(days: 1)));
final queryEnd = unixSeconds(day.add(const Duration(days: 2)));
final candidates = sleepIntervals
.where((e) => e.endTime > queryStart && e.startTime < dayEnd)
.where((e) => e.endTime > queryStart && e.startTime < queryEnd)
.toList()
..sort((a, b) {
final startCompare = a.startTime.compareTo(b.startTime);
... ... @@ -60,33 +62,28 @@ class LocalHealthDataConvert {
});
if (candidates.isEmpty) return const <HealthKitRawDataPoint>[];
final anchorIndex = candidates.indexWhere(
(e) => e.endTime > dayStart && e.startTime < dayEnd,
);
if (anchorIndex < 0) return const <HealthKitRawDataPoint>[];
var rangeStart = candidates[anchorIndex].startTime;
var rangeEnd = candidates[anchorIndex].endTime;
var expanded = true;
while (expanded) {
expanded = false;
for (final interval in candidates) {
final isConnected =
interval.startTime <= rangeEnd && interval.endTime >= rangeStart;
if (!isConnected) continue;
final nextStart = math.min(rangeStart, interval.startTime);
final nextEnd = math.max(rangeEnd, interval.endTime);
if (nextStart != rangeStart || nextEnd != rangeEnd) {
rangeStart = nextStart;
rangeEnd = nextEnd;
expanded = true;
}
final groups = <List<HealthKitRawDataPoint>>[];
for (final interval in candidates) {
if (groups.isEmpty) {
groups.add([interval]);
continue;
}
final latestGroup = groups.last;
final latestEnd = latestGroup.map((e) => e.endTime).reduce(math.max);
if (interval.startTime <= latestEnd + _sleepContinuityToleranceSeconds) {
latestGroup.add(interval);
} else {
groups.add([interval]);
}
}
return candidates
.where((e) => e.endTime > rangeStart && e.startTime < rangeEnd)
.toList();
for (final group in groups) {
final groupEnd = group.map((e) => e.endTime).reduce(math.max);
if (groupEnd > dayStart && groupEnd <= dayEnd) {
return group;
}
}
return const <HealthKitRawDataPoint>[];
}
static HealthKitRawDataPoint? mergeContinuousSleepIntervals(
... ... @@ -181,6 +178,7 @@ class LocalHealthDataConvert {
latestSleepInfo: _latestSleepInfo(validSleep),
hrList: shouldIncludeHeartRate
? [
// SleepHrItem(time: 1784091398, value: 63),
for (final point in allSleepHr)
SleepHrItem(time: point.endTime, value: point.value),
]
... ... @@ -476,11 +474,13 @@ class LocalHealthDataConvert {
point.endTime > interval.startTime &&
point.endTime <= interval.endTime))
.toList();
final sleepDuration = windowEnd - windowStart;
return _SleepDaySummary(
day: day,
sleepIntervals: intervals,
mergeSleepTimeRange: mergedInterval,
durationSeconds: (summary.totalAsleepMinutes * 60).round(),
durationSeconds: sleepDuration.round(),
asleepTime: intervals.isEmpty
? null
: intervals.map((e) => e.startTime).reduce(math.min),
... ...
import 'package:doublefeel_flutter/data/datasource/health/health_local_data_convert.dart';
import 'package:doublefeel_flutter/pigeon/health_kit_raw_data_api.g.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('LocalHealthDataConvert sleep intervals', () {
test('keeps previous-day sleep start for next-day sleep statistics', () {
final july15 = DateTime(2026, 7, 15);
final july16 = DateTime(2026, 7, 16);
final sleepStart = DateTime(2026, 7, 15, 23, 56);
final midnight = DateTime(2026, 7, 16);
final sleepEnd = DateTime(2026, 7, 16, 7);
final intervals = [
HealthKitRawDataPoint(
dataType: 3,
startTime: LocalHealthDataConvert.unixSeconds(sleepStart),
endTime: LocalHealthDataConvert.unixSeconds(midnight),
),
HealthKitRawDataPoint(
dataType: 3,
startTime: LocalHealthDataConvert.unixSeconds(midnight),
endTime: LocalHealthDataConvert.unixSeconds(sleepEnd),
),
];
final previousDayIntervals =
LocalHealthDataConvert.completeSleepIntervalsForDay(
july15,
intervals,
);
final currentDayIntervals =
LocalHealthDataConvert.completeSleepIntervalsForDay(
july16,
intervals,
);
final statistics = LocalHealthDataConvert.sleepStatistics(
dateRangeType: 3,
days: [july16],
previousDays: const [],
sleepIntervals: intervals,
heartRate: const [],
);
expect(previousDayIntervals, isEmpty);
expect(currentDayIntervals, hasLength(2));
expect(
currentDayIntervals.first.startTime,
LocalHealthDataConvert.unixSeconds(sleepStart),
);
expect(
statistics.asleepTimeTrendList?.single.asleepTime,
LocalHealthDataConvert.unixSeconds(sleepStart),
);
expect(
statistics.sleepTrendList?.single.totalTime,
const Duration(hours: 7, minutes: 4).inSeconds,
);
});
});
}
... ...