Commit bae130e9a08c2b51ecfe1bf862995ef7a9468363

Authored by 权海
1 parent a5aac34f

feat(ui):增加几种计算结果的上传

... ... @@ -5,6 +5,7 @@ enum HealthRawStressUploadKind {
case hrv
case realtimeStress
case dailyStress
case sleepScore
}
enum HealthRawStressSQLiteUploadError: LocalizedError {
... ... @@ -117,16 +118,17 @@ final class HealthRawStressSQLiteUploader {
return uploadedUntil
}
func uploadDailyStress(sqliteFilePath: String) async throws -> Bool {
func uploadDailyStress(sqliteFilePath: String) async throws -> Int64 {
let rows = try queryRows(
sqliteFilePath: sqliteFilePath,
sql: """
SELECT stress_value, stress_score, state, data_time
SELECT date, stress_value, stress_score, state, data_time
FROM daily_stress_results
WHERE uploaded != 1
ORDER BY date ASC
"""
)
var uploadedUntil: Int64 = 0
for batch in batches(rows) {
let list = batch.map { row in
[
... ... @@ -140,8 +142,40 @@ final class HealthRawStressSQLiteUploader {
path: "/client/doublefeel/health/v2/stress_score/",
body: ["data_list": list]
)
uploadedUntil = batch.last?.int64("date") ?? uploadedUntil
}
return uploadedUntil
}
func uploadSleepScore(sqliteFilePath: String) async throws -> Int64 {
let rows = try queryRows(
sqliteFilePath: sqliteFilePath,
sql: """
SELECT date, sleep_score, sleep_state, in_bed_minutes, awak_minutes, sleep_minutes
FROM sleep_results
WHERE uploaded != 1
ORDER BY date ASC
"""
)
var uploadedUntil: Int64 = 0
for batch in batches(rows) {
let list = batch.map { row in
[
"date": row.int64("date"),
"sleep_score": row.int("sleep_score"),
"sleep_state": row.int("sleep_state"),
"in_bed_minutes": row.int("in_bed_minutes"),
"awak_minutes": row.int("awak_minutes"),
"sleep_minutes": row.int("sleep_minutes"),
] as [String: Any]
}
try await upload(
path: "/client/doublefeel/health/v2/sleep_score/",
body: ["data_list": list]
)
uploadedUntil = batch.last?.int64("date") ?? uploadedUntil
}
return true
return uploadedUntil
}
private func queryRows(sqliteFilePath: String, sql: String) throws -> [SQLiteRow] {
... ...
... ... @@ -374,8 +374,10 @@ protocol HealthKitRawDataHostApi {
func performHRDataUpload(sqliteFilePath: String, completion: @escaping (Result<Int64, Error>) -> Void)
/// 上传HRV - HRV压力数据, 返回上传截止的时间戳,方便flutter 修改数据库
func performHRVDataUpload(sqliteFilePath: String, completion: @escaping (Result<Int64, Error>) -> Void)
/// 上传睡眠统计数据
func performSleepAnalysisDataUpload(sqliteFilePath: String, completion: @escaping (Result<Int64, Error>) -> Void)
/// 上传当前实时压力(统计维度是当天)
func performAvgRealtimeStressDataUpload(sqliteFilePath: String, completion: @escaping (Result<Bool, Error>) -> Void)
func performAvgRealtimeStressDataUpload(sqliteFilePath: String, completion: @escaping (Result<Int64, Error>) -> Void)
/// 从从AppleHealth中读取睡眠数据
func getHealthKitRawSleepData(startTime: Int64, endTime: Int64, completion: @escaping (Result<[HealthKitRawSleepDataPoint], Error>) -> Void)
/// 从从AppleHealth中读取活动统计数据
... ... @@ -478,6 +480,24 @@ class HealthKitRawDataHostApiSetup {
} else {
performHRVDataUploadChannel.setMessageHandler(nil)
}
/// 上传睡眠统计数据
let performSleepAnalysisDataUploadChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.doublefeel_flutter.HealthKitRawDataHostApi.performSleepAnalysisDataUpload\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
performSleepAnalysisDataUploadChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
let sqliteFilePathArg = args[0] as! String
api.performSleepAnalysisDataUpload(sqliteFilePath: sqliteFilePathArg) { result in
switch result {
case .success(let res):
reply(wrapResult(res))
case .failure(let error):
reply(wrapError(error))
}
}
}
} else {
performSleepAnalysisDataUploadChannel.setMessageHandler(nil)
}
/// 上传当前实时压力(统计维度是当天)
let performAvgRealtimeStressDataUploadChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.doublefeel_flutter.HealthKitRawDataHostApi.performAvgRealtimeStressDataUpload\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
... ...
... ... @@ -46,12 +46,24 @@ final class HealthKitRawDataHostApiImpl: HealthKitRawDataHostApi {
}
}
func performAvgRealtimeStressDataUpload(sqliteFilePath: String, completion: @escaping (Result<Bool, any Error>) -> Void) {
func performAvgRealtimeStressDataUpload(sqliteFilePath: String, completion: @escaping (Result<Int64, any Error>) -> Void) {
print("trigger AvgRealtimeStressDataUpload")
Task {
do {
let success = try await HealthRawStressSQLiteUploader.shared.uploadDailyStress(sqliteFilePath: sqliteFilePath)
completion(.success(success))
let uploadedUntil = try await HealthRawStressSQLiteUploader.shared.uploadDailyStress(sqliteFilePath: sqliteFilePath)
completion(.success(uploadedUntil))
} catch {
completion(.failure(error))
}
}
}
func performSleepAnalysisDataUpload(sqliteFilePath: String, completion: @escaping (Result<Int64, any Error>) -> Void) {
print("trigger SleepAnalysisDataUpload")
Task {
do {
let uploadedUntil = try await HealthRawStressSQLiteUploader.shared.uploadSleepScore(sqliteFilePath: sqliteFilePath)
completion(.success(uploadedUntil))
} catch {
completion(.failure(error))
}
... ... @@ -118,7 +130,6 @@ final class HealthKitRawDataHostApiImpl: HealthKitRawDataHostApi {
completion(.success([]))
return
}
print("trigger getHealthKitRawSleepData back: \(points)")
completion(.success([
HealthKitRawSleepDataPoint(
dataType: Int64(NativeHealthDataType.sleep.rawValue),
... ...
... ... @@ -14,6 +14,7 @@ import '../config/app_environment_config.dart';
import '../logging/app_logger.dart';
import '../util/app_toast.dart';
import 'health_raw_stress_calculator.dart';
import 'health_sleep_calculator.dart';
class HealthRawDataCoreService {
static const int defaultLookbackDays = 183;
... ... @@ -48,6 +49,7 @@ class HealthRawDataCoreService {
bool _isUploadingHrvResults = false;
bool _isUploadingRealtimeStressResults = false;
bool _isUploadingDailyStressResults = false;
bool _isUploadingSleepResults = false;
int get _userId {
final userId = _userIdProvider?.call() ?? 0;
... ... @@ -222,6 +224,12 @@ class HealthRawDataCoreService {
final hrvContextStart = await _localStore.latestHrvSourceStartTime(userId);
final realtimeContextStart =
await _localStore.latestRealtimeSourceStartTime(userId);
final latestHrvRawEndTime = await _localStore.latestHrvRawEndTime(userId);
final latestRealtimeRawEndTime =
await _localStore.latestRealtimeRawEndTime(userId);
final latestSleepResultTime = await _localStore.latestSleepResultTime(
userId,
);
final isFirstCalculation =
hrvContextStart == null && realtimeContextStart == null;
final hrvStartTime = math.max(
... ... @@ -237,6 +245,12 @@ class HealthRawDataCoreService {
earliestStartTime,
);
final restingHeartRateStartTime = heartRateStartTime;
final sleepStartTime = math.max(
latestSleepResultTime == null
? requestedStartTime
: latestSleepResultTime - Duration.secondsPerDay,
earliestStartTime,
);
final hrvPoints = await _fetchRawDataInChunks(
HealthDataUploadType.hrv.type,
... ... @@ -257,7 +271,7 @@ class HealthRawDataCoreService {
readChunkDays: readChunkDays,
);
final sleepIntervals = await _fetchSleepIntervalsInChunks(
heartRateStartTime,
sleepStartTime,
effectiveEndTime,
readChunkDays: readChunkDays,
);
... ... @@ -276,20 +290,39 @@ class HealthRawDataCoreService {
startTime: math.min(hrvStartTime, heartRateStartTime),
endTime: effectiveEndTime,
);
await _localStore.upsertResult(result);
final newResult = result.copyWith(
hrvStressPoints: _filterNewHrvStressPoints(
result.hrvStressPoints,
latestHrvRawEndTime,
),
realtimeStressPoints: _filterNewRealtimeStressPoints(
result.realtimeStressPoints,
latestRealtimeRawEndTime,
),
);
await _localStore.upsertResult(newResult);
if (_uploadResultsAfterCalculation) {
await _uploadHrvResults();
await _uploadRealtimeStressResults();
}
final dailyStressPoints = await _calculateAndStoreDailyStressPoints(
userId: userId,
realtimePoints: result.realtimeStressPoints,
realtimePoints: newResult.realtimeStressPoints,
nowSeconds: effectiveEndTime,
);
final sleepResults = await _calculateAndStoreSleepResults(
userId: userId,
sleepIntervals: sleepIntervals,
latestSleepResultTime: latestSleepResultTime,
);
if (_uploadResultsAfterCalculation) {
await _uploadDailyStressResults();
await _uploadSleepResults();
}
final storedResult = result.copyWith(dailyStressPoints: dailyStressPoints);
final storedResult = newResult.copyWith(
dailyStressPoints: dailyStressPoints,
sleepResults: sleepResults,
);
if (isFirstCalculation && (_environmentConfig?.isDebug ?? false)) {
AppToast.show('首次计算完成');
}
... ... @@ -409,6 +442,17 @@ class HealthRawDataCoreService {
);
}
Future<List<HealthRawSleepResult>> querySleepResults({
required int startTime,
required int endTime,
}) {
return _localStore.querySleepResults(
userId: _userId,
startTime: startTime,
endTime: endTime,
);
}
Future<List<HealthKitRawDataPoint>> queryRawDataPoints({
required int dataType,
required int startTime,
... ... @@ -535,11 +579,15 @@ class HealthRawDataCoreService {
}
_isUploadingDailyStressResults = true;
try {
final success = await _rawDataApi.performAvgRealtimeStressDataUpload(
final uploadedUntil =
await _rawDataApi.performAvgRealtimeStressDataUpload(
sqliteFilePath: await _localStore.dbPath(_userId),
);
if (success) {
await _localStore.markDailyStressUploaded(userId: _userId);
if (uploadedUntil > 0) {
await _localStore.markDailyStressUploadedUntil(
userId: _userId,
date: uploadedUntil,
);
}
} catch (error, stackTrace) {
_logError('upload daily stress results failed', error, stackTrace);
... ... @@ -548,6 +596,31 @@ class HealthRawDataCoreService {
}
}
Future<void> _uploadSleepResults() async {
if (_isUploadingSleepResults) {
return;
}
if (!await _localStore.hasPendingSleepResultUploads(userId: _userId)) {
return;
}
_isUploadingSleepResults = true;
try {
final uploadedUntil = await _rawDataApi.performSleepAnalysisDataUpload(
sqliteFilePath: await _localStore.dbPath(_userId),
);
if (uploadedUntil > 0) {
await _localStore.markSleepResultsUploadedUntil(
userId: _userId,
date: uploadedUntil,
);
}
} catch (error, stackTrace) {
_logError('upload sleep results failed', error, stackTrace);
} finally {
_isUploadingSleepResults = false;
}
}
HealthRawStressCalculationResult calculate({
required List<HealthKitRawDataPoint> hrvPoints,
required List<HealthKitRawDataPoint> heartRatePoints,
... ... @@ -621,6 +694,73 @@ class HealthRawDataCoreService {
return dailyStressPoints;
}
Future<List<HealthRawSleepResult>> _calculateAndStoreSleepResults({
required int userId,
required List<HealthKitRawDataPoint> sleepIntervals,
required int? latestSleepResultTime,
}) async {
if (sleepIntervals.isEmpty) return const <HealthRawSleepResult>[];
final days = {
for (final interval in sleepIntervals)
DateTime.fromMillisecondsSinceEpoch(interval.endTime * 1000)
}.map((date) => DateTime(date.year, date.month, date.day)).toList()
..sort((a, b) => a.compareTo(b));
final results = <HealthRawSleepResult>[];
for (final day in days) {
final calculation = HealthSleepCalculator.calculateDay(
day: day,
sleepIntervals: sleepIntervals,
);
final merged = calculation.mergeSleepTimeRange;
final score = calculation.score;
final state = calculation.state;
if (merged == null || score == null || state == null) continue;
if (!calculation.hasValidSleep) continue;
if (latestSleepResultTime != null &&
merged.endTime <= latestSleepResultTime) {
continue;
}
results.add(
HealthRawSleepResult(
userId: userId,
date: merged.endTime,
startDate: merged.startTime,
sleepScore: score,
sleepState: state.value,
inBedMinutes: calculation.summary.timeInBedMinutes,
awakMinutes: calculation.summary.awakeMinutes,
sleepMinutes: calculation.summary.sleepMinutes,
uploaded: false,
),
);
}
await _localStore.upsertSleepResults(
userId: userId,
results: results,
);
return results;
}
List<HealthRawHrvStressPoint> _filterNewHrvStressPoints(
List<HealthRawHrvStressPoint> points,
int? latestRawEndTime,
) {
if (latestRawEndTime == null) return points;
return points
.where((point) => point.rawEndTime > latestRawEndTime)
.toList();
}
List<HealthRawRealtimeStressPoint> _filterNewRealtimeStressPoints(
List<HealthRawRealtimeStressPoint> points,
int? latestRawEndTime,
) {
if (latestRawEndTime == null) return points;
return points
.where((point) => point.rawEndTime > latestRawEndTime)
.toList();
}
Future<List<HealthKitRawDataPoint>> _fetchRawDataInChunks(
int dataType,
int startTime,
... ... @@ -740,23 +880,27 @@ class HealthRawStressCalculationResult {
required this.hrvStressPoints,
required this.realtimeStressPoints,
required this.dailyStressPoints,
this.sleepResults = const <HealthRawSleepResult>[],
});
final int userId;
final List<HealthRawHrvStressPoint> hrvStressPoints;
final List<HealthRawRealtimeStressPoint> realtimeStressPoints;
final List<HealthRawDailyStressPoint> dailyStressPoints;
final List<HealthRawSleepResult> sleepResults;
HealthRawStressCalculationResult copyWith({
List<HealthRawHrvStressPoint>? hrvStressPoints,
List<HealthRawRealtimeStressPoint>? realtimeStressPoints,
List<HealthRawDailyStressPoint>? dailyStressPoints,
List<HealthRawSleepResult>? sleepResults,
}) {
return HealthRawStressCalculationResult(
userId: userId,
hrvStressPoints: hrvStressPoints ?? this.hrvStressPoints,
realtimeStressPoints: realtimeStressPoints ?? this.realtimeStressPoints,
dailyStressPoints: dailyStressPoints ?? this.dailyStressPoints,
sleepResults: sleepResults ?? this.sleepResults,
);
}
}
... ... @@ -1002,6 +1146,53 @@ class HealthRawDailyStressPoint {
}
}
class HealthRawSleepResult {
const HealthRawSleepResult({
required this.userId,
required this.date,
required this.startDate,
required this.sleepScore,
required this.sleepState,
required this.inBedMinutes,
required this.awakMinutes,
required this.sleepMinutes,
this.uploaded = false,
});
final int userId;
final int date;
final int startDate;
final int sleepScore;
final int sleepState;
final int inBedMinutes;
final int awakMinutes;
final int sleepMinutes;
final bool uploaded;
factory HealthRawSleepResult.fromDb(Map<String, Object?> row) {
return HealthRawSleepResult(
userId: row['user_id'] as int,
date: row['date'] as int,
startDate: row['start_date'] as int,
sleepScore: row['sleep_score'] as int,
sleepState: row['sleep_state'] as int,
inBedMinutes: row['in_bed_minutes'] as int,
awakMinutes: row['awak_minutes'] as int,
sleepMinutes: row['sleep_minutes'] as int,
uploaded: (row['uploaded'] as int? ?? 0) == 1,
);
}
int get sleepStateValue => sleepState;
String? get sleepStateDescription {
for (final state in HealthSleepState.values) {
if (state.value == sleepState) return state.description;
}
return null;
}
}
class HealthRawDailyStressCalculator {
const HealthRawDailyStressCalculator._();
... ... @@ -1138,11 +1329,13 @@ class HealthRawStressDbRows {
required this.hrvRows,
required this.realtimeRows,
required this.dailyStressRows,
required this.sleepRows,
});
final List<Map<String, Object?>> hrvRows;
final List<Map<String, Object?>> realtimeRows;
final List<Map<String, Object?>> dailyStressRows;
final List<Map<String, Object?>> sleepRows;
}
class HealthRawStressLocalStore {
... ... @@ -1155,6 +1348,7 @@ class HealthRawStressLocalStore {
static const hrvResultsTable = 'hrv_results';
static const realtimeStressResultsTable = 'realtime_stress_results';
static const dailyStressResultsTable = 'daily_stress_results';
static const sleepResultsTable = 'sleep_results';
final Directory? _rootDirectory;
final DatabaseFactory? _databaseFactory;
... ... @@ -1192,6 +1386,18 @@ class HealthRawStressLocalStore {
});
}
Future<void> upsertSleepResults({
required int userId,
required Iterable<HealthRawSleepResult> results,
}) async {
final db = await _database(userId);
await db.transaction((txn) async {
for (final result in results) {
await _upsertSleepResultResettingUploaded(txn, _sleepRow(result));
}
});
}
Future<Set<int>> existingDailyStressDates({
required int userId,
required Iterable<int> dates,
... ... @@ -1292,6 +1498,21 @@ class HealthRawStressLocalStore {
return rows.map(HealthRawDailyStressPoint.fromDb).toList();
}
Future<List<HealthRawSleepResult>> querySleepResults({
required int userId,
required int startTime,
required int endTime,
}) async {
final db = await _database(userId);
final rows = await db.query(
sleepResultsTable,
where: 'date >= ? AND date <= ?',
whereArgs: [startTime, endTime],
orderBy: 'date ASC',
);
return rows.map(HealthRawSleepResult.fromDb).toList();
}
Future<HealthRawStressDbRows> debugQueryAllRows(int userId) async {
final db = await _database(userId);
final hrvRows = await db.query(
... ... @@ -1306,10 +1527,15 @@ class HealthRawStressLocalStore {
dailyStressResultsTable,
orderBy: 'date ASC',
);
final sleepRows = await db.query(
sleepResultsTable,
orderBy: 'date ASC',
);
return HealthRawStressDbRows(
hrvRows: hrvRows,
realtimeRows: realtimeRows,
dailyStressRows: dailyStressRows,
sleepRows: sleepRows,
);
}
... ... @@ -1321,6 +1547,26 @@ class HealthRawStressLocalStore {
return _latestSourceStartTime(userId, realtimeStressResultsTable);
}
Future<int?> latestHrvRawEndTime(int userId) {
return _latestRawEndTime(userId, hrvResultsTable);
}
Future<int?> latestRealtimeRawEndTime(int userId) {
return _latestRawEndTime(userId, realtimeStressResultsTable);
}
Future<int?> latestSleepResultTime(int userId) async {
final db = await _database(userId);
final rows = await db.query(
sleepResultsTable,
columns: ['date'],
orderBy: 'date DESC',
limit: 1,
);
if (rows.isEmpty) return null;
return rows.first['date'] as int;
}
Future<void> markHrvStressUploaded({
required int userId,
required Iterable<int> rawEndTimes,
... ... @@ -1367,12 +1613,27 @@ class HealthRawStressLocalStore {
);
}
Future<void> markDailyStressUploaded({required int userId}) async {
final db = await _database(userId);
await db.update(
dailyStressResultsTable,
{'uploaded': 1},
where: 'uploaded != 1',
Future<void> markDailyStressUploadedUntil({
required int userId,
required int date,
}) {
return _markUploadedUntil(
userId: userId,
table: dailyStressResultsTable,
timeColumn: 'date',
time: date,
);
}
Future<void> markSleepResultsUploadedUntil({
required int userId,
required int date,
}) {
return _markUploadedUntil(
userId: userId,
table: sleepResultsTable,
timeColumn: 'date',
time: date,
);
}
... ... @@ -1389,6 +1650,10 @@ class HealthRawStressLocalStore {
return _hasPendingUploads(userId: userId, table: dailyStressResultsTable);
}
Future<bool> hasPendingSleepResultUploads({required int userId}) {
return _hasPendingUploads(userId: userId, table: sleepResultsTable);
}
Future<String> dbPath(int userId) async {
final dir = _rootDirectory ?? await getApplicationDocumentsDirectory();
return '${dir.path}/hrv_result_$userId.sqlite';
... ... @@ -1429,7 +1694,7 @@ class HealthRawStressLocalStore {
final db = await factory.openDatabase(
path,
options: OpenDatabaseOptions(
version: 6,
version: 7,
onCreate: (db, version) async {
await _createTables(db);
},
... ... @@ -1450,6 +1715,9 @@ class HealthRawStressLocalStore {
if (oldVersion < 6) {
await _addRealtimeRawHrColumn(db);
}
if (oldVersion < 7) {
await _createSleepResultsTable(db);
}
},
),
);
... ... @@ -1494,6 +1762,7 @@ CREATE TABLE IF NOT EXISTS $realtimeStressResultsTable (
)
''');
await _createDailyStressTable(db);
await _createSleepResultsTable(db);
}
Future<void> _createDailyStressTable(DatabaseExecutor db) async {
... ... @@ -1510,6 +1779,22 @@ CREATE TABLE IF NOT EXISTS $dailyStressResultsTable (
''');
}
Future<void> _createSleepResultsTable(DatabaseExecutor db) async {
await db.execute('''
CREATE TABLE IF NOT EXISTS $sleepResultsTable (
date INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL,
start_date INTEGER NOT NULL,
sleep_score INTEGER NOT NULL,
sleep_state INTEGER NOT NULL,
in_bed_minutes INTEGER NOT NULL,
awak_minutes INTEGER NOT NULL,
sleep_minutes INTEGER NOT NULL,
uploaded INTEGER NOT NULL DEFAULT 0
)
''');
}
Future<void> _addFlagColumns(DatabaseExecutor db, String table) async {
for (final column in const [
'is_workout',
... ... @@ -1581,6 +1866,18 @@ CREATE TABLE IF NOT EXISTS $dailyStressResultsTable (
return rows.first['source_start_time'] as int;
}
Future<int?> _latestRawEndTime(int userId, String table) async {
final db = await _database(userId);
final rows = await db.query(
table,
columns: ['raw_end_time'],
orderBy: 'raw_end_time DESC',
limit: 1,
);
if (rows.isEmpty) return null;
return rows.first['raw_end_time'] as int;
}
Future<void> _markUploaded({
required int userId,
required String table,
... ... @@ -1670,6 +1967,20 @@ CREATE TABLE IF NOT EXISTS $dailyStressResultsTable (
};
}
Map<String, Object?> _sleepRow(HealthRawSleepResult result) {
return <String, Object?>{
'date': result.date,
'user_id': result.userId,
'start_date': result.startDate,
'sleep_score': result.sleepScore,
'sleep_state': result.sleepState,
'in_bed_minutes': result.inBedMinutes,
'awak_minutes': result.awakMinutes,
'sleep_minutes': result.sleepMinutes,
'uploaded': result.uploaded ? 1 : 0,
};
}
Map<String, Object?> _flagsRow(HealthRawPointFlags flags) {
return <String, Object?>{
'is_workout': flags.isWorkout ? 1 : 0,
... ... @@ -1768,6 +2079,51 @@ CREATE TABLE IF NOT EXISTS $dailyStressResultsTable (
);
}
Future<void> _upsertSleepResultResettingUploaded(
DatabaseExecutor db,
Map<String, Object?> row,
) async {
final existing = await db.query(
sleepResultsTable,
where: 'date = ?',
whereArgs: [row['date']],
limit: 1,
);
if (existing.isEmpty) {
await db.insert(sleepResultsTable, row);
return;
}
final existingRow = existing.first;
final valueChanged = !_matchesStoredValues(
existingRow,
row,
const [
'user_id',
'start_date',
'sleep_score',
'sleep_state',
'in_bed_minutes',
'awak_minutes',
'sleep_minutes',
],
);
await db.update(
sleepResultsTable,
<String, Object?>{
'user_id': row['user_id'],
'start_date': row['start_date'],
'sleep_score': row['sleep_score'],
'sleep_state': row['sleep_state'],
'in_bed_minutes': row['in_bed_minutes'],
'awak_minutes': row['awak_minutes'],
'sleep_minutes': row['sleep_minutes'],
'uploaded': valueChanged ? 0 : existingRow['uploaded'],
},
where: 'date = ?',
whereArgs: [row['date']],
);
}
List<String> _rawResultStoredValueKeys(Map<String, Object?> row) {
return <String>[
'user_id',
... ...
import 'dart:math' as math;
import '../../pigeon/health_kit_raw_data_api.g.dart';
const healthSleepGoalMinutes = 8 * 60;
const _sleepContinuityToleranceSeconds = 1;
const _sleepTypeInBed = 0;
const _sleepTypeAsleep = 1;
const _sleepTypeAsleepUnspecified = 1;
const _sleepTypeAwake = 2;
const _sleepTypeAsleepCore = 3;
const _sleepTypeAsleepDeep = 4;
const _sleepTypeAsleepRem = 5;
class HealthSleepCalculator {
const HealthSleepCalculator._();
static HealthSleepDayCalculation calculateDay({
required DateTime day,
required List<HealthKitRawDataPoint> sleepIntervals,
}) {
final intervals = completeSleepIntervalsForDay(day, sleepIntervals);
final mergedInterval = mergeContinuousSleepIntervals(intervals);
final windowStart = mergedInterval?.startTime ?? unixSeconds(day);
final windowEnd = mergedInterval?.endTime ??
unixSeconds(day.add(const Duration(days: 1)));
final summary = sleepSummary(intervals, windowStart, windowEnd);
final score = sleepScore(summary);
final nullableScore = score == 0 ? null : score;
final state = sleepState(score);
final durationSeconds =
nullableScore == null || state == null ? 0 : summary.sleepMinutes * 60;
return HealthSleepDayCalculation(
day: day,
sleepIntervals: intervals,
mergeSleepTimeRange: mergedInterval,
summary: summary,
durationSeconds: durationSeconds,
asleepTime: intervals.isEmpty
? null
: intervals.map((e) => e.startTime).reduce(math.min),
score: nullableScore,
state: state,
);
}
static List<HealthKitRawDataPoint> completeSleepIntervalsForDay(
DateTime day,
List<HealthKitRawDataPoint> sleepIntervals,
) {
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 < queryEnd)
.toList()
..sort((a, b) {
final startCompare = a.startTime.compareTo(b.startTime);
if (startCompare != 0) return startCompare;
return a.endTime.compareTo(b.endTime);
});
if (candidates.isEmpty) return const <HealthKitRawDataPoint>[];
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]);
}
}
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(
List<HealthKitRawDataPoint> sleepIntervals,
) {
if (sleepIntervals.isEmpty) return null;
final sorted = [...sleepIntervals]..sort((a, b) {
final startCompare = a.startTime.compareTo(b.startTime);
if (startCompare != 0) return startCompare;
return a.endTime.compareTo(b.endTime);
});
var start = sorted.first.startTime;
var end = sorted.first.endTime;
for (final interval in sorted.skip(1)) {
if (interval.startTime > end) break;
end = math.max(end, interval.endTime);
}
return HealthKitRawDataPoint(
dataType: sorted.first.dataType,
startTime: start,
endTime: end,
);
}
static HealthSleepSummary sleepSummary(
List<HealthKitRawDataPoint> intervals,
int windowStart,
int windowEnd,
) {
var inBedSeconds = 0;
var asleepSeconds = 0;
var awakeSeconds = 0;
var coreSeconds = 0;
var deepSeconds = 0;
var remSeconds = 0;
var unknownSleepSeconds = 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 seconds = math.max(0, clippedEnd - clippedStart);
if (seconds <= 0) continue;
earliestStart = math.min(earliestStart, clippedStart);
latestEnd = math.max(latestEnd, clippedEnd);
switch (interval.dataType) {
case _sleepTypeInBed:
inBedSeconds += seconds;
break;
case _sleepTypeAwake:
awakeSeconds += seconds;
wakeCount += 1;
break;
case _sleepTypeAsleepCore:
coreSeconds += seconds;
asleepSeconds += seconds;
break;
case _sleepTypeAsleepDeep:
deepSeconds += seconds;
asleepSeconds += seconds;
break;
case _sleepTypeAsleepRem:
remSeconds += seconds;
asleepSeconds += seconds;
break;
default:
if (isAsleepSleepType(interval.dataType)) {
unknownSleepSeconds += seconds;
asleepSeconds += seconds;
}
break;
}
}
if (inBedSeconds <= 0 && latestEnd > earliestStart) {
inBedSeconds = latestEnd - earliestStart;
}
if (inBedSeconds <= 0) {
inBedSeconds = asleepSeconds + awakeSeconds;
}
return HealthSleepSummary(
timeInBedMinutes: secondsToRoundedMinutes(inBedSeconds),
sleepMinutes: secondsToRoundedMinutes(asleepSeconds),
awakeMinutes: secondsToRoundedMinutes(awakeSeconds),
coreMinutes: secondsToRoundedMinutes(coreSeconds),
deepMinutes: secondsToRoundedMinutes(deepSeconds),
remMinutes: secondsToRoundedMinutes(remSeconds),
unknownSleepMinutes: secondsToRoundedMinutes(unknownSleepSeconds),
wakeCount: wakeCount,
sleepGoalMinutes: healthSleepGoalMinutes,
);
}
static int sleepScore(HealthSleepSummary sleep) {
if (sleep.timeInBedMinutes <= 0 || sleep.sleepMinutes <= 0) return 0;
final totalAsleepMinutes = sleep.sleepMinutes.toDouble();
final timeInBedMinutes = sleep.timeInBedMinutes.toDouble();
final sleepGoalMinutes = sleep.sleepGoalMinutes.toDouble();
final deepMinutes = sleep.deepMinutes.toDouble();
final remMinutes = sleep.remMinutes.toDouble();
final awakeMinutes = sleep.awakeMinutes.toDouble();
final durationRatio = math.min(
totalAsleepMinutes / sleepGoalMinutes,
1.0,
);
final durationScore = durationRatio * 40;
final efficiency = totalAsleepMinutes / timeInBedMinutes;
final efficiencyScore = math.min(efficiency / 0.9, 1.0) * 25;
final deepRatio = deepMinutes / totalAsleepMinutes;
final deepScore = math.min(deepRatio / 0.18, 1.0) * 15;
final remRatio = remMinutes / totalAsleepMinutes;
final remScore = math.min(remRatio / 0.22, 1.0) * 10;
final awakePenalty = math.min(
sleep.wakeCount * 2 + awakeMinutes / 10,
10,
);
final rawScore =
durationScore + efficiencyScore + deepScore + remScore - awakePenalty;
return mappedSleepScore(rawScore);
}
static 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);
}
static HealthSleepState? sleepState(int score) {
if (score <= 0) return null;
if (score >= 85) return HealthSleepState.great;
if (score >= 60) return HealthSleepState.good;
return HealthSleepState.poor;
}
static int secondsToRoundedMinutes(int seconds) {
return (seconds / 60.0).round();
}
static bool isAsleepSleepType(int dataType) {
return dataType == _sleepTypeAsleep ||
dataType == _sleepTypeAsleepUnspecified ||
dataType == _sleepTypeAsleepCore ||
dataType == _sleepTypeAsleepDeep ||
dataType == _sleepTypeAsleepRem;
}
static int unixSeconds(DateTime date) => date.millisecondsSinceEpoch ~/ 1000;
}
class HealthSleepDayCalculation {
const HealthSleepDayCalculation({
required this.day,
required this.sleepIntervals,
required this.mergeSleepTimeRange,
required this.summary,
required this.durationSeconds,
required this.asleepTime,
required this.score,
required this.state,
});
final DateTime day;
final List<HealthKitRawDataPoint> sleepIntervals;
final HealthKitRawDataPoint? mergeSleepTimeRange;
final HealthSleepSummary summary;
final int durationSeconds;
final int? asleepTime;
final int? score;
final HealthSleepState? state;
bool get hasValidSleep =>
durationSeconds > 0 && score != null && state != null;
}
class HealthSleepSummary {
const HealthSleepSummary({
required this.timeInBedMinutes,
required this.sleepMinutes,
required this.awakeMinutes,
required this.coreMinutes,
required this.deepMinutes,
required this.remMinutes,
required this.unknownSleepMinutes,
required this.wakeCount,
required this.sleepGoalMinutes,
});
final int timeInBedMinutes;
final int sleepMinutes;
final int awakeMinutes;
final int coreMinutes;
final int deepMinutes;
final int remMinutes;
final int unknownSleepMinutes;
final int wakeCount;
final int sleepGoalMinutes;
}
enum HealthSleepState {
great(1, '睡得很棒'),
good(2, '睡得不错'),
poor(3, '睡的差');
const HealthSleepState(this.value, this.description);
final int value;
final String description;
}
... ...
import 'dart:math' as math;
import 'package:doublefeel_flutter/core/services/health_raw_data_core_service.dart';
import 'package:doublefeel_flutter/core/services/health_sleep_calculator.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/health_v2_models.dart';
... ... @@ -8,16 +9,6 @@ import 'package:doublefeel_flutter/data/models/health/hrv/hrv_statistics_data.da
import 'package:doublefeel_flutter/data/models/health/sleep/sleep_statistics_data.dart';
import 'package:doublefeel_flutter/pigeon/health_kit_raw_data_api.g.dart';
const _sleepTypeInBed = 0;
const _sleepTypeAsleep = 1;
const _sleepTypeAsleepUnspecified = 1;
const _sleepTypeAwake = 2;
const _sleepTypeAsleepCore = 3;
const _sleepTypeAsleepDeep = 4;
const _sleepTypeAsleepRem = 5;
const _sleepGoalMinutes = 8 * 60.0;
const _sleepContinuityToleranceSeconds = 1;
class LocalHealthDataConvert {
const LocalHealthDataConvert._();
... ... @@ -50,64 +41,16 @@ class LocalHealthDataConvert {
DateTime day,
List<HealthKitRawDataPoint> sleepIntervals,
) {
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 < queryEnd)
.toList()
..sort((a, b) {
final startCompare = a.startTime.compareTo(b.startTime);
if (startCompare != 0) return startCompare;
return a.endTime.compareTo(b.endTime);
});
if (candidates.isEmpty) return const <HealthKitRawDataPoint>[];
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]);
}
}
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>[];
return HealthSleepCalculator.completeSleepIntervalsForDay(
day,
sleepIntervals,
);
}
static HealthKitRawDataPoint? mergeContinuousSleepIntervals(
List<HealthKitRawDataPoint> sleepIntervals,
) {
if (sleepIntervals.isEmpty) return null;
final sorted = [...sleepIntervals]..sort((a, b) {
final startCompare = a.startTime.compareTo(b.startTime);
if (startCompare != 0) return startCompare;
return a.endTime.compareTo(b.endTime);
});
var start = sorted.first.startTime;
var end = sorted.first.endTime;
for (final interval in sorted.skip(1)) {
if (interval.startTime > end) break;
end = math.max(end, interval.endTime);
}
return HealthKitRawDataPoint(
dataType: sorted.first.dataType,
startTime: start,
endTime: end,
);
return HealthSleepCalculator.mergeContinuousSleepIntervals(sleepIntervals);
}
static SleepStatisticsData sleepStatistics({
... ... @@ -119,24 +62,24 @@ class LocalHealthDataConvert {
}) {
final daily = [
for (final day in days)
_sleepDaySummary(
day,
sleepIntervals,
heartRate,
HealthSleepCalculator.calculateDay(
day: day,
sleepIntervals: sleepIntervals,
),
];
final validSleep = daily.where((e) => e.durationSeconds > 0).toList();
final previousDaily = [
for (final day in previousDays)
_sleepDaySummary(
day,
sleepIntervals,
heartRate,
HealthSleepCalculator.calculateDay(
day: day,
sleepIntervals: sleepIntervals,
),
];
final previousValidSleep =
previousDaily.where((e) => e.durationSeconds > 0).toList();
final allSleepHr = validSleep.expand((e) => e.sleepHr).toList();
final allSleepHr = validSleep
.expand((e) => _sleepHeartRate(e.sleepIntervals, heartRate))
.toList();
final shouldIncludeHeartRate = dateRangeType == 3;
return SleepStatisticsData(
... ... @@ -147,7 +90,7 @@ class LocalHealthDataConvert {
validSleep.map((e) => e.score).whereType<num>().toList(),
),
avgSleepEvaluate: _averageOrNull(
validSleep.map((e) => e.evaluate).whereType<num>().toList(),
validSleep.map((e) => e.state?.value).whereType<num>().toList(),
),
qoqAvgSleepDuration: _averageOrNull(
previousValidSleep.map((e) => e.durationSeconds).toList(),
... ... @@ -156,7 +99,7 @@ class LocalHealthDataConvert {
previousValidSleep.map((e) => e.score).whereType<num>().toList(),
),
qoqSleepEvaluate: _averageOrNull(
previousValidSleep.map((e) => e.evaluate).whereType<num>().toList(),
previousValidSleep.map((e) => e.state?.value).whereType<num>().toList(),
),
sleepTrendList: [
for (final item in daily)
... ... @@ -164,7 +107,7 @@ class LocalHealthDataConvert {
timeKey: dateKey(item.day),
totalTime: item.durationSeconds,
score: item.score,
sleepEvaluate: item.evaluate,
sleepEvaluate: item.state?.value,
),
],
asleepTimeTrendList: [
... ... @@ -200,7 +143,97 @@ class LocalHealthDataConvert {
allSleepHr.map((e) => e.value).whereType<num>().toList(),
)
: null,
sleepTargetDuration: (_sleepGoalMinutes * 60).round(),
sleepTargetDuration: healthSleepGoalMinutes * 60,
);
}
static SleepStatisticsData sleepStatisticsFromResults({
required int dateRangeType,
required List<DateTime> days,
required List<DateTime> previousDays,
required List<HealthRawSleepResult> sleepResults,
required List<HealthKitRawDataPoint> heartRate,
}) {
final daily = [
for (final day in days) _sleepResultForDay(day, sleepResults),
];
final validSleep = daily.whereType<HealthRawSleepResult>().toList();
final previousDaily = [
for (final day in previousDays) _sleepResultForDay(day, sleepResults),
];
final previousValidSleep =
previousDaily.whereType<HealthRawSleepResult>().toList();
final shouldIncludeHeartRate = dateRangeType == 3;
final allSleepHr = shouldIncludeHeartRate
? _sleepResultHeartRate(validSleep, heartRate)
: const <HealthKitRawDataPoint>[];
return SleepStatisticsData(
avgSleepDuration: _averageOrNull(
validSleep.map((e) => e.sleepMinutes * 60).toList(),
),
avgSleepScore: _averageOrNull(
validSleep.map((e) => e.sleepScore).toList(),
),
avgSleepEvaluate: _averageOrNull(
validSleep.map((e) => e.sleepStateValue).whereType<num>().toList(),
),
qoqAvgSleepDuration: _averageOrNull(
previousValidSleep.map((e) => e.sleepMinutes * 60).toList(),
),
qoqSleepScore: _averageOrNull(
previousValidSleep.map((e) => e.sleepScore).toList(),
),
qoqSleepEvaluate: _averageOrNull(
previousValidSleep
.map((e) => e.sleepStateValue)
.whereType<num>()
.toList(),
),
sleepTrendList: [
for (var index = 0; index < days.length; index++)
SleepTrendList(
timeKey: dateKey(days[index]),
totalTime: daily[index]?.sleepMinutes == null
? 0
: daily[index]!.sleepMinutes * 60,
score: daily[index]?.sleepScore,
sleepEvaluate: daily[index]?.sleepStateValue,
),
],
asleepTimeTrendList: [
for (var index = 0; index < days.length; index++)
AsleepTimeTrendList(
timeKey: dateKey(days[index]),
asleepTime: daily[index]?.startDate,
),
],
bestSleepInfo: _bestSleepResultInfo(validSleep, best: true),
worstSleepInfo: _worstSleepResultInfo(validSleep),
earliestSleepInfo: _earliestSleepResultInfo(validSleep),
latestSleepInfo: _latestSleepResultInfo(validSleep),
hrList: shouldIncludeHeartRate
? [
for (final point in allSleepHr)
SleepHrItem(time: point.endTime, value: point.value),
]
: null,
avgHr: shouldIncludeHeartRate
? _averageOrNull(
allSleepHr.map((e) => e.value).whereType<num>().toList(),
)
: null,
maxHr: shouldIncludeHeartRate
? _maxOrNull(
allSleepHr.map((e) => e.value).whereType<num>().toList(),
)
: null,
minHr: shouldIncludeHeartRate
? _minOrNull(
allSleepHr.map((e) => e.value).whereType<num>().toList(),
)
: null,
sleepTargetDuration: healthSleepGoalMinutes * 60,
);
}
... ... @@ -372,7 +405,7 @@ class LocalHealthDataConvert {
? null
: latestWithGoal!.exerciseTimeGoal!.round(),
updateTime: latestWithGoal?.endTime,
sleepTargetDuration: (60 * _sleepGoalMinutes).round(),
sleepTargetDuration: 60 * healthSleepGoalMinutes,
);
}
... ... @@ -385,10 +418,9 @@ class LocalHealthDataConvert {
required List<HealthKitRawDataPoint> sleepIntervals,
}) {
final activitySummary = _activityDaySummary(day, activity);
final sleepSummary = _sleepDaySummary(
day,
sleepIntervals,
sleepingHeartRate,
final sleepSummary = HealthSleepCalculator.calculateDay(
day: day,
sleepIntervals: sleepIntervals,
);
final dayStart = unixSeconds(day);
final dayEnd = unixSeconds(day.add(const Duration(days: 1)));
... ... @@ -410,7 +442,10 @@ class LocalHealthDataConvert {
lastRestingHrValue:
dayRestingHr.isEmpty ? null : dayRestingHr.last.value?.round(),
hrAvg: _averageOrNull(
sleepSummary.sleepHr.map((e) => e.value).whereType<num>().toList(),
_sleepHeartRate(sleepSummary.sleepIntervals, sleepingHeartRate)
.map((e) => e.value)
.whereType<num>()
.toList(),
)?.round(),
move: activitySummary.move.round(),
exercise: activitySummary.exerciseSeconds.round(),
... ... @@ -418,7 +453,52 @@ class LocalHealthDataConvert {
steps: activitySummary.steps.round(),
sleepDuration: sleepSummary.durationSeconds.round(),
sleepScore: sleepSummary.score?.toDouble(),
sleepState: sleepSummary.evaluate?.round(),
sleepState: sleepSummary.state?.value,
sleepTimeList: sleepTimeList,
);
}
static V2HealthData v2HealthDataFromSleepResult({
required DateTime day,
required List<HealthRawHrvStressPoint> hrvPoints,
required List<HealthKitRawActivityDataPoint> activity,
required List<HealthKitRawDataPoint> sleepingHeartRate,
required List<HealthKitRawDataPoint> restingHeartRate,
required HealthRawSleepResult? sleepResult,
}) {
final activitySummary = _activityDaySummary(day, activity);
final dayStart = unixSeconds(day);
final dayEnd = unixSeconds(day.add(const Duration(days: 1)));
final sleepTimeList = sleepResult == null
? <V2SleepTimeRange>[]
: [
V2SleepTimeRange(
fromTime: sleepResult.startDate,
toTime: sleepResult.date,
),
];
final sleepHr = sleepResult == null
? const <HealthKitRawDataPoint>[]
: _sleepResultHeartRate([sleepResult], sleepingHeartRate);
final dayRestingHr = restingHeartRate
.where((e) => e.endTime >= dayStart && e.endTime < dayEnd)
.toList()
..sort((a, b) => a.endTime.compareTo(b.endTime));
return V2HealthData(
hrvAvg: _averageOrNull(hrvPoints.map((e) => e.result).toList())?.round(),
lastRestingHrValue:
dayRestingHr.isEmpty ? null : dayRestingHr.last.value?.round(),
hrAvg: _averageOrNull(
sleepHr.map((e) => e.value).whereType<num>().toList(),
)?.round(),
move: activitySummary.move.round(),
exercise: activitySummary.exerciseSeconds.round(),
stand: activitySummary.standHours.round(),
steps: activitySummary.steps.round(),
sleepDuration: sleepResult == null ? 0 : sleepResult.sleepMinutes * 60,
sleepScore: sleepResult?.sleepScore.toDouble(),
sleepState: sleepResult?.sleepStateValue,
sleepTimeList: sleepTimeList,
);
}
... ... @@ -479,43 +559,6 @@ class LocalHealthDataConvert {
);
}
static _SleepDaySummary _sleepDaySummary(
DateTime day,
List<HealthKitRawDataPoint> sleepIntervals,
List<HealthKitRawDataPoint> heartRate,
) {
final intervals = completeSleepIntervalsForDay(day, sleepIntervals);
final mergedInterval = mergeContinuousSleepIntervals(intervals);
final windowStart = mergedInterval?.startTime ?? unixSeconds(day);
final windowEnd = mergedInterval?.endTime ??
unixSeconds(day.add(const Duration(days: 1)));
final summary = _sleepSummary(intervals, windowStart, windowEnd);
final score = _sleepScore(summary);
final sleepHr = heartRate
.where((point) => intervals.any((interval) =>
point.endTime > interval.startTime &&
point.endTime <= interval.endTime))
.toList();
final nullableScore = score == 0 ? null : score;
final sleepEvaluate = _sleepEvaluate(score);
final sleepDuration = nullableScore == null || sleepEvaluate == null
? 0
: summary.totalAsleepMinutes.round() * 60;
return _SleepDaySummary(
day: day,
sleepIntervals: intervals,
mergeSleepTimeRange: mergedInterval,
durationSeconds: sleepDuration.round(),
asleepTime: intervals.isEmpty
? null
: intervals.map((e) => e.startTime).reduce(math.min),
score: nullableScore,
evaluate: sleepEvaluate,
sleepHr: sleepHr,
);
}
static _ActivityDaySummary _activityDaySummary(
DateTime day,
List<HealthKitRawActivityDataPoint> activity,
... ... @@ -653,12 +696,15 @@ class LocalHealthDataConvert {
return result;
}
static 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,
);
static BestSleepInfo? _bestSleepInfo(
List<HealthSleepDayCalculation> daily,
) {
final item =
daily.where((e) => e.score != null).fold<HealthSleepDayCalculation?>(
null,
(best, item) =>
best == null || item.score! > best.score! ? item : best,
);
return item == null
? null
: BestSleepInfo(
... ... @@ -668,12 +714,15 @@ class LocalHealthDataConvert {
);
}
static 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,
);
static WorstSleepInfo? _worstSleepInfo(
List<HealthSleepDayCalculation> daily,
) {
final item =
daily.where((e) => e.score != null).fold<HealthSleepDayCalculation?>(
null,
(worst, item) =>
worst == null || item.score! < worst.score! ? item : worst,
);
return item == null
? null
: WorstSleepInfo(
... ... @@ -683,7 +732,9 @@ class LocalHealthDataConvert {
);
}
static EarliestSleepInfo? _earliestSleepInfo(List<_SleepDaySummary> daily) {
static EarliestSleepInfo? _earliestSleepInfo(
List<HealthSleepDayCalculation> 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;
... ... @@ -695,132 +746,81 @@ class LocalHealthDataConvert {
);
}
static 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,
);
}
static _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;
default:
if (_isAsleepSleepType(interval.dataType)) {
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,
static BestSleepInfo? _bestSleepResultInfo(
List<HealthRawSleepResult> sleepResults, {
required bool best,
}) {
final item = sleepResults.fold<HealthRawSleepResult?>(
null,
(selected, item) {
if (selected == null) return item;
if (best && item.sleepScore > selected.sleepScore) return item;
if (!best && item.sleepScore < selected.sleepScore) return item;
return selected;
},
);
if (item == null) return null;
return BestSleepInfo(
timeKey: dateKey(dayOf(item.date)),
totalTime: item.sleepMinutes * 60,
score: item.sleepScore,
);
}
static int _sleepScore(_SleepSummary sleep) {
if (sleep.timeInBedMinutes <= 0 || sleep.totalAsleepMinutes <= 0) return 0;
final durationRatio = math.min(
sleep.totalAsleepMinutes / sleep.sleepGoalMinutes,
1.0,
static WorstSleepInfo? _worstSleepResultInfo(
List<HealthRawSleepResult> sleepResults,
) {
final item = sleepResults.fold<HealthRawSleepResult?>(
null,
(worst, item) =>
worst == null || item.sleepScore < worst.sleepScore ? item : worst,
);
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,
if (item == null) return null;
return WorstSleepInfo(
timeKey: dateKey(dayOf(item.date)),
totalTime: item.sleepMinutes * 60,
score: item.sleepScore,
);
final rawScore =
durationScore + efficiencyScore + deepScore + remScore - awakePenalty;
return _mappedSleepScore(rawScore);
}
static 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);
static EarliestSleepInfo? _earliestSleepResultInfo(
List<HealthRawSleepResult> sleepResults,
) {
final sorted = [...sleepResults]
..sort((a, b) => a.startDate.compareTo(b.startDate));
final item = sorted.isEmpty ? null : sorted.first;
if (item == null) return null;
return EarliestSleepInfo(
timeKey: dateKey(dayOf(item.date)),
asleepTime: item.startDate,
);
}
static num? _sleepEvaluate(int score) {
if (score <= 0) return null;
if (score >= 85) return 1;
if (score >= 60) return 2;
return 3;
static LatestSleepInfo? _latestSleepResultInfo(
List<HealthRawSleepResult> sleepResults,
) {
final sorted = [...sleepResults]
..sort((a, b) => b.startDate.compareTo(a.startDate));
final item = sorted.isEmpty ? null : sorted.first;
if (item == null) return null;
return LatestSleepInfo(
timeKey: dateKey(dayOf(item.date)),
asleepTime: item.startDate,
);
}
static bool _isAsleepSleepType(int dataType) {
return dataType == _sleepTypeAsleep ||
dataType == _sleepTypeAsleepUnspecified ||
dataType == _sleepTypeAsleepCore ||
dataType == _sleepTypeAsleepDeep ||
dataType == _sleepTypeAsleepRem;
static LatestSleepInfo? _latestSleepInfo(
List<HealthSleepDayCalculation> 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,
);
}
static int? _modeState(Iterable<int> states) {
... ... @@ -836,6 +836,40 @@ class LocalHealthDataConvert {
return intervals.any((e) => time > e.startTime && time <= e.endTime);
}
static List<HealthKitRawDataPoint> _sleepHeartRate(
List<HealthKitRawDataPoint> intervals,
List<HealthKitRawDataPoint> heartRate,
) {
return heartRate
.where((point) => intervals.any((interval) =>
point.endTime > interval.startTime &&
point.endTime <= interval.endTime))
.toList();
}
static HealthRawSleepResult? _sleepResultForDay(
DateTime day,
List<HealthRawSleepResult> sleepResults,
) {
final start = unixSeconds(day);
final end = unixSeconds(day.add(const Duration(days: 1)));
final dayResults = sleepResults
.where((e) => e.date >= start && e.date < end)
.toList()
..sort((a, b) => a.date.compareTo(b.date));
return dayResults.isEmpty ? null : dayResults.last;
}
static List<HealthKitRawDataPoint> _sleepResultHeartRate(
List<HealthRawSleepResult> sleepResults,
List<HealthKitRawDataPoint> heartRate,
) {
return heartRate
.where((point) => sleepResults.any((result) =>
point.endTime > result.startDate && point.endTime <= result.date))
.toList();
}
static DateTime dateFromKey(int key) {
final year = key ~/ 10000;
final month = (key ~/ 100) % 100;
... ... @@ -873,50 +907,6 @@ class LocalHealthDataConvert {
}
}
class _SleepDaySummary {
const _SleepDaySummary({
required this.day,
required this.sleepIntervals,
required this.mergeSleepTimeRange,
required this.durationSeconds,
required this.asleepTime,
required this.score,
required this.evaluate,
required this.sleepHr,
});
final DateTime day;
final List<HealthKitRawDataPoint> sleepIntervals;
final HealthKitRawDataPoint? mergeSleepTimeRange;
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,
... ...
... ... @@ -6,6 +6,7 @@ import 'package:doublefeel_flutter/data/models/health/activity/activity_burn_sta
import 'package:doublefeel_flutter/data/models/health/health_v2_models.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';
import 'health_local_data_convert.dart';
... ... @@ -31,27 +32,26 @@ class LocalHealthDataSource implements HealthDataSource {
);
final allDays = [...previousDays, ...days];
final queryStart = LocalHealthDataConvert.unixSeconds(
allDays.first.subtract(const Duration(days: 1)),
allDays.first,
);
final queryEnd = LocalHealthDataConvert.unixSeconds(
allDays.last.add(const Duration(days: 1)),
);
final sleepIntervals = await coreService.queryRawSleepIntervals(
startTime: queryStart,
endTime: queryEnd,
);
final heartRate = await coreService.queryRawDataPoints(
dataType: HealthDataUploadType.heartRate.type,
allDays.last.add(const Duration(days: 1)),
) -
1;
final sleepResults = await coreService.querySleepResults(
startTime: queryStart,
endTime: queryEnd,
);
final heartRate = dateRangeType == 3
? await _querySleepHeartRate(sleepResults)
: const <HealthKitRawDataPoint>[];
return AppSuccess(
LocalHealthDataConvert.sleepStatistics(
LocalHealthDataConvert.sleepStatisticsFromResults(
dateRangeType: dateRangeType,
days: days,
previousDays: previousDays,
sleepIntervals: sleepIntervals,
sleepResults: sleepResults,
heartRate: heartRate,
),
);
... ... @@ -188,12 +188,6 @@ class LocalHealthDataSource implements HealthDataSource {
day.add(const Duration(days: 1)),
) -
1;
final sleepStart = LocalHealthDataConvert.unixSeconds(
day.subtract(const Duration(days: 1)),
);
final sleepEnd = LocalHealthDataConvert.unixSeconds(
day.add(const Duration(days: 1)),
);
final hrvPoints = await coreService.queryHrvStressPoints(
startTime: dayStart,
endTime: dayEnd,
... ... @@ -202,28 +196,27 @@ class LocalHealthDataSource implements HealthDataSource {
startTime: dayStart,
endTime: dayEnd,
);
final sleepingHeartRate = await coreService.queryRawDataPoints(
dataType: HealthDataUploadType.heartRate.type,
startTime: sleepStart,
endTime: sleepEnd,
final sleepResults = await coreService.querySleepResults(
startTime: dayStart,
endTime: dayEnd,
);
final sleepResult = sleepResults.isEmpty ? null : sleepResults.last;
final sleepingHeartRate = await _querySleepHeartRate(
sleepResult == null ? const [] : [sleepResult],
);
final restingHeartRate = await coreService.queryRawDataPoints(
dataType: HealthDataUploadType.restingHeartRate.type,
startTime: dayStart,
endTime: dayEnd,
);
final sleepIntervals = await coreService.queryRawSleepIntervals(
startTime: sleepStart,
endTime: sleepEnd,
);
return AppSuccess(
LocalHealthDataConvert.v2HealthData(
LocalHealthDataConvert.v2HealthDataFromSleepResult(
day: day,
hrvPoints: hrvPoints,
activity: activity,
sleepingHeartRate: sleepingHeartRate,
restingHeartRate: restingHeartRate,
sleepIntervals: sleepIntervals,
sleepResult: sleepResult,
),
);
} catch (error) {
... ... @@ -303,4 +296,19 @@ class LocalHealthDataSource implements HealthDataSource {
LocalHealthDataConvert.unixSeconds(day.add(const Duration(days: 1))) - 1,
);
}
Future<List<HealthKitRawDataPoint>> _querySleepHeartRate(
List<HealthRawSleepResult> sleepResults,
) async {
if (sleepResults.isEmpty) return const [];
final startTime =
sleepResults.map((e) => e.startDate).reduce((a, b) => a < b ? a : b);
final endTime =
sleepResults.map((e) => e.date).reduce((a, b) => a > b ? a : b);
return coreService.queryRawDataPoints(
dataType: HealthDataUploadType.heartRate.type,
startTime: startTime,
endTime: endTime,
);
}
}
... ...
... ... @@ -525,8 +525,37 @@ class HealthKitRawDataHostApi {
}
}
/// 上传睡眠统计数据
Future<int> performSleepAnalysisDataUpload({required String sqliteFilePath}) async {
final String pigeonVar_channelName = 'dev.flutter.pigeon.doublefeel_flutter.HealthKitRawDataHostApi.performSleepAnalysisDataUpload$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[sqliteFilePath]);
final List<Object?>? pigeonVar_replyList =
await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) {
throw _createConnectionError(pigeonVar_channelName);
} else if (pigeonVar_replyList.length > 1) {
throw PlatformException(
code: pigeonVar_replyList[0]! as String,
message: pigeonVar_replyList[1] as String?,
details: pigeonVar_replyList[2],
);
} else if (pigeonVar_replyList[0] == null) {
throw PlatformException(
code: 'null-error',
message: 'Host platform returned null value for non-null return value.',
);
} else {
return (pigeonVar_replyList[0] as int?)!;
}
}
/// 上传当前实时压力(统计维度是当天)
Future<bool> performAvgRealtimeStressDataUpload({required String sqliteFilePath}) async {
Future<int> performAvgRealtimeStressDataUpload({required String sqliteFilePath}) async {
final String pigeonVar_channelName = 'dev.flutter.pigeon.doublefeel_flutter.HealthKitRawDataHostApi.performAvgRealtimeStressDataUpload$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
... ... @@ -550,7 +579,7 @@ class HealthKitRawDataHostApi {
message: 'Host platform returned null value for non-null return value.',
);
} else {
return (pigeonVar_replyList[0] as bool?)!;
return (pigeonVar_replyList[0] as int?)!;
}
}
... ...
... ... @@ -110,9 +110,13 @@ abstract class HealthKitRawDataHostApi {
@async
int performHRVDataUpload({required String sqliteFilePath});
/// 上传睡眠统计数据
@async
int performSleepAnalysisDataUpload({required String sqliteFilePath});
/// 上传当前实时压力(统计维度是当天)
@async
bool performAvgRealtimeStressDataUpload({required String sqliteFilePath});
int performAvgRealtimeStressDataUpload({required String sqliteFilePath});
/// 从从AppleHealth中读取睡眠数据
@async
... ...
import 'package:doublefeel_flutter/core/services/health_raw_data_core_service.dart';
import 'package:doublefeel_flutter/data/datasource/health/health_local_data_convert.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';
... ... @@ -69,11 +70,16 @@ void main() {
);
api.calls.clear();
await service.startCoreCaculate(
final second = await service.startCoreCaculate(
endTime: base + 800,
readChunkDays: 1,
);
expect(second.hrvStressPoints.map((e) => e.rawEndTime), [base + 720]);
expect(
second.realtimeStressPoints.map((e) => e.rawEndTime),
[base + 720],
);
expect(
api.firstCallStart(HealthDataUploadType.hrv.type),
lessThanOrEqualTo(base + 120),
... ... @@ -318,6 +324,93 @@ void main() {
[HealthDataUploadType.hrv.type, HealthDataUploadType.heartRate.type],
);
});
test('startCoreCaculate calculates and stores sleep results', () async {
final now = DateTime.now();
final day = DateTime(now.year, now.month, now.day);
final sleepStart = day.subtract(const Duration(hours: 1, minutes: 30));
final sleepEnd = day.add(const Duration(hours: 6, minutes: 30));
final api = _FakeHealthKitRawDataHostApi();
api.setSleepPoints([
HealthKitRawDataPoint(
dataType: 3,
startTime: LocalHealthDataConvert.unixSeconds(sleepStart),
endTime: LocalHealthDataConvert.unixSeconds(sleepEnd),
),
]);
final store = _MemoryHealthRawStressLocalStore();
final service = HealthRawDataCoreService(
healthApi: _FakeHealthKitHostApi(),
rawDataApi: api,
localStore: store,
userIdProvider: () => 42,
uploadResultsAfterCalculation: false,
);
final result = await service.startCoreCaculate(
endTime: LocalHealthDataConvert.unixSeconds(day.add(
const Duration(hours: 12),
)),
readChunkDays: 1,
);
final rows = await store.debugQueryAllRows(42);
expect(result.sleepResults, hasLength(1));
expect(rows.sleepRows, hasLength(1));
expect(rows.sleepRows.single['date'],
LocalHealthDataConvert.unixSeconds(sleepEnd));
expect(rows.sleepRows.single['start_date'],
LocalHealthDataConvert.unixSeconds(sleepStart));
expect(rows.sleepRows.single['sleep_state'], 2);
expect(rows.sleepRows.single['sleep_minutes'], 480);
expect(rows.sleepRows.single['uploaded'], 0);
final second = await service.startCoreCaculate(
endTime: LocalHealthDataConvert.unixSeconds(day.add(
const Duration(hours: 12),
)),
readChunkDays: 1,
);
final secondRows = await store.debugQueryAllRows(42);
expect(second.sleepResults, isEmpty);
expect(secondRows.sleepRows, hasLength(1));
});
test('startCoreCaculate uploads sleep results and marks uploaded', () async {
final now = DateTime.now();
final day = DateTime(now.year, now.month, now.day);
final sleepStart = day.subtract(const Duration(hours: 1));
final sleepEnd = day.add(const Duration(hours: 7));
final sleepEndSeconds = LocalHealthDataConvert.unixSeconds(sleepEnd);
final api = _FakeHealthKitRawDataHostApi()
..sleepUploadUntil = sleepEndSeconds;
api.setSleepPoints([
HealthKitRawDataPoint(
dataType: 3,
startTime: LocalHealthDataConvert.unixSeconds(sleepStart),
endTime: sleepEndSeconds,
),
]);
final store = _MemoryHealthRawStressLocalStore();
final service = HealthRawDataCoreService(
healthApi: _FakeHealthKitHostApi(),
rawDataApi: api,
localStore: store,
userIdProvider: () => 42,
);
await service.startCoreCaculate(
endTime: LocalHealthDataConvert.unixSeconds(day.add(
const Duration(hours: 12),
)),
readChunkDays: 1,
);
final rows = await store.debugQueryAllRows(42);
expect(api.sleepUploadCallCount, 1);
expect(rows.sleepRows.single['uploaded'], 1);
});
}
HealthKitRawDataPoint _point(
... ... @@ -337,9 +430,12 @@ HealthKitRawDataPoint _point(
class _FakeHealthKitRawDataHostApi extends HealthKitRawDataHostApi {
final Map<int, List<HealthKitRawDataPoint>> _pointsByType = {};
final List<HealthKitRawWorkoutDataPoint> _workoutPoints = [];
final List<HealthKitRawDataPoint> _sleepPoints = [];
final List<_ReadCall> calls = [];
var sleepCallCount = 0;
var workoutCallCount = 0;
var sleepUploadCallCount = 0;
var sleepUploadUntil = 0;
void setPoints(int dataType, List<HealthKitRawDataPoint> points) {
_pointsByType[dataType] = points;
... ... @@ -351,6 +447,12 @@ class _FakeHealthKitRawDataHostApi extends HealthKitRawDataHostApi {
..addAll(points);
}
void setSleepPoints(List<HealthKitRawDataPoint> points) {
_sleepPoints
..clear()
..addAll(points);
}
int firstCallStart(int dataType) {
return calls.firstWhere((e) => e.dataType == dataType).startTime;
}
... ... @@ -374,10 +476,18 @@ class _FakeHealthKitRawDataHostApi extends HealthKitRawDataHostApi {
Future<int> performHRVDataUpload({required String sqliteFilePath}) async => 0;
@override
Future<bool> performAvgRealtimeStressDataUpload({
Future<int> performAvgRealtimeStressDataUpload({
required String sqliteFilePath,
}) async =>
false;
0;
@override
Future<int> performSleepAnalysisDataUpload({
required String sqliteFilePath,
}) async {
sleepUploadCallCount += 1;
return sleepUploadUntil;
}
@override
Future<List<HealthKitRawSleepDataPoint>> getHealthKitRawSleepData(
... ... @@ -385,7 +495,16 @@ class _FakeHealthKitRawDataHostApi extends HealthKitRawDataHostApi {
int endTime,
) async {
sleepCallCount += 1;
return const <HealthKitRawSleepDataPoint>[];
final points = _sleepPoints
.where((e) => e.endTime >= startTime && e.startTime <= endTime)
.toList();
if (points.isEmpty) return const <HealthKitRawSleepDataPoint>[];
return [
HealthKitRawSleepDataPoint(
dataType: 3,
sleepDataPoints: points,
),
];
}
@override
... ... @@ -423,6 +542,7 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
final Map<int, List<HealthRawHrvStressPoint>> _hrv = {};
final Map<int, List<HealthRawRealtimeStressPoint>> _realtime = {};
final Map<int, List<HealthRawDailyStressPoint>> _daily = {};
final Map<int, List<HealthRawSleepResult>> _sleep = {};
final List<String> operationLog = [];
void insertDailyStress(HealthRawDailyStressPoint point) {
... ... @@ -442,6 +562,9 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
Future<void> prepareDatabaseFileForShare(int userId) async {}
@override
Future<String> dbPath(int userId) async => 'memory-$userId.sqlite';
@override
Future<void> upsertResult(HealthRawStressCalculationResult result) async {
operationLog.add('upsertResult');
final hrvByTime = <int, HealthRawHrvStressPoint>{
... ... @@ -526,6 +649,22 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
}
@override
Future<void> upsertSleepResults({
required int userId,
required Iterable<HealthRawSleepResult> results,
}) async {
final byDate = <int, HealthRawSleepResult>{
for (final result in _sleep[userId] ?? <HealthRawSleepResult>[])
result.date: result,
};
for (final result in results) {
byDate[result.date] = result;
}
_sleep[userId] = byDate.values.toList()
..sort((a, b) => a.date.compareTo(b.date));
}
@override
Future<Set<int>> existingDailyStressDates({
required int userId,
required Iterable<int> dates,
... ... @@ -583,6 +722,17 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
}
@override
Future<List<HealthRawSleepResult>> querySleepResults({
required int userId,
required int startTime,
required int endTime,
}) async {
return (_sleep[userId] ?? <HealthRawSleepResult>[])
.where((e) => e.date >= startTime && e.date <= endTime)
.toList();
}
@override
Future<HealthRawStressDbRows> debugQueryAllRows(int userId) async {
return HealthRawStressDbRows(
hrvRows:
... ... @@ -593,6 +743,8 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
dailyStressRows: (_daily[userId] ?? <HealthRawDailyStressPoint>[])
.map(_dailyStressRow)
.toList(),
sleepRows:
(_sleep[userId] ?? <HealthRawSleepResult>[]).map(_sleepRow).toList(),
);
}
... ... @@ -611,6 +763,27 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
}
@override
Future<int?> latestHrvRawEndTime(int userId) async {
final points = _hrv[userId];
if (points == null || points.isEmpty) return null;
return points.last.rawEndTime;
}
@override
Future<int?> latestRealtimeRawEndTime(int userId) async {
final points = _realtime[userId];
if (points == null || points.isEmpty) return null;
return points.last.rawEndTime;
}
@override
Future<int?> latestSleepResultTime(int userId) async {
final results = _sleep[userId];
if (results == null || results.isEmpty) return null;
return results.last.date;
}
@override
Future<void> markHrvStressUploaded({
required int userId,
required Iterable<int> rawEndTimes,
... ... @@ -681,18 +854,44 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
}
@override
Future<void> markDailyStressUploaded({required int userId}) async {
_daily[userId] = (_daily[userId] ?? <HealthRawDailyStressPoint>[])
.map((point) => HealthRawDailyStressPoint(
userId: point.userId,
date: point.date,
stressValue: point.stressValue,
stressScore: point.stressScore,
state: point.state,
dataTime: point.dataTime,
uploaded: true,
))
.toList();
Future<void> markDailyStressUploadedUntil({
required int userId,
required int date,
}) async {
_daily[userId] =
(_daily[userId] ?? <HealthRawDailyStressPoint>[]).map((point) {
if (point.date > date) return point;
return HealthRawDailyStressPoint(
userId: point.userId,
date: point.date,
stressValue: point.stressValue,
stressScore: point.stressScore,
state: point.state,
dataTime: point.dataTime,
uploaded: true,
);
}).toList();
}
@override
Future<void> markSleepResultsUploadedUntil({
required int userId,
required int date,
}) async {
_sleep[userId] = (_sleep[userId] ?? <HealthRawSleepResult>[]).map((result) {
if (result.date > date) return result;
return HealthRawSleepResult(
userId: result.userId,
date: result.date,
startDate: result.startDate,
sleepScore: result.sleepScore,
sleepState: result.sleepState,
inBedMinutes: result.inBedMinutes,
awakMinutes: result.awakMinutes,
sleepMinutes: result.sleepMinutes,
uploaded: true,
);
}).toList();
}
@override
... ... @@ -713,6 +912,12 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
.any((point) => !point.uploaded);
}
@override
Future<bool> hasPendingSleepResultUploads({required int userId}) async {
return (_sleep[userId] ?? <HealthRawSleepResult>[])
.any((result) => !result.uploaded);
}
bool _sameHrvStressPoint(
HealthRawHrvStressPoint a,
HealthRawHrvStressPoint b,
... ... @@ -793,4 +998,18 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
'uploaded': point.uploaded ? 1 : 0,
};
}
Map<String, Object?> _sleepRow(HealthRawSleepResult result) {
return <String, Object?>{
'date': result.date,
'user_id': result.userId,
'start_date': result.startDate,
'sleep_score': result.sleepScore,
'sleep_state': result.sleepState,
'in_bed_minutes': result.inBedMinutes,
'awak_minutes': result.awakMinutes,
'sleep_minutes': result.sleepMinutes,
'uploaded': result.uploaded ? 1 : 0,
};
}
}
... ...
... ... @@ -124,10 +124,11 @@ void main() {
);
});
test('sleep duration is rounded to displayed Apple Health minutes', () {
test('sleep duration is rounded after summing sleep stage seconds', () {
final day = DateTime(2026, 7, 16);
final sleepStart = DateTime(2026, 7, 15, 23, 56, 20);
final sleepEnd = DateTime(2026, 7, 16, 8, 20, 50);
final coreEnd = DateTime(2026, 7, 16, 4, 10, 50);
final remEnd = DateTime(2026, 7, 16, 8, 20, 51);
final statistics = LocalHealthDataConvert.sleepStatistics(
dateRangeType: 3,
days: [day],
... ... @@ -136,7 +137,12 @@ void main() {
HealthKitRawDataPoint(
dataType: 3,
startTime: LocalHealthDataConvert.unixSeconds(sleepStart),
endTime: LocalHealthDataConvert.unixSeconds(sleepEnd),
endTime: LocalHealthDataConvert.unixSeconds(coreEnd),
),
HealthKitRawDataPoint(
dataType: 5,
startTime: LocalHealthDataConvert.unixSeconds(coreEnd),
endTime: LocalHealthDataConvert.unixSeconds(remEnd),
),
],
heartRate: const [],
... ...