Commit a68331488cba1f5715917abecee8bd5f41a2c6a4

Authored by 权海
1 parent 987f24d7

feat(ui):本地Health数据对接

@@ -3,7 +3,7 @@ @@ -3,7 +3,7 @@
3 archiveVersion = 1; 3 archiveVersion = 1;
4 classes = { 4 classes = {
5 }; 5 };
6 - objectVersion = 77; 6 + objectVersion = 54;
7 objects = { 7 objects = {
8 8
9 /* Begin PBXBuildFile section */ 9 /* Begin PBXBuildFile section */
@@ -342,14 +342,10 @@ @@ -342,14 +342,10 @@
342 inputFileListPaths = ( 342 inputFileListPaths = (
343 "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", 343 "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
344 ); 344 );
345 - inputPaths = (  
346 - );  
347 name = "[CP] Embed Pods Frameworks"; 345 name = "[CP] Embed Pods Frameworks";
348 outputFileListPaths = ( 346 outputFileListPaths = (
349 "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", 347 "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
350 ); 348 );
351 - outputPaths = (  
352 - );  
353 runOnlyForDeploymentPostprocessing = 0; 349 runOnlyForDeploymentPostprocessing = 0;
354 shellPath = /bin/sh; 350 shellPath = /bin/sh;
355 shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; 351 shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
@@ -420,14 +416,10 @@ @@ -420,14 +416,10 @@
420 inputFileListPaths = ( 416 inputFileListPaths = (
421 "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", 417 "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
422 ); 418 );
423 - inputPaths = (  
424 - );  
425 name = "[CP] Copy Pods Resources"; 419 name = "[CP] Copy Pods Resources";
426 outputFileListPaths = ( 420 outputFileListPaths = (
427 "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", 421 "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
428 ); 422 );
429 - outputPaths = (  
430 - );  
431 runOnlyForDeploymentPostprocessing = 0; 423 runOnlyForDeploymentPostprocessing = 0;
432 shellPath = /bin/sh; 424 shellPath = /bin/sh;
433 shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; 425 shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
@@ -66,33 +66,32 @@ final class HealthKitService { @@ -66,33 +66,32 @@ final class HealthKitService {
66 guard isHealthDataAvailable else { return false } 66 guard isHealthDataAvailable else { return false }
67 67
68 let endDate = Date() 68 let endDate = Date()
69 - let startDate = requestedStartDate  
70 - ?? Calendar.current.date(byAdding: .year, value: -1, to: endDate)  
71 - ?? Date(timeInterval: -1 * 365 * 24 * 60 * 60, since: endDate)  
72 -  
73 - if let sleepType = NativeHealthTypeCatalog.category(.sleepAnalysis),  
74 - await hasAnyReadableSample(  
75 - of: [sleepType],  
76 - startDate: startDate, 69 + let sampleTypes = NativeHealthTypeCatalog.readTypes.compactMap { $0 as? HKSampleType }
  70 + if await hasAnyReadableSample(
  71 + of: sampleTypes,
  72 + startDate: requestedStartDate,
77 endDate: endDate 73 endDate: endDate
78 ) { 74 ) {
79 return true 75 return true
80 } 76 }
  77 +
  78 + if NativeHealthTypeCatalog.readTypes.contains(.activitySummaryType()),
  79 + await hasAnyReadableActivitySummary(startDate: requestedStartDate, endDate: endDate) {
  80 + return true
  81 + }
81 return false 82 return false
82 } 83 }
83 84
84 private func hasAnyReadableSample( 85 private func hasAnyReadableSample(
85 of sampleTypes: [HKSampleType], 86 of sampleTypes: [HKSampleType],
86 - startDate: Date, 87 + startDate: Date?,
87 endDate: Date 88 endDate: Date
88 ) async -> Bool { 89 ) async -> Bool {
89 guard !sampleTypes.isEmpty else { return false } 90 guard !sampleTypes.isEmpty else { return false }
90 91
91 - let predicate = HKQuery.predicateForSamples(  
92 - withStart: startDate,  
93 - end: endDate,  
94 - options: []  
95 - ) 92 + let predicate = startDate.map {
  93 + HKQuery.predicateForSamples(withStart: $0, end: endDate, options: [])
  94 + }
96 let state = HealthReadableDataProbeState() 95 let state = HealthReadableDataProbeState()
97 96
98 return await withCheckedContinuation { continuation in 97 return await withCheckedContinuation { continuation in
@@ -119,6 +118,27 @@ final class HealthKitService { @@ -119,6 +118,27 @@ final class HealthKitService {
119 } 118 }
120 } 119 }
121 120
  121 + private func hasAnyReadableActivitySummary(startDate: Date?, endDate: Date) async -> Bool {
  122 + let calendar = Calendar.current
  123 + let start = startDate ?? Date(timeIntervalSince1970: 0)
  124 + var startComponents = calendar.dateComponents([.era, .year, .month, .day], from: start)
  125 + var endComponents = calendar.dateComponents([.era, .year, .month, .day], from: endDate)
  126 + startComponents.calendar = calendar
  127 + endComponents.calendar = calendar
  128 +
  129 + let predicate = HKQuery.predicate(
  130 + forActivitySummariesBetweenStart: startComponents,
  131 + end: endComponents
  132 + )
  133 +
  134 + return await withCheckedContinuation { continuation in
  135 + let query = HKActivitySummaryQuery(predicate: predicate) { _, summaries, _ in
  136 + continuation.resume(returning: summaries?.isEmpty == false)
  137 + }
  138 + healthStore.execute(query)
  139 + }
  140 + }
  141 +
122 func startBackgroundObserversIfNeeded() { 142 func startBackgroundObserversIfNeeded() {
123 Task { @MainActor [weak self] in 143 Task { @MainActor [weak self] in
124 self?.observerController.startIfNeeded() 144 self?.observerController.startIfNeeded()
@@ -364,6 +364,8 @@ class HealthKitRawDataApiPigeonCodec: FlutterStandardMessageCodec, @unchecked Se @@ -364,6 +364,8 @@ class HealthKitRawDataApiPigeonCodec: FlutterStandardMessageCodec, @unchecked Se
364 364
365 /// Generated protocol from Pigeon that represents a handler of messages from Flutter. 365 /// Generated protocol from Pigeon that represents a handler of messages from Flutter.
366 protocol HealthKitRawDataHostApi { 366 protocol HealthKitRawDataHostApi {
  367 + /// 是否有AppleHealth数据
  368 + func hasHealthData(completion: @escaping (Result<Bool, Error>) -> Void)
367 /// 从AppleHealth 中读取原始数据 369 /// 从AppleHealth 中读取原始数据
368 func getHealthKitRawData(dataType: Int64, startTime: Int64, endTime: Int64, completion: @escaping (Result<[HealthKitRawDataPoint], Error>) -> Void) 370 func getHealthKitRawData(dataType: Int64, startTime: Int64, endTime: Int64, completion: @escaping (Result<[HealthKitRawDataPoint], Error>) -> Void)
369 /// 从从AppleHealth中读取睡眠数据 371 /// 从从AppleHealth中读取睡眠数据
@@ -382,6 +384,22 @@ class HealthKitRawDataHostApiSetup { @@ -382,6 +384,22 @@ class HealthKitRawDataHostApiSetup {
382 /// Sets up an instance of `HealthKitRawDataHostApi` to handle messages through the `binaryMessenger`. 384 /// Sets up an instance of `HealthKitRawDataHostApi` to handle messages through the `binaryMessenger`.
383 static func setUp(binaryMessenger: FlutterBinaryMessenger, api: HealthKitRawDataHostApi?, messageChannelSuffix: String = "") { 385 static func setUp(binaryMessenger: FlutterBinaryMessenger, api: HealthKitRawDataHostApi?, messageChannelSuffix: String = "") {
384 let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : "" 386 let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : ""
  387 + /// 是否有AppleHealth数据
  388 + let hasHealthDataChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.doublefeel_flutter.HealthKitRawDataHostApi.hasHealthData\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
  389 + if let api = api {
  390 + hasHealthDataChannel.setMessageHandler { _, reply in
  391 + api.hasHealthData { result in
  392 + switch result {
  393 + case .success(let res):
  394 + reply(wrapResult(res))
  395 + case .failure(let error):
  396 + reply(wrapError(error))
  397 + }
  398 + }
  399 + }
  400 + } else {
  401 + hasHealthDataChannel.setMessageHandler(nil)
  402 + }
385 /// 从AppleHealth 中读取原始数据 403 /// 从AppleHealth 中读取原始数据
386 let getHealthKitRawDataChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.doublefeel_flutter.HealthKitRawDataHostApi.getHealthKitRawData\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) 404 let getHealthKitRawDataChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.doublefeel_flutter.HealthKitRawDataHostApi.getHealthKitRawData\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
387 if let api = api { 405 if let api = api {
1 import Foundation 1 import Foundation
2 2
3 final class HealthKitRawDataHostApiImpl: HealthKitRawDataHostApi { 3 final class HealthKitRawDataHostApiImpl: HealthKitRawDataHostApi {
  4 +
4 private let service: HealthKitService 5 private let service: HealthKitService
5 6
6 init(service: HealthKitService = .shared) { 7 init(service: HealthKitService = .shared) {
7 self.service = service 8 self.service = service
8 } 9 }
  10 +
  11 + func hasHealthData(completion: @escaping (Result<Bool, Error>) -> Void) {
  12 + Task {
  13 + let hasData = await service.hasAnyReadableData()
  14 + completion(.success(hasData))
  15 + }
  16 + }
9 17
10 func getHealthKitRawData( 18 func getHealthKitRawData(
11 dataType: Int64, 19 dataType: Int64,
@@ -57,6 +57,10 @@ class HealthRawDataCoreService { @@ -57,6 +57,10 @@ class HealthRawDataCoreService {
57 return _localStore.closeDatabase(userId: _userId); 57 return _localStore.closeDatabase(userId: _userId);
58 } 58 }
59 59
  60 + Future<bool> hasHealthData() {
  61 + return _rawDataApi.hasHealthData();
  62 + }
  63 +
60 Stream<HealthRawDataUpdatedEvent> get healthDataUpdatedStream => 64 Stream<HealthRawDataUpdatedEvent> get healthDataUpdatedStream =>
61 _healthDataUpdatedController.stream; 65 _healthDataUpdatedController.stream;
62 66
@@ -732,6 +736,7 @@ class HealthRawDataCoreService { @@ -732,6 +736,7 @@ class HealthRawDataCoreService {
732 HealthRawHrvStressPoint( 736 HealthRawHrvStressPoint(
733 userId: userId, 737 userId: userId,
734 rawEndTime: point.endTime, 738 rawEndTime: point.endTime,
  739 + rawHrv: pointValue,
735 result: trendHrv, 740 result: trendHrv,
736 sourceStartTime: sourceRange.start, 741 sourceStartTime: sourceRange.start,
737 sourceEndTime: sourceRange.end, 742 sourceEndTime: sourceRange.end,
@@ -1263,6 +1268,7 @@ class HealthRawHrvStressPoint { @@ -1263,6 +1268,7 @@ class HealthRawHrvStressPoint {
1263 const HealthRawHrvStressPoint({ 1268 const HealthRawHrvStressPoint({
1264 required this.userId, 1269 required this.userId,
1265 required this.rawEndTime, 1270 required this.rawEndTime,
  1271 + required this.rawHrv,
1266 required this.result, 1272 required this.result,
1267 required this.sourceStartTime, 1273 required this.sourceStartTime,
1268 required this.sourceEndTime, 1274 required this.sourceEndTime,
@@ -1277,6 +1283,7 @@ class HealthRawHrvStressPoint { @@ -1277,6 +1283,7 @@ class HealthRawHrvStressPoint {
1277 1283
1278 final int userId; 1284 final int userId;
1279 final int rawEndTime; 1285 final int rawEndTime;
  1286 + final double rawHrv;
1280 final double result; 1287 final double result;
1281 final int sourceStartTime; 1288 final int sourceStartTime;
1282 final int sourceEndTime; 1289 final int sourceEndTime;
@@ -1290,16 +1297,18 @@ class HealthRawHrvStressPoint { @@ -1290,16 +1297,18 @@ class HealthRawHrvStressPoint {
1290 1297
1291 factory HealthRawHrvStressPoint.fromDb(Map<String, Object?> row) { 1298 factory HealthRawHrvStressPoint.fromDb(Map<String, Object?> row) {
1292 final result = (row['result'] as num).toDouble(); 1299 final result = (row['result'] as num).toDouble();
  1300 + final rawHrv = _positiveDouble(row['raw_hrv']) ?? result;
1293 final baselineHrv = _positiveDouble(row['baseline_hrv']) ?? result; 1301 final baselineHrv = _positiveDouble(row['baseline_hrv']) ?? result;
1294 final baselineAwakeHrv = 1302 final baselineAwakeHrv =
1295 _positiveDouble(row['baseline_awake_hrv']) ?? baselineHrv; 1303 _positiveDouble(row['baseline_awake_hrv']) ?? baselineHrv;
1296 return HealthRawHrvStressPoint( 1304 return HealthRawHrvStressPoint(
1297 userId: row['user_id'] as int, 1305 userId: row['user_id'] as int,
1298 rawEndTime: row['raw_end_time'] as int, 1306 rawEndTime: row['raw_end_time'] as int,
  1307 + rawHrv: rawHrv,
1299 result: result, 1308 result: result,
1300 sourceStartTime: row['source_start_time'] as int, 1309 sourceStartTime: row['source_start_time'] as int,
1301 sourceEndTime: row['source_end_time'] as int, 1310 sourceEndTime: row['source_end_time'] as int,
1302 - state: HealthRawStressState.normal, 1311 + state: _stressStateFromDb(row['state']) ?? HealthRawStressState.normal,
1303 baselineHrv: baselineHrv, 1312 baselineHrv: baselineHrv,
1304 baselineAwakeHrv: baselineAwakeHrv, 1313 baselineAwakeHrv: baselineAwakeHrv,
1305 baselineSleepHrv: _positiveDouble(row['baseline_sleep_hrv']), 1314 baselineSleepHrv: _positiveDouble(row['baseline_sleep_hrv']),
@@ -1329,6 +1338,8 @@ class HealthRawRealtimeStressPoint { @@ -1329,6 +1338,8 @@ class HealthRawRealtimeStressPoint {
1329 final HealthRawPointFlags flags; 1338 final HealthRawPointFlags flags;
1330 final bool uploaded; 1339 final bool uploaded;
1331 1340
  1341 + HealthRawStressState get state => healthRawRealtimeStressState(result);
  1342 +
1332 factory HealthRawRealtimeStressPoint.fromDb(Map<String, Object?> row) { 1343 factory HealthRawRealtimeStressPoint.fromDb(Map<String, Object?> row) {
1333 return HealthRawRealtimeStressPoint( 1344 return HealthRawRealtimeStressPoint(
1334 userId: row['user_id'] as int, 1345 userId: row['user_id'] as int,
@@ -1342,6 +1353,13 @@ class HealthRawRealtimeStressPoint { @@ -1342,6 +1353,13 @@ class HealthRawRealtimeStressPoint {
1342 } 1353 }
1343 } 1354 }
1344 1355
  1356 +HealthRawStressState healthRawRealtimeStressState(num value) {
  1357 + if (value >= 81) return HealthRawStressState.overload;
  1358 + if (value >= 61) return HealthRawStressState.attention;
  1359 + if (value >= 21) return HealthRawStressState.normal;
  1360 + return HealthRawStressState.excellent;
  1361 +}
  1362 +
1345 HealthRawPointFlags _flagsFromDb(Map<String, Object?> row) { 1363 HealthRawPointFlags _flagsFromDb(Map<String, Object?> row) {
1346 return HealthRawPointFlags( 1364 return HealthRawPointFlags(
1347 isWorkout: (row['is_workout'] as int? ?? 0) == 1, 1365 isWorkout: (row['is_workout'] as int? ?? 0) == 1,
@@ -1357,6 +1375,15 @@ double? _positiveDouble(Object? value) { @@ -1357,6 +1375,15 @@ double? _positiveDouble(Object? value) {
1357 return result; 1375 return result;
1358 } 1376 }
1359 1377
  1378 +HealthRawStressState? _stressStateFromDb(Object? value) {
  1379 + final stateValue = value as int?;
  1380 + if (stateValue == null) return null;
  1381 + for (final state in HealthRawStressState.values) {
  1382 + if (state.value == stateValue) return state;
  1383 + }
  1384 + return null;
  1385 +}
  1386 +
1360 class HealthRawStressDbRows { 1387 class HealthRawStressDbRows {
1361 const HealthRawStressDbRows({ 1388 const HealthRawStressDbRows({
1362 required this.hrvRows, 1389 required this.hrvRows,
@@ -1542,7 +1569,7 @@ class HealthRawStressLocalStore { @@ -1542,7 +1569,7 @@ class HealthRawStressLocalStore {
1542 final db = await factory.openDatabase( 1569 final db = await factory.openDatabase(
1543 path, 1570 path,
1544 options: OpenDatabaseOptions( 1571 options: OpenDatabaseOptions(
1545 - version: 3, 1572 + version: 4,
1546 onCreate: (db, version) async { 1573 onCreate: (db, version) async {
1547 await _createTables(db); 1574 await _createTables(db);
1548 }, 1575 },
@@ -1554,6 +1581,9 @@ class HealthRawStressLocalStore { @@ -1554,6 +1581,9 @@ class HealthRawStressLocalStore {
1554 if (oldVersion < 3) { 1581 if (oldVersion < 3) {
1555 await _addHrvBaselineColumns(db); 1582 await _addHrvBaselineColumns(db);
1556 } 1583 }
  1584 + if (oldVersion < 4) {
  1585 + await _addHrvRawAndStateColumns(db);
  1586 + }
1557 }, 1587 },
1558 ), 1588 ),
1559 ); 1589 );
@@ -1566,9 +1596,11 @@ class HealthRawStressLocalStore { @@ -1566,9 +1596,11 @@ class HealthRawStressLocalStore {
1566 CREATE TABLE IF NOT EXISTS $hrvResultsTable ( 1596 CREATE TABLE IF NOT EXISTS $hrvResultsTable (
1567 raw_end_time INTEGER PRIMARY KEY, 1597 raw_end_time INTEGER PRIMARY KEY,
1568 user_id INTEGER NOT NULL, 1598 user_id INTEGER NOT NULL,
  1599 + raw_hrv REAL NOT NULL DEFAULT 0,
1569 result REAL NOT NULL, 1600 result REAL NOT NULL,
1570 source_start_time INTEGER NOT NULL, 1601 source_start_time INTEGER NOT NULL,
1571 source_end_time INTEGER NOT NULL, 1602 source_end_time INTEGER NOT NULL,
  1603 + state INTEGER NOT NULL DEFAULT 3,
1572 baseline_hrv REAL NOT NULL DEFAULT 0, 1604 baseline_hrv REAL NOT NULL DEFAULT 0,
1573 baseline_awake_hrv REAL NOT NULL DEFAULT 0, 1605 baseline_awake_hrv REAL NOT NULL DEFAULT 0,
1574 baseline_sleep_hrv REAL, 1606 baseline_sleep_hrv REAL,
@@ -1630,6 +1662,21 @@ CREATE TABLE IF NOT EXISTS $realtimeStressResultsTable ( @@ -1630,6 +1662,21 @@ CREATE TABLE IF NOT EXISTS $realtimeStressResultsTable (
1630 } 1662 }
1631 } 1663 }
1632 1664
  1665 + Future<void> _addHrvRawAndStateColumns(DatabaseExecutor db) async {
  1666 + for (final columnSql in const [
  1667 + 'raw_hrv REAL NOT NULL DEFAULT 0',
  1668 + 'state INTEGER NOT NULL DEFAULT 3',
  1669 + ]) {
  1670 + try {
  1671 + await db.execute(
  1672 + 'ALTER TABLE $hrvResultsTable ADD COLUMN $columnSql',
  1673 + );
  1674 + } on DatabaseException catch (error) {
  1675 + if (!error.isDuplicateColumnError()) rethrow;
  1676 + }
  1677 + }
  1678 + }
  1679 +
1633 Future<int?> _latestSourceStartTime(int userId, String table) async { 1680 Future<int?> _latestSourceStartTime(int userId, String table) async {
1634 final db = await _database(userId); 1681 final db = await _database(userId);
1635 final rows = await db.query( 1682 final rows = await db.query(
@@ -1662,9 +1709,11 @@ CREATE TABLE IF NOT EXISTS $realtimeStressResultsTable ( @@ -1662,9 +1709,11 @@ CREATE TABLE IF NOT EXISTS $realtimeStressResultsTable (
1662 return <String, Object?>{ 1709 return <String, Object?>{
1663 'raw_end_time': point.rawEndTime, 1710 'raw_end_time': point.rawEndTime,
1664 'user_id': point.userId, 1711 'user_id': point.userId,
  1712 + 'raw_hrv': point.rawHrv,
1665 'result': point.result, 1713 'result': point.result,
1666 'source_start_time': point.sourceStartTime, 1714 'source_start_time': point.sourceStartTime,
1667 'source_end_time': point.sourceEndTime, 1715 'source_end_time': point.sourceEndTime,
  1716 + 'state': point.state.value,
1668 'baseline_hrv': point.baselineHrv, 1717 'baseline_hrv': point.baselineHrv,
1669 'baseline_awake_hrv': point.baselineAwakeHrv, 1718 'baseline_awake_hrv': point.baselineAwakeHrv,
1670 'baseline_sleep_hrv': point.baselineSleepHrv, 1719 'baseline_sleep_hrv': point.baselineSleepHrv,
@@ -1709,9 +1758,11 @@ CREATE TABLE IF NOT EXISTS $realtimeStressResultsTable ( @@ -1709,9 +1758,11 @@ CREATE TABLE IF NOT EXISTS $realtimeStressResultsTable (
1709 table, 1758 table,
1710 <String, Object?>{ 1759 <String, Object?>{
1711 'user_id': row['user_id'], 1760 'user_id': row['user_id'],
  1761 + if (row.containsKey('raw_hrv')) 'raw_hrv': row['raw_hrv'],
1712 'result': row['result'], 1762 'result': row['result'],
1713 'source_start_time': row['source_start_time'], 1763 'source_start_time': row['source_start_time'],
1714 'source_end_time': row['source_end_time'], 1764 'source_end_time': row['source_end_time'],
  1765 + if (row.containsKey('state')) 'state': row['state'],
1715 if (row.containsKey('baseline_hrv')) 1766 if (row.containsKey('baseline_hrv'))
1716 'baseline_hrv': row['baseline_hrv'], 1767 'baseline_hrv': row['baseline_hrv'],
1717 if (row.containsKey('baseline_awake_hrv')) 1768 if (row.containsKey('baseline_awake_hrv'))
@@ -3,6 +3,7 @@ import 'dart:math' as math; @@ -3,6 +3,7 @@ import 'dart:math' as math;
3 import 'package:doublefeel_flutter/core/services/health_raw_data_core_service.dart'; 3 import 'package:doublefeel_flutter/core/services/health_raw_data_core_service.dart';
4 import 'package:doublefeel_flutter/data/models/enums/app_enums.dart'; 4 import 'package:doublefeel_flutter/data/models/enums/app_enums.dart';
5 import 'package:doublefeel_flutter/data/models/health/activity/activity_burn_statistics_data_v2.dart'; 5 import 'package:doublefeel_flutter/data/models/health/activity/activity_burn_statistics_data_v2.dart';
  6 +import 'package:doublefeel_flutter/data/models/health/health_v2_models.dart';
6 import 'package:doublefeel_flutter/data/models/health/hrv/hrv_statistics_data.dart'; 7 import 'package:doublefeel_flutter/data/models/health/hrv/hrv_statistics_data.dart';
7 import 'package:doublefeel_flutter/data/models/health/sleep/sleep_statistics_data.dart'; 8 import 'package:doublefeel_flutter/data/models/health/sleep/sleep_statistics_data.dart';
8 import 'package:doublefeel_flutter/pigeon/health_kit_raw_data_api.g.dart'; 9 import 'package:doublefeel_flutter/pigeon/health_kit_raw_data_api.g.dart';
@@ -253,6 +254,135 @@ class LocalHealthDataConvert { @@ -253,6 +254,135 @@ class LocalHealthDataConvert {
253 ); 254 );
254 } 255 }
255 256
  257 + static V2ActivityTarget v2ActivityTarget({
  258 + required int userId,
  259 + required List<HealthKitRawActivityDataPoint> activity,
  260 + }) {
  261 + final latestWithGoal = activity
  262 + .where(_hasActivityGoal)
  263 + .fold<HealthKitRawActivityDataPoint?>(
  264 + null,
  265 + (latest, item) =>
  266 + latest == null || item.endTime > latest.endTime ? item : latest,
  267 + );
  268 + return V2ActivityTarget(
  269 + userId: userId,
  270 + move: latestWithGoal?.activeEnergyBurnedGoal?.round(),
  271 + step: latestWithGoal?.appleMoveTimeGoal?.round(),
  272 + stand: latestWithGoal?.standHoursGoal?.round(),
  273 + exercise: latestWithGoal?.exerciseTimeGoal == null
  274 + ? null
  275 + : (latestWithGoal!.exerciseTimeGoal! * 60).round(),
  276 + updateTime: latestWithGoal?.endTime,
  277 + sleepTargetDuration: _sleepGoalMinutes.round(),
  278 + );
  279 + }
  280 +
  281 + static V2HealthData v2HealthData({
  282 + required DateTime day,
  283 + required List<HealthRawHrvStressPoint> hrvPoints,
  284 + required List<HealthKitRawActivityDataPoint> activity,
  285 + required List<HealthKitRawDataPoint> heartRate,
  286 + required List<HealthKitRawDataPoint> restingHeartRate,
  287 + required List<HealthKitRawDataPoint> sleepIntervals,
  288 + }) {
  289 + final activitySummary = _activityDaySummary(day, activity);
  290 + final sleepSummary = _sleepDaySummary(day, sleepIntervals, heartRate);
  291 + final dayStart = unixSeconds(day);
  292 + final dayEnd = unixSeconds(day.add(const Duration(days: 1)));
  293 + final sleepWindowStart =
  294 + unixSeconds(day.subtract(const Duration(hours: 12)));
  295 + final sleepWindowEnd = unixSeconds(day.add(const Duration(hours: 12)));
  296 + final sleepTimeList = sleepIntervals
  297 + .where(
  298 + (e) => e.endTime > sleepWindowStart && e.startTime < sleepWindowEnd)
  299 + .map(
  300 + (e) => V2SleepTimeRange(
  301 + fromTime: math.max(e.startTime, sleepWindowStart),
  302 + toTime: math.min(e.endTime, sleepWindowEnd),
  303 + ),
  304 + )
  305 + .toList();
  306 + final dayHr = heartRate
  307 + .where((e) => e.endTime >= dayStart && e.endTime < dayEnd)
  308 + .toList();
  309 + final dayRestingHr = restingHeartRate
  310 + .where((e) => e.endTime >= dayStart && e.endTime < dayEnd)
  311 + .toList()
  312 + ..sort((a, b) => a.endTime.compareTo(b.endTime));
  313 +
  314 + return V2HealthData(
  315 + hrvAvg: _averageOrNull(hrvPoints.map((e) => e.result).toList())?.round(),
  316 + lastRestingHrValue:
  317 + dayRestingHr.isEmpty ? null : dayRestingHr.last.value?.round(),
  318 + hrAvg: _averageOrNull(dayHr.map((e) => e.value).whereType<num>().toList())
  319 + ?.round(),
  320 + move: activitySummary.move.round(),
  321 + exercise: activitySummary.exerciseSeconds.round(),
  322 + stand: activitySummary.standHours.round(),
  323 + steps: activitySummary.steps.round(),
  324 + sleepDuration: sleepSummary.durationSeconds.round(),
  325 + sleepScore: sleepSummary.score?.toDouble(),
  326 + sleepState: sleepSummary.evaluate?.round(),
  327 + sleepTimeList: sleepTimeList,
  328 + );
  329 + }
  330 +
  331 + static V2HrvTrendData v2HrvTrendData(
  332 + List<HealthRawHrvStressPoint> hrvPoints,
  333 + ) {
  334 + return V2HrvTrendData(
  335 + list: [
  336 + for (final point in hrvPoints)
  337 + V2HrvTrendItem(
  338 + time: point.rawEndTime,
  339 + rawHrv: point.rawHrv,
  340 + trendHrv: point.result.round(),
  341 + state: point.state.value,
  342 + ),
  343 + ],
  344 + );
  345 + }
  346 +
  347 + static V2LatestHrvData v2LatestHrvData(
  348 + List<HealthRawHrvStressPoint> hrvPoints,
  349 + ) {
  350 + final latest = _latestHrvByRawEndTime(hrvPoints);
  351 + return V2LatestHrvData(
  352 + latestHrv: latest?.result,
  353 + latestDataTime: latest?.rawEndTime,
  354 + state: latest?.state.value,
  355 + );
  356 + }
  357 +
  358 + static V2RealtimeStressData v2RealtimeStressData(
  359 + List<HealthRawRealtimeStressPoint> realtimePoints,
  360 + ) {
  361 + return V2RealtimeStressData(
  362 + list: [
  363 + for (final point in realtimePoints)
  364 + V2RealtimeStressItem(
  365 + time: point.rawEndTime,
  366 + value: point.result.round(),
  367 + state: point.state.value,
  368 + ),
  369 + ],
  370 + );
  371 + }
  372 +
  373 + static V2StressScore v2StressScore(
  374 + List<HealthRawRealtimeStressPoint> realtimePoints,
  375 + ) {
  376 + final averageStress = _averageOrNull(
  377 + realtimePoints.map((e) => e.result).toList(),
  378 + );
  379 + final score = averageStress?.round();
  380 + return V2StressScore(
  381 + state: score == null ? 0 : healthRawRealtimeStressState(score).value,
  382 + comprehensiveScore: score,
  383 + );
  384 + }
  385 +
256 static _SleepDaySummary _sleepDaySummary( 386 static _SleepDaySummary _sleepDaySummary(
257 DateTime day, 387 DateTime day,
258 List<HealthKitRawDataPoint> sleepIntervals, 388 List<HealthKitRawDataPoint> sleepIntervals,
@@ -311,6 +441,23 @@ class LocalHealthDataConvert { @@ -311,6 +441,23 @@ class LocalHealthDataConvert {
311 ); 441 );
312 } 442 }
313 443
  444 + static bool _hasActivityGoal(HealthKitRawActivityDataPoint item) {
  445 + return item.activeEnergyBurnedGoal != null ||
  446 + item.appleMoveTimeGoal != null ||
  447 + item.standHoursGoal != null ||
  448 + item.exerciseTimeGoal != null;
  449 + }
  450 +
  451 + static HealthRawHrvStressPoint? _latestHrvByRawEndTime(
  452 + List<HealthRawHrvStressPoint> points,
  453 + ) {
  454 + return points.fold<HealthRawHrvStressPoint?>(
  455 + null,
  456 + (latest, item) =>
  457 + latest == null || item.rawEndTime > latest.rawEndTime ? item : latest,
  458 + );
  459 + }
  460 +
314 static _ActivityDaySummary? _latestActivitySummaryWithGoal( 461 static _ActivityDaySummary? _latestActivitySummaryWithGoal(
315 List<_ActivityDaySummary> daily, 462 List<_ActivityDaySummary> daily,
316 ) { 463 ) {
@@ -144,51 +144,159 @@ class LocalHealthDataSource implements HealthDataSource { @@ -144,51 +144,159 @@ class LocalHealthDataSource implements HealthDataSource {
144 144
145 @override 145 @override
146 Future<AppResult<V2HealthEverUploaded>> getHealthDataEverUploaded() async { 146 Future<AppResult<V2HealthEverUploaded>> getHealthDataEverUploaded() async {
147 - var data = V2HealthEverUploaded(flag: true);  
148 - return AppSuccess(data); 147 + try {
  148 + final hasHealthData = await coreService.hasHealthData();
  149 + return AppSuccess(V2HealthEverUploaded(flag: hasHealthData));
  150 + } catch (error) {
  151 + return AppFailure(AppUnknownError(error));
  152 + }
149 } 153 }
150 154
151 @override 155 @override
152 Future<AppResult<V2ActivityTarget>> getV2ActivityTarget( 156 Future<AppResult<V2ActivityTarget>> getV2ActivityTarget(
153 int? queryUserId) async { 157 int? queryUserId) async {
154 - var data = V2ActivityTarget(  
155 - sleepTargetDuration: 480,  
156 - );  
157 - return AppSuccess(data); 158 + try {
  159 + final now = DateTime.now();
  160 + final queryStart = LocalHealthDataConvert.unixSeconds(
  161 + now.subtract(
  162 + const Duration(days: HealthRawDataCoreService.defaultLookbackDays)),
  163 + );
  164 + final queryEnd = LocalHealthDataConvert.unixSeconds(now);
  165 + final activity = await coreService.queryRawActivitySummaries(
  166 + startTime: queryStart,
  167 + endTime: queryEnd,
  168 + );
  169 + return AppSuccess(
  170 + LocalHealthDataConvert.v2ActivityTarget(
  171 + userId: 0,
  172 + activity: activity,
  173 + ),
  174 + );
  175 + } catch (error) {
  176 + return AppFailure(AppUnknownError(error));
  177 + }
158 } 178 }
159 179
160 @override 180 @override
161 Future<AppResult<V2HealthData>> getV2HealthData( 181 Future<AppResult<V2HealthData>> getV2HealthData(
162 int? queryUserId, int intDate) async { 182 int? queryUserId, int intDate) async {
163 - var data = V2HealthData(hrvAvg: 10);  
164 - return AppSuccess(data); 183 + try {
  184 + final day = LocalHealthDataConvert.dateFromKey(intDate);
  185 + final dayStart = LocalHealthDataConvert.unixSeconds(day);
  186 + final dayEnd = LocalHealthDataConvert.unixSeconds(
  187 + day.add(const Duration(days: 1)),
  188 + ) -
  189 + 1;
  190 + final sleepStart = LocalHealthDataConvert.unixSeconds(
  191 + day.subtract(const Duration(hours: 12)),
  192 + );
  193 + final sleepEnd = LocalHealthDataConvert.unixSeconds(
  194 + day.add(const Duration(hours: 12)),
  195 + );
  196 + final hrvPoints = await coreService.queryHrvStressPoints(
  197 + startTime: dayStart,
  198 + endTime: dayEnd,
  199 + );
  200 + final activity = await coreService.queryRawActivitySummaries(
  201 + startTime: dayStart,
  202 + endTime: dayEnd,
  203 + );
  204 + final heartRate = await coreService.queryRawDataPoints(
  205 + dataType: HealthDataUploadType.heartRate.type,
  206 + startTime: sleepStart,
  207 + endTime: dayEnd,
  208 + );
  209 + final restingHeartRate = await coreService.queryRawDataPoints(
  210 + dataType: HealthDataUploadType.restingHeartRate.type,
  211 + startTime: dayStart,
  212 + endTime: dayEnd,
  213 + );
  214 + final sleepIntervals = await coreService.queryRawSleepIntervals(
  215 + startTime: sleepStart,
  216 + endTime: sleepEnd,
  217 + );
  218 + return AppSuccess(
  219 + LocalHealthDataConvert.v2HealthData(
  220 + day: day,
  221 + hrvPoints: hrvPoints,
  222 + activity: activity,
  223 + heartRate: heartRate,
  224 + restingHeartRate: restingHeartRate,
  225 + sleepIntervals: sleepIntervals,
  226 + ),
  227 + );
  228 + } catch (error) {
  229 + return AppFailure(AppUnknownError(error));
  230 + }
165 } 231 }
166 232
167 @override 233 @override
168 Future<AppResult<V2HrvTrendData>> getV2HrvTrend( 234 Future<AppResult<V2HrvTrendData>> getV2HrvTrend(
169 int? queryUserId, int intDate) async { 235 int? queryUserId, int intDate) async {
170 - var data = V2HrvTrendData();  
171 - return AppSuccess(data); 236 + try {
  237 + final (startTime, endTime) = _dayRange(intDate);
  238 + final hrvPoints = await coreService.queryHrvStressPoints(
  239 + startTime: startTime,
  240 + endTime: endTime,
  241 + );
  242 + return AppSuccess(LocalHealthDataConvert.v2HrvTrendData(hrvPoints));
  243 + } catch (error) {
  244 + return AppFailure(AppUnknownError(error));
  245 + }
172 } 246 }
173 247
174 @override 248 @override
175 Future<AppResult<V2LatestHrvData>> getV2LatestHrv( 249 Future<AppResult<V2LatestHrvData>> getV2LatestHrv(
176 int? queryUserId, int intDate) async { 250 int? queryUserId, int intDate) async {
177 - var data = V2LatestHrvData();  
178 - return AppSuccess(data); 251 + try {
  252 + final (startTime, endTime) = _dayRange(intDate);
  253 + final hrvPoints = await coreService.queryHrvStressPoints(
  254 + startTime: startTime,
  255 + endTime: endTime,
  256 + );
  257 + return AppSuccess(LocalHealthDataConvert.v2LatestHrvData(hrvPoints));
  258 + } catch (error) {
  259 + return AppFailure(AppUnknownError(error));
  260 + }
179 } 261 }
180 262
181 @override 263 @override
182 Future<AppResult<V2RealtimeStressData>> getV2RealtimeStress( 264 Future<AppResult<V2RealtimeStressData>> getV2RealtimeStress(
183 int? queryUserId, int intDate) async { 265 int? queryUserId, int intDate) async {
184 - var data = V2RealtimeStressData();  
185 - return AppSuccess(data); 266 + try {
  267 + final (startTime, endTime) = _dayRange(intDate);
  268 + final realtimePoints = await coreService.queryRealtimeStressPoints(
  269 + startTime: startTime,
  270 + endTime: endTime,
  271 + );
  272 + return AppSuccess(
  273 + LocalHealthDataConvert.v2RealtimeStressData(realtimePoints),
  274 + );
  275 + } catch (error) {
  276 + return AppFailure(AppUnknownError(error));
  277 + }
186 } 278 }
187 279
188 @override 280 @override
189 Future<AppResult<V2StressScore>> getV2StressScore( 281 Future<AppResult<V2StressScore>> getV2StressScore(
190 int? queryUserId, int intDate) async { 282 int? queryUserId, int intDate) async {
191 - var data = V2StressScore(state: 1, comprehensiveScore: 18);  
192 - return AppSuccess(data); 283 + try {
  284 + final (startTime, endTime) = _dayRange(intDate);
  285 + final realtimePoints = await coreService.queryRealtimeStressPoints(
  286 + startTime: startTime,
  287 + endTime: endTime,
  288 + );
  289 + return AppSuccess(LocalHealthDataConvert.v2StressScore(realtimePoints));
  290 + } catch (error) {
  291 + return AppFailure(AppUnknownError(error));
  292 + }
  293 + }
  294 +
  295 + (int startTime, int endTime) _dayRange(int intDate) {
  296 + final day = LocalHealthDataConvert.dateFromKey(intDate);
  297 + return (
  298 + LocalHealthDataConvert.unixSeconds(day),
  299 + LocalHealthDataConvert.unixSeconds(day.add(const Duration(days: 1))) - 1,
  300 + );
193 } 301 }
194 } 302 }
@@ -380,6 +380,35 @@ class HealthKitRawDataHostApi { @@ -380,6 +380,35 @@ class HealthKitRawDataHostApi {
380 380
381 final String pigeonVar_messageChannelSuffix; 381 final String pigeonVar_messageChannelSuffix;
382 382
  383 + /// 是否有AppleHealth数据
  384 + Future<bool> hasHealthData() async {
  385 + final String pigeonVar_channelName = 'dev.flutter.pigeon.doublefeel_flutter.HealthKitRawDataHostApi.hasHealthData$pigeonVar_messageChannelSuffix';
  386 + final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
  387 + pigeonVar_channelName,
  388 + pigeonChannelCodec,
  389 + binaryMessenger: pigeonVar_binaryMessenger,
  390 + );
  391 + final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
  392 + final List<Object?>? pigeonVar_replyList =
  393 + await pigeonVar_sendFuture as List<Object?>?;
  394 + if (pigeonVar_replyList == null) {
  395 + throw _createConnectionError(pigeonVar_channelName);
  396 + } else if (pigeonVar_replyList.length > 1) {
  397 + throw PlatformException(
  398 + code: pigeonVar_replyList[0]! as String,
  399 + message: pigeonVar_replyList[1] as String?,
  400 + details: pigeonVar_replyList[2],
  401 + );
  402 + } else if (pigeonVar_replyList[0] == null) {
  403 + throw PlatformException(
  404 + code: 'null-error',
  405 + message: 'Host platform returned null value for non-null return value.',
  406 + );
  407 + } else {
  408 + return (pigeonVar_replyList[0] as bool?)!;
  409 + }
  410 + }
  411 +
383 /// 从AppleHealth 中读取原始数据 412 /// 从AppleHealth 中读取原始数据
384 Future<List<HealthKitRawDataPoint>> getHealthKitRawData(int dataType, int startTime, int endTime) async { 413 Future<List<HealthKitRawDataPoint>> getHealthKitRawData(int dataType, int startTime, int endTime) async {
385 final String pigeonVar_channelName = 'dev.flutter.pigeon.doublefeel_flutter.HealthKitRawDataHostApi.getHealthKitRawData$pigeonVar_messageChannelSuffix'; 414 final String pigeonVar_channelName = 'dev.flutter.pigeon.doublefeel_flutter.HealthKitRawDataHostApi.getHealthKitRawData$pigeonVar_messageChannelSuffix';
@@ -89,6 +89,10 @@ class HealthKitRawActivityDataPoint { @@ -89,6 +89,10 @@ class HealthKitRawActivityDataPoint {
89 ) 89 )
90 @HostApi() 90 @HostApi()
91 abstract class HealthKitRawDataHostApi { 91 abstract class HealthKitRawDataHostApi {
  92 + /// 是否有AppleHealth数据
  93 + @async
  94 + bool hasHealthData();
  95 +
92 /// 从AppleHealth 中读取原始数据 96 /// 从AppleHealth 中读取原始数据
93 @async 97 @async
94 List<HealthKitRawDataPoint> getHealthKitRawData( 98 List<HealthKitRawDataPoint> getHealthKitRawData(
@@ -283,6 +283,7 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { @@ -283,6 +283,7 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
283 hrvByTime[point.rawEndTime] = HealthRawHrvStressPoint( 283 hrvByTime[point.rawEndTime] = HealthRawHrvStressPoint(
284 userId: point.userId, 284 userId: point.userId,
285 rawEndTime: point.rawEndTime, 285 rawEndTime: point.rawEndTime,
  286 + rawHrv: point.rawHrv,
286 result: point.result, 287 result: point.result,
287 sourceStartTime: point.sourceStartTime, 288 sourceStartTime: point.sourceStartTime,
288 sourceEndTime: point.sourceEndTime, 289 sourceEndTime: point.sourceEndTime,
@@ -374,6 +375,7 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { @@ -374,6 +375,7 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
374 return HealthRawHrvStressPoint( 375 return HealthRawHrvStressPoint(
375 userId: point.userId, 376 userId: point.userId,
376 rawEndTime: point.rawEndTime, 377 rawEndTime: point.rawEndTime,
  378 + rawHrv: point.rawHrv,
377 result: point.result, 379 result: point.result,
378 sourceStartTime: point.sourceStartTime, 380 sourceStartTime: point.sourceStartTime,
379 sourceEndTime: point.sourceEndTime, 381 sourceEndTime: point.sourceEndTime,
@@ -413,9 +415,11 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { @@ -413,9 +415,11 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
413 return <String, Object?>{ 415 return <String, Object?>{
414 'raw_end_time': point.rawEndTime, 416 'raw_end_time': point.rawEndTime,
415 'user_id': point.userId, 417 'user_id': point.userId,
  418 + 'raw_hrv': point.rawHrv,
416 'result': point.result, 419 'result': point.result,
417 'source_start_time': point.sourceStartTime, 420 'source_start_time': point.sourceStartTime,
418 'source_end_time': point.sourceEndTime, 421 'source_end_time': point.sourceEndTime,
  422 + 'state': point.state.value,
419 'baseline_hrv': point.baselineHrv, 423 'baseline_hrv': point.baselineHrv,
420 'baseline_awake_hrv': point.baselineAwakeHrv, 424 'baseline_awake_hrv': point.baselineAwakeHrv,
421 'baseline_sleep_hrv': point.baselineSleepHrv, 425 'baseline_sleep_hrv': point.baselineSleepHrv,