Showing
7 changed files
with
908 additions
and
20 deletions
| 1 | +import Foundation | ||
| 2 | +import SQLite3 | ||
| 3 | + | ||
| 4 | +enum HealthRawStressUploadKind { | ||
| 5 | + case hrv | ||
| 6 | + case realtimeStress | ||
| 7 | + case dailyStress | ||
| 8 | +} | ||
| 9 | + | ||
| 10 | +enum HealthRawStressSQLiteUploadError: LocalizedError { | ||
| 11 | + case invalidDatabasePath | ||
| 12 | + case openDatabaseFailed(String) | ||
| 13 | + case prepareFailed(String) | ||
| 14 | + case invalidServerURL | ||
| 15 | + case missingAccessToken | ||
| 16 | + case invalidResponse | ||
| 17 | + case requestFailed(path: String, statusCode: Int, body: String?) | ||
| 18 | + | ||
| 19 | + var errorDescription: String? { | ||
| 20 | + switch self { | ||
| 21 | + case .invalidDatabasePath: | ||
| 22 | + return "Invalid HealthRawData sqlite file path." | ||
| 23 | + case .openDatabaseFailed(let message): | ||
| 24 | + return "Open HealthRawData sqlite failed: \(message)" | ||
| 25 | + case .prepareFailed(let message): | ||
| 26 | + return "Prepare HealthRawData sqlite query failed: \(message)" | ||
| 27 | + case .invalidServerURL: | ||
| 28 | + return "Invalid server URL." | ||
| 29 | + case .missingAccessToken: | ||
| 30 | + return "Missing access token." | ||
| 31 | + case .invalidResponse: | ||
| 32 | + return "Invalid upload response." | ||
| 33 | + case .requestFailed(let path, let statusCode, let body): | ||
| 34 | + return "Upload \(path) failed status=\(statusCode) body=\(body ?? "nil")" | ||
| 35 | + } | ||
| 36 | + } | ||
| 37 | +} | ||
| 38 | + | ||
| 39 | +final class HealthRawStressSQLiteUploader { | ||
| 40 | + static let shared = HealthRawStressSQLiteUploader() | ||
| 41 | + | ||
| 42 | + private let session: URLSession | ||
| 43 | + private let batchSize = 200 | ||
| 44 | + | ||
| 45 | + init(session: URLSession = .shared) { | ||
| 46 | + self.session = session | ||
| 47 | + } | ||
| 48 | + | ||
| 49 | + func uploadHrv(sqliteFilePath: String) async throws -> Int64 { | ||
| 50 | + let rows = try queryRows( | ||
| 51 | + sqliteFilePath: sqliteFilePath, | ||
| 52 | + sql: """ | ||
| 53 | + SELECT raw_end_time, raw_hrv, result, state, baseline_resting_hr, baseline_hrv, | ||
| 54 | + is_sleep_likely, is_workout, is_workout_recovery, is_suspected_activity | ||
| 55 | + FROM hrv_results | ||
| 56 | + WHERE uploaded != 1 | ||
| 57 | + ORDER BY raw_end_time ASC | ||
| 58 | + """ | ||
| 59 | + ) | ||
| 60 | + var uploadedUntil: Int64 = 0 | ||
| 61 | + for batch in batches(rows) { | ||
| 62 | + let list = batch.map { row in | ||
| 63 | + [ | ||
| 64 | + "data_time": row.int64("raw_end_time"), | ||
| 65 | + "raw_hrv": row.double("raw_hrv"), | ||
| 66 | + "trend_hrv": row.double("result"), | ||
| 67 | + "state": row.int("state"), | ||
| 68 | + "hr_baseline": row.double("baseline_resting_hr"), | ||
| 69 | + "hrv_baseline": row.double("baseline_hrv"), | ||
| 70 | + "is_asleep": row.int("is_sleep_likely"), | ||
| 71 | + "is_workout": row.int("is_workout"), | ||
| 72 | + "is_workout_recovery": row.int("is_workout_recovery"), | ||
| 73 | + "is_suspected_activity": row.int("is_suspected_activity"), | ||
| 74 | + ] as [String: Any] | ||
| 75 | + } | ||
| 76 | + try await upload( | ||
| 77 | + path: "/client/doublefeel/health/v2/hrv_trend/", | ||
| 78 | + body: ["data_list": list] | ||
| 79 | + ) | ||
| 80 | + uploadedUntil = batch.last?.int64("raw_end_time") ?? uploadedUntil | ||
| 81 | + } | ||
| 82 | + return uploadedUntil | ||
| 83 | + } | ||
| 84 | + | ||
| 85 | + func uploadRealtimeStress(sqliteFilePath: String) async throws -> Int64 { | ||
| 86 | + let rows = try queryRows( | ||
| 87 | + sqliteFilePath: sqliteFilePath, | ||
| 88 | + sql: """ | ||
| 89 | + SELECT raw_end_time, raw_hr, result, is_sleep_likely, is_workout, | ||
| 90 | + is_workout_recovery, is_suspected_activity | ||
| 91 | + FROM realtime_stress_results | ||
| 92 | + WHERE uploaded != 1 | ||
| 93 | + ORDER BY raw_end_time ASC | ||
| 94 | + """ | ||
| 95 | + ) | ||
| 96 | + var uploadedUntil: Int64 = 0 | ||
| 97 | + for batch in batches(rows) { | ||
| 98 | + let list = batch.map { row in | ||
| 99 | + let stressValue = row.double("result") | ||
| 100 | + return [ | ||
| 101 | + "data_time": row.int64("raw_end_time"), | ||
| 102 | + "hr_value": row.double("raw_hr"), | ||
| 103 | + "stress_value": stressValue, | ||
| 104 | + "state": stressState(stressValue), | ||
| 105 | + "is_asleep": row.int("is_sleep_likely"), | ||
| 106 | + "is_workout": row.int("is_workout"), | ||
| 107 | + "is_workout_recovery": row.int("is_workout_recovery"), | ||
| 108 | + "is_suspected_activity": row.int("is_suspected_activity"), | ||
| 109 | + ] as [String: Any] | ||
| 110 | + } | ||
| 111 | + try await upload( | ||
| 112 | + path: "/client/doublefeel/health/v2/realtime_stress/", | ||
| 113 | + body: ["data_list": list] | ||
| 114 | + ) | ||
| 115 | + uploadedUntil = batch.last?.int64("raw_end_time") ?? uploadedUntil | ||
| 116 | + } | ||
| 117 | + return uploadedUntil | ||
| 118 | + } | ||
| 119 | + | ||
| 120 | + func uploadDailyStress(sqliteFilePath: String) async throws -> Bool { | ||
| 121 | + let rows = try queryRows( | ||
| 122 | + sqliteFilePath: sqliteFilePath, | ||
| 123 | + sql: """ | ||
| 124 | + SELECT stress_value, stress_score, state, data_time | ||
| 125 | + FROM daily_stress_results | ||
| 126 | + WHERE uploaded != 1 | ||
| 127 | + ORDER BY date ASC | ||
| 128 | + """ | ||
| 129 | + ) | ||
| 130 | + for batch in batches(rows) { | ||
| 131 | + let list = batch.map { row in | ||
| 132 | + [ | ||
| 133 | + "stress_value": row.double("stress_value"), | ||
| 134 | + "stress_score": row.int("stress_score"), | ||
| 135 | + "state": row.int("state"), | ||
| 136 | + "data_time": row.int64("data_time"), | ||
| 137 | + ] as [String: Any] | ||
| 138 | + } | ||
| 139 | + try await upload( | ||
| 140 | + path: "/client/doublefeel/health/v2/stress_score/", | ||
| 141 | + body: ["data_list": list] | ||
| 142 | + ) | ||
| 143 | + } | ||
| 144 | + return true | ||
| 145 | + } | ||
| 146 | + | ||
| 147 | + private func queryRows(sqliteFilePath: String, sql: String) throws -> [SQLiteRow] { | ||
| 148 | + guard FileManager.default.fileExists(atPath: sqliteFilePath) else { | ||
| 149 | + throw HealthRawStressSQLiteUploadError.invalidDatabasePath | ||
| 150 | + } | ||
| 151 | + | ||
| 152 | + var db: OpaquePointer? | ||
| 153 | + let flags = SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX | ||
| 154 | + guard sqlite3_open_v2(sqliteFilePath, &db, flags, nil) == SQLITE_OK, | ||
| 155 | + let db else { | ||
| 156 | + let message = db.map { String(cString: sqlite3_errmsg($0)) } ?? "unknown" | ||
| 157 | + if let db { sqlite3_close(db) } | ||
| 158 | + throw HealthRawStressSQLiteUploadError.openDatabaseFailed(message) | ||
| 159 | + } | ||
| 160 | + defer { sqlite3_close(db) } | ||
| 161 | + | ||
| 162 | + var statement: OpaquePointer? | ||
| 163 | + guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK, | ||
| 164 | + let statement else { | ||
| 165 | + throw HealthRawStressSQLiteUploadError.prepareFailed( | ||
| 166 | + String(cString: sqlite3_errmsg(db)) | ||
| 167 | + ) | ||
| 168 | + } | ||
| 169 | + defer { sqlite3_finalize(statement) } | ||
| 170 | + | ||
| 171 | + var rows: [SQLiteRow] = [] | ||
| 172 | + while sqlite3_step(statement) == SQLITE_ROW { | ||
| 173 | + var values: [String: SQLiteValue] = [:] | ||
| 174 | + for index in 0..<sqlite3_column_count(statement) { | ||
| 175 | + let name = String(cString: sqlite3_column_name(statement, index)) | ||
| 176 | + values[name] = SQLiteValue(statement: statement, index: index) | ||
| 177 | + } | ||
| 178 | + rows.append(SQLiteRow(values: values)) | ||
| 179 | + } | ||
| 180 | + return rows | ||
| 181 | + } | ||
| 182 | + | ||
| 183 | + private func batches(_ values: [SQLiteRow]) -> [[SQLiteRow]] { | ||
| 184 | + guard batchSize > 0 else { return [values] } | ||
| 185 | + return stride(from: 0, to: values.count, by: batchSize).map { | ||
| 186 | + Array(values[$0..<Swift.min($0 + batchSize, values.count)]) | ||
| 187 | + } | ||
| 188 | + } | ||
| 189 | + | ||
| 190 | + private func upload(path: String, body: [String: Any]) async throws { | ||
| 191 | + guard let baseURL = URL(string: AppShared.shared.baseUrl), | ||
| 192 | + let url = URL(string: path, relativeTo: baseURL)?.absoluteURL else { | ||
| 193 | + throw HealthRawStressSQLiteUploadError.invalidServerURL | ||
| 194 | + } | ||
| 195 | + guard let accessToken = AppShared.shared.token, !accessToken.isEmpty else { | ||
| 196 | + throw HealthRawStressSQLiteUploadError.missingAccessToken | ||
| 197 | + } | ||
| 198 | + | ||
| 199 | + var request = URLRequest(url: url) | ||
| 200 | + request.httpMethod = "POST" | ||
| 201 | + request.timeoutInterval = 60 | ||
| 202 | + request.setValue("application/json", forHTTPHeaderField: "Accept") | ||
| 203 | + request.setValue("application/json", forHTTPHeaderField: "Content-Type") | ||
| 204 | + request.setValue(accessToken, forHTTPHeaderField: "access_token") | ||
| 205 | + request.setValue(AppShared.shared.agent.finalUA, forHTTPHeaderField: "User-Agent") | ||
| 206 | + request.httpBody = try JSONSerialization.data(withJSONObject: body) | ||
| 207 | + | ||
| 208 | + let (data, response) = try await session.data(for: request) | ||
| 209 | + guard let httpResponse = response as? HTTPURLResponse else { | ||
| 210 | + throw HealthRawStressSQLiteUploadError.invalidResponse | ||
| 211 | + } | ||
| 212 | + guard (200..<300).contains(httpResponse.statusCode) else { | ||
| 213 | + if httpResponse.statusCode == 401 { | ||
| 214 | + await MainActor.run { AppShared.shared.logout() } | ||
| 215 | + } | ||
| 216 | + throw HealthRawStressSQLiteUploadError.requestFailed( | ||
| 217 | + path: path, | ||
| 218 | + statusCode: httpResponse.statusCode, | ||
| 219 | + body: String(data: data, encoding: .utf8) | ||
| 220 | + ) | ||
| 221 | + } | ||
| 222 | + } | ||
| 223 | + | ||
| 224 | + private func stressState(_ value: Double) -> Int { | ||
| 225 | + if value >= 81 { return 1 } | ||
| 226 | + if value >= 61 { return 2 } | ||
| 227 | + if value >= 21 { return 3 } | ||
| 228 | + return 4 | ||
| 229 | + } | ||
| 230 | +} | ||
| 231 | + | ||
| 232 | +private enum SQLiteValue { | ||
| 233 | + case integer(Int64) | ||
| 234 | + case double(Double) | ||
| 235 | + case text(String) | ||
| 236 | + case null | ||
| 237 | + | ||
| 238 | + init(statement: OpaquePointer, index: Int32) { | ||
| 239 | + switch sqlite3_column_type(statement, index) { | ||
| 240 | + case SQLITE_INTEGER: | ||
| 241 | + self = .integer(sqlite3_column_int64(statement, index)) | ||
| 242 | + case SQLITE_FLOAT: | ||
| 243 | + self = .double(sqlite3_column_double(statement, index)) | ||
| 244 | + case SQLITE_TEXT: | ||
| 245 | + self = .text(String(cString: sqlite3_column_text(statement, index))) | ||
| 246 | + default: | ||
| 247 | + self = .null | ||
| 248 | + } | ||
| 249 | + } | ||
| 250 | +} | ||
| 251 | + | ||
| 252 | +private struct SQLiteRow { | ||
| 253 | + let values: [String: SQLiteValue] | ||
| 254 | + | ||
| 255 | + func int64(_ key: String) -> Int64 { | ||
| 256 | + switch values[key] { | ||
| 257 | + case .integer(let value): return value | ||
| 258 | + case .double(let value): return Int64(value) | ||
| 259 | + case .text(let value): return Int64(value) ?? 0 | ||
| 260 | + case .null, .none: return 0 | ||
| 261 | + } | ||
| 262 | + } | ||
| 263 | + | ||
| 264 | + func int(_ key: String) -> Int { | ||
| 265 | + Int(int64(key)) | ||
| 266 | + } | ||
| 267 | + | ||
| 268 | + func double(_ key: String) -> Double { | ||
| 269 | + switch values[key] { | ||
| 270 | + case .integer(let value): return Double(value) | ||
| 271 | + case .double(let value): return value | ||
| 272 | + case .text(let value): return Double(value) ?? 0 | ||
| 273 | + case .null, .none: return 0 | ||
| 274 | + } | ||
| 275 | + } | ||
| 276 | +} |
| @@ -15,6 +15,7 @@ final class HealthKitRawDataHostApiImpl: HealthKitRawDataHostApi { | @@ -15,6 +15,7 @@ final class HealthKitRawDataHostApiImpl: HealthKitRawDataHostApi { | ||
| 15 | } | 15 | } |
| 16 | 16 | ||
| 17 | func performHealthDataUpload(completion: @escaping (Result<Bool, any Error>) -> Void) { | 17 | func performHealthDataUpload(completion: @escaping (Result<Bool, any Error>) -> Void) { |
| 18 | + print("trigger HealthDataUpload") | ||
| 18 | Task { | 19 | Task { |
| 19 | let summary = await AnchoredHealthDataUploader.shared.uploadAll() | 20 | let summary = await AnchoredHealthDataUploader.shared.uploadAll() |
| 20 | completion(.success(true)) | 21 | completion(.success(true)) |
| @@ -22,15 +23,39 @@ final class HealthKitRawDataHostApiImpl: HealthKitRawDataHostApi { | @@ -22,15 +23,39 @@ final class HealthKitRawDataHostApiImpl: HealthKitRawDataHostApi { | ||
| 22 | } | 23 | } |
| 23 | 24 | ||
| 24 | func performHRDataUpload(sqliteFilePath: String, completion: @escaping (Result<Int64, any Error>) -> Void) { | 25 | func performHRDataUpload(sqliteFilePath: String, completion: @escaping (Result<Int64, any Error>) -> Void) { |
| 25 | - | 26 | + print("trigger HRDataUpload") |
| 27 | + Task { | ||
| 28 | + do { | ||
| 29 | + let uploadedUntil = try await HealthRawStressSQLiteUploader.shared.uploadRealtimeStress(sqliteFilePath: sqliteFilePath) | ||
| 30 | + completion(.success(uploadedUntil)) | ||
| 31 | + } catch { | ||
| 32 | + completion(.failure(error)) | ||
| 33 | + } | ||
| 34 | + } | ||
| 26 | } | 35 | } |
| 27 | 36 | ||
| 28 | func performHRVDataUpload(sqliteFilePath: String, completion: @escaping (Result<Int64, any Error>) -> Void) { | 37 | func performHRVDataUpload(sqliteFilePath: String, completion: @escaping (Result<Int64, any Error>) -> Void) { |
| 29 | - | 38 | + print("trigger HRVDataUpload") |
| 39 | + Task { | ||
| 40 | + do { | ||
| 41 | + let uploadedUntil = try await HealthRawStressSQLiteUploader.shared.uploadHrv(sqliteFilePath: sqliteFilePath) | ||
| 42 | + completion(.success(uploadedUntil)) | ||
| 43 | + } catch { | ||
| 44 | + completion(.failure(error)) | ||
| 45 | + } | ||
| 46 | + } | ||
| 30 | } | 47 | } |
| 31 | 48 | ||
| 32 | func performAvgRealtimeStressDataUpload(sqliteFilePath: String, completion: @escaping (Result<Bool, any Error>) -> Void) { | 49 | func performAvgRealtimeStressDataUpload(sqliteFilePath: String, completion: @escaping (Result<Bool, any Error>) -> Void) { |
| 33 | - | 50 | + print("trigger AvgRealtimeStressDataUpload") |
| 51 | + Task { | ||
| 52 | + do { | ||
| 53 | + let success = try await HealthRawStressSQLiteUploader.shared.uploadDailyStress(sqliteFilePath: sqliteFilePath) | ||
| 54 | + completion(.success(success)) | ||
| 55 | + } catch { | ||
| 56 | + completion(.failure(error)) | ||
| 57 | + } | ||
| 58 | + } | ||
| 34 | } | 59 | } |
| 35 | 60 | ||
| 36 | 61 | ||
| @@ -81,7 +106,7 @@ final class HealthKitRawDataHostApiImpl: HealthKitRawDataHostApi { | @@ -81,7 +106,7 @@ final class HealthKitRawDataHostApiImpl: HealthKitRawDataHostApi { | ||
| 81 | ) { | 106 | ) { |
| 82 | let startDate = Date(timeIntervalSince1970: TimeInterval(startTime)) | 107 | let startDate = Date(timeIntervalSince1970: TimeInterval(startTime)) |
| 83 | let endDate = Date(timeIntervalSince1970: TimeInterval(endTime)) | 108 | let endDate = Date(timeIntervalSince1970: TimeInterval(endTime)) |
| 84 | - | 109 | + print("trigger getHealthKitRawSleepData from: \(startDate) to \(endDate)") |
| 85 | Task { | 110 | Task { |
| 86 | do { | 111 | do { |
| 87 | let intervals = try await service.fetchSleepData( | 112 | let intervals = try await service.fetchSleepData( |
| @@ -93,6 +118,7 @@ final class HealthKitRawDataHostApiImpl: HealthKitRawDataHostApi { | @@ -93,6 +118,7 @@ final class HealthKitRawDataHostApiImpl: HealthKitRawDataHostApi { | ||
| 93 | completion(.success([])) | 118 | completion(.success([])) |
| 94 | return | 119 | return |
| 95 | } | 120 | } |
| 121 | + print("trigger getHealthKitRawSleepData back: \(points)") | ||
| 96 | completion(.success([ | 122 | completion(.success([ |
| 97 | HealthKitRawSleepDataPoint( | 123 | HealthKitRawSleepDataPoint( |
| 98 | dataType: Int64(NativeHealthDataType.sleep.rawValue), | 124 | dataType: Int64(NativeHealthDataType.sleep.rawValue), |
| @@ -25,22 +25,29 @@ class HealthRawDataCoreService { | @@ -25,22 +25,29 @@ class HealthRawDataCoreService { | ||
| 25 | HealthRawStressLocalStore? localStore, | 25 | HealthRawStressLocalStore? localStore, |
| 26 | AppEnvironmentConfig? environmentConfig, | 26 | AppEnvironmentConfig? environmentConfig, |
| 27 | int Function()? userIdProvider, | 27 | int Function()? userIdProvider, |
| 28 | + bool uploadResultsAfterCalculation = true, | ||
| 28 | }) : _healthApi = healthApi ?? HealthKitHostApi(), | 29 | }) : _healthApi = healthApi ?? HealthKitHostApi(), |
| 29 | _rawDataApi = rawDataApi ?? HealthKitRawDataHostApi(), | 30 | _rawDataApi = rawDataApi ?? HealthKitRawDataHostApi(), |
| 30 | _localStore = localStore ?? HealthRawStressLocalStore(), | 31 | _localStore = localStore ?? HealthRawStressLocalStore(), |
| 31 | _environmentConfig = environmentConfig, | 32 | _environmentConfig = environmentConfig, |
| 32 | - _userIdProvider = userIdProvider; | 33 | + _userIdProvider = userIdProvider, |
| 34 | + _uploadResultsAfterCalculation = uploadResultsAfterCalculation; | ||
| 33 | 35 | ||
| 34 | final HealthKitHostApi _healthApi; | 36 | final HealthKitHostApi _healthApi; |
| 35 | final HealthKitRawDataHostApi _rawDataApi; | 37 | final HealthKitRawDataHostApi _rawDataApi; |
| 36 | final HealthRawStressLocalStore _localStore; | 38 | final HealthRawStressLocalStore _localStore; |
| 37 | final AppEnvironmentConfig? _environmentConfig; | 39 | final AppEnvironmentConfig? _environmentConfig; |
| 38 | final int Function()? _userIdProvider; | 40 | final int Function()? _userIdProvider; |
| 41 | + final bool _uploadResultsAfterCalculation; | ||
| 39 | final StreamController<HealthRawDataUpdatedEvent> | 42 | final StreamController<HealthRawDataUpdatedEvent> |
| 40 | _healthDataUpdatedController = | 43 | _healthDataUpdatedController = |
| 41 | StreamController<HealthRawDataUpdatedEvent>.broadcast(); | 44 | StreamController<HealthRawDataUpdatedEvent>.broadcast(); |
| 42 | Future<void>? _healthDataUpdatedCalculation; | 45 | Future<void>? _healthDataUpdatedCalculation; |
| 46 | + Future<HealthRawStressCalculationResult>? _coreCalculation; | ||
| 43 | List<int> _pendingHealthDataUpdatedTypes = const <int>[]; | 47 | List<int> _pendingHealthDataUpdatedTypes = const <int>[]; |
| 48 | + bool _isUploadingHrvResults = false; | ||
| 49 | + bool _isUploadingRealtimeStressResults = false; | ||
| 50 | + bool _isUploadingDailyStressResults = false; | ||
| 44 | 51 | ||
| 45 | int get _userId { | 52 | int get _userId { |
| 46 | final userId = _userIdProvider?.call() ?? 0; | 53 | final userId = _userIdProvider?.call() ?? 0; |
| @@ -127,7 +134,11 @@ class HealthRawDataCoreService { | @@ -127,7 +134,11 @@ class HealthRawDataCoreService { | ||
| 127 | int? endTime, | 134 | int? endTime, |
| 128 | int readChunkDays = defaultReadChunkDays, | 135 | int readChunkDays = defaultReadChunkDays, |
| 129 | }) async { | 136 | }) async { |
| 130 | - return _runWithLog( | 137 | + final running = _coreCalculation; |
| 138 | + if (running != null) { | ||
| 139 | + return running; | ||
| 140 | + } | ||
| 141 | + final task = _runWithLog( | ||
| 131 | action: 'startCoreCaculate', | 142 | action: 'startCoreCaculate', |
| 132 | body: () => _readCalculateAndStore( | 143 | body: () => _readCalculateAndStore( |
| 133 | endTime: endTime, | 144 | endTime: endTime, |
| @@ -135,6 +146,14 @@ class HealthRawDataCoreService { | @@ -135,6 +146,14 @@ class HealthRawDataCoreService { | ||
| 135 | forceStartTime: null, | 146 | forceStartTime: null, |
| 136 | ), | 147 | ), |
| 137 | ); | 148 | ); |
| 149 | + _coreCalculation = task; | ||
| 150 | + try { | ||
| 151 | + return await task; | ||
| 152 | + } finally { | ||
| 153 | + if (identical(_coreCalculation, task)) { | ||
| 154 | + _coreCalculation = null; | ||
| 155 | + } | ||
| 156 | + } | ||
| 138 | } | 157 | } |
| 139 | 158 | ||
| 140 | Future<HealthRawStressCalculationResult> syncAndStore({ | 159 | Future<HealthRawStressCalculationResult> syncAndStore({ |
| @@ -258,11 +277,18 @@ class HealthRawDataCoreService { | @@ -258,11 +277,18 @@ class HealthRawDataCoreService { | ||
| 258 | endTime: effectiveEndTime, | 277 | endTime: effectiveEndTime, |
| 259 | ); | 278 | ); |
| 260 | await _localStore.upsertResult(result); | 279 | await _localStore.upsertResult(result); |
| 280 | + if (_uploadResultsAfterCalculation) { | ||
| 281 | + await _uploadHrvResults(); | ||
| 282 | + await _uploadRealtimeStressResults(); | ||
| 283 | + } | ||
| 261 | final dailyStressPoints = await _calculateAndStoreDailyStressPoints( | 284 | final dailyStressPoints = await _calculateAndStoreDailyStressPoints( |
| 262 | userId: userId, | 285 | userId: userId, |
| 263 | realtimePoints: result.realtimeStressPoints, | 286 | realtimePoints: result.realtimeStressPoints, |
| 264 | nowSeconds: effectiveEndTime, | 287 | nowSeconds: effectiveEndTime, |
| 265 | ); | 288 | ); |
| 289 | + if (_uploadResultsAfterCalculation) { | ||
| 290 | + await _uploadDailyStressResults(); | ||
| 291 | + } | ||
| 266 | final storedResult = result.copyWith(dailyStressPoints: dailyStressPoints); | 292 | final storedResult = result.copyWith(dailyStressPoints: dailyStressPoints); |
| 267 | if (isFirstCalculation && (_environmentConfig?.isDebug ?? false)) { | 293 | if (isFirstCalculation && (_environmentConfig?.isDebug ?? false)) { |
| 268 | AppToast.show('首次计算完成'); | 294 | AppToast.show('首次计算完成'); |
| @@ -450,6 +476,78 @@ class HealthRawDataCoreService { | @@ -450,6 +476,78 @@ class HealthRawDataCoreService { | ||
| 450 | ); | 476 | ); |
| 451 | } | 477 | } |
| 452 | 478 | ||
| 479 | + Future<void> _uploadHrvResults() async { | ||
| 480 | + if (_isUploadingHrvResults) { | ||
| 481 | + return; | ||
| 482 | + } | ||
| 483 | + if (!await _localStore.hasPendingHrvStressUploads(userId: _userId)) { | ||
| 484 | + return; | ||
| 485 | + } | ||
| 486 | + _isUploadingHrvResults = true; | ||
| 487 | + try { | ||
| 488 | + final uploadedUntil = await _rawDataApi.performHRVDataUpload( | ||
| 489 | + sqliteFilePath: await _localStore.dbPath(_userId), | ||
| 490 | + ); | ||
| 491 | + if (uploadedUntil > 0) { | ||
| 492 | + await _localStore.markHrvStressUploadedUntil( | ||
| 493 | + userId: _userId, | ||
| 494 | + rawEndTime: uploadedUntil, | ||
| 495 | + ); | ||
| 496 | + } | ||
| 497 | + } catch (error, stackTrace) { | ||
| 498 | + _logError('upload hrv results failed', error, stackTrace); | ||
| 499 | + } finally { | ||
| 500 | + _isUploadingHrvResults = false; | ||
| 501 | + } | ||
| 502 | + } | ||
| 503 | + | ||
| 504 | + Future<void> _uploadRealtimeStressResults() async { | ||
| 505 | + if (_isUploadingRealtimeStressResults) { | ||
| 506 | + return; | ||
| 507 | + } | ||
| 508 | + if (!await _localStore.hasPendingRealtimeStressUploads(userId: _userId)) { | ||
| 509 | + return; | ||
| 510 | + } | ||
| 511 | + _isUploadingRealtimeStressResults = true; | ||
| 512 | + try { | ||
| 513 | + final uploadedUntil = await _rawDataApi.performHRDataUpload( | ||
| 514 | + sqliteFilePath: await _localStore.dbPath(_userId), | ||
| 515 | + ); | ||
| 516 | + if (uploadedUntil > 0) { | ||
| 517 | + await _localStore.markRealtimeStressUploadedUntil( | ||
| 518 | + userId: _userId, | ||
| 519 | + rawEndTime: uploadedUntil, | ||
| 520 | + ); | ||
| 521 | + } | ||
| 522 | + } catch (error, stackTrace) { | ||
| 523 | + _logError('upload realtime stress results failed', error, stackTrace); | ||
| 524 | + } finally { | ||
| 525 | + _isUploadingRealtimeStressResults = false; | ||
| 526 | + } | ||
| 527 | + } | ||
| 528 | + | ||
| 529 | + Future<void> _uploadDailyStressResults() async { | ||
| 530 | + if (_isUploadingDailyStressResults) { | ||
| 531 | + return; | ||
| 532 | + } | ||
| 533 | + if (!await _localStore.hasPendingDailyStressUploads(userId: _userId)) { | ||
| 534 | + return; | ||
| 535 | + } | ||
| 536 | + _isUploadingDailyStressResults = true; | ||
| 537 | + try { | ||
| 538 | + final success = await _rawDataApi.performAvgRealtimeStressDataUpload( | ||
| 539 | + sqliteFilePath: await _localStore.dbPath(_userId), | ||
| 540 | + ); | ||
| 541 | + if (success) { | ||
| 542 | + await _localStore.markDailyStressUploaded(userId: _userId); | ||
| 543 | + } | ||
| 544 | + } catch (error, stackTrace) { | ||
| 545 | + _logError('upload daily stress results failed', error, stackTrace); | ||
| 546 | + } finally { | ||
| 547 | + _isUploadingDailyStressResults = false; | ||
| 548 | + } | ||
| 549 | + } | ||
| 550 | + | ||
| 453 | HealthRawStressCalculationResult calculate({ | 551 | HealthRawStressCalculationResult calculate({ |
| 454 | required List<HealthKitRawDataPoint> hrvPoints, | 552 | required List<HealthKitRawDataPoint> hrvPoints, |
| 455 | required List<HealthKitRawDataPoint> heartRatePoints, | 553 | required List<HealthKitRawDataPoint> heartRatePoints, |
| @@ -833,6 +931,7 @@ class HealthRawRealtimeStressPoint { | @@ -833,6 +931,7 @@ class HealthRawRealtimeStressPoint { | ||
| 833 | const HealthRawRealtimeStressPoint({ | 931 | const HealthRawRealtimeStressPoint({ |
| 834 | required this.userId, | 932 | required this.userId, |
| 835 | required this.rawEndTime, | 933 | required this.rawEndTime, |
| 934 | + required this.rawHr, | ||
| 836 | required this.result, | 935 | required this.result, |
| 837 | required this.sourceStartTime, | 936 | required this.sourceStartTime, |
| 838 | required this.sourceEndTime, | 937 | required this.sourceEndTime, |
| @@ -842,6 +941,7 @@ class HealthRawRealtimeStressPoint { | @@ -842,6 +941,7 @@ class HealthRawRealtimeStressPoint { | ||
| 842 | 941 | ||
| 843 | final int userId; | 942 | final int userId; |
| 844 | final int rawEndTime; | 943 | final int rawEndTime; |
| 944 | + final double rawHr; | ||
| 845 | final double result; | 945 | final double result; |
| 846 | final int sourceStartTime; | 946 | final int sourceStartTime; |
| 847 | final int sourceEndTime; | 947 | final int sourceEndTime; |
| @@ -858,6 +958,7 @@ class HealthRawRealtimeStressPoint { | @@ -858,6 +958,7 @@ class HealthRawRealtimeStressPoint { | ||
| 858 | return HealthRawRealtimeStressPoint( | 958 | return HealthRawRealtimeStressPoint( |
| 859 | userId: row['user_id'] as int, | 959 | userId: row['user_id'] as int, |
| 860 | rawEndTime: row['raw_end_time'] as int, | 960 | rawEndTime: row['raw_end_time'] as int, |
| 961 | + rawHr: (row['raw_hr'] as num?)?.toDouble() ?? 0, | ||
| 861 | result: (row['result'] as num).toDouble(), | 962 | result: (row['result'] as num).toDouble(), |
| 862 | sourceStartTime: row['source_start_time'] as int, | 963 | sourceStartTime: row['source_start_time'] as int, |
| 863 | sourceEndTime: row['source_end_time'] as int, | 964 | sourceEndTime: row['source_end_time'] as int, |
| @@ -1231,6 +1332,18 @@ class HealthRawStressLocalStore { | @@ -1231,6 +1332,18 @@ class HealthRawStressLocalStore { | ||
| 1231 | ); | 1332 | ); |
| 1232 | } | 1333 | } |
| 1233 | 1334 | ||
| 1335 | + Future<void> markHrvStressUploadedUntil({ | ||
| 1336 | + required int userId, | ||
| 1337 | + required int rawEndTime, | ||
| 1338 | + }) { | ||
| 1339 | + return _markUploadedUntil( | ||
| 1340 | + userId: userId, | ||
| 1341 | + table: hrvResultsTable, | ||
| 1342 | + timeColumn: 'raw_end_time', | ||
| 1343 | + time: rawEndTime, | ||
| 1344 | + ); | ||
| 1345 | + } | ||
| 1346 | + | ||
| 1234 | Future<void> markRealtimeStressUploaded({ | 1347 | Future<void> markRealtimeStressUploaded({ |
| 1235 | required int userId, | 1348 | required int userId, |
| 1236 | required Iterable<int> rawEndTimes, | 1349 | required Iterable<int> rawEndTimes, |
| @@ -1242,6 +1355,40 @@ class HealthRawStressLocalStore { | @@ -1242,6 +1355,40 @@ class HealthRawStressLocalStore { | ||
| 1242 | ); | 1355 | ); |
| 1243 | } | 1356 | } |
| 1244 | 1357 | ||
| 1358 | + Future<void> markRealtimeStressUploadedUntil({ | ||
| 1359 | + required int userId, | ||
| 1360 | + required int rawEndTime, | ||
| 1361 | + }) { | ||
| 1362 | + return _markUploadedUntil( | ||
| 1363 | + userId: userId, | ||
| 1364 | + table: realtimeStressResultsTable, | ||
| 1365 | + timeColumn: 'raw_end_time', | ||
| 1366 | + time: rawEndTime, | ||
| 1367 | + ); | ||
| 1368 | + } | ||
| 1369 | + | ||
| 1370 | + Future<void> markDailyStressUploaded({required int userId}) async { | ||
| 1371 | + final db = await _database(userId); | ||
| 1372 | + await db.update( | ||
| 1373 | + dailyStressResultsTable, | ||
| 1374 | + {'uploaded': 1}, | ||
| 1375 | + where: 'uploaded != 1', | ||
| 1376 | + ); | ||
| 1377 | + } | ||
| 1378 | + | ||
| 1379 | + Future<bool> hasPendingHrvStressUploads({required int userId}) { | ||
| 1380 | + return _hasPendingUploads(userId: userId, table: hrvResultsTable); | ||
| 1381 | + } | ||
| 1382 | + | ||
| 1383 | + Future<bool> hasPendingRealtimeStressUploads({required int userId}) { | ||
| 1384 | + return _hasPendingUploads( | ||
| 1385 | + userId: userId, table: realtimeStressResultsTable); | ||
| 1386 | + } | ||
| 1387 | + | ||
| 1388 | + Future<bool> hasPendingDailyStressUploads({required int userId}) { | ||
| 1389 | + return _hasPendingUploads(userId: userId, table: dailyStressResultsTable); | ||
| 1390 | + } | ||
| 1391 | + | ||
| 1245 | Future<String> dbPath(int userId) async { | 1392 | Future<String> dbPath(int userId) async { |
| 1246 | final dir = _rootDirectory ?? await getApplicationDocumentsDirectory(); | 1393 | final dir = _rootDirectory ?? await getApplicationDocumentsDirectory(); |
| 1247 | return '${dir.path}/hrv_result_$userId.sqlite'; | 1394 | return '${dir.path}/hrv_result_$userId.sqlite'; |
| @@ -1282,7 +1429,7 @@ class HealthRawStressLocalStore { | @@ -1282,7 +1429,7 @@ class HealthRawStressLocalStore { | ||
| 1282 | final db = await factory.openDatabase( | 1429 | final db = await factory.openDatabase( |
| 1283 | path, | 1430 | path, |
| 1284 | options: OpenDatabaseOptions( | 1431 | options: OpenDatabaseOptions( |
| 1285 | - version: 5, | 1432 | + version: 6, |
| 1286 | onCreate: (db, version) async { | 1433 | onCreate: (db, version) async { |
| 1287 | await _createTables(db); | 1434 | await _createTables(db); |
| 1288 | }, | 1435 | }, |
| @@ -1300,6 +1447,9 @@ class HealthRawStressLocalStore { | @@ -1300,6 +1447,9 @@ class HealthRawStressLocalStore { | ||
| 1300 | if (oldVersion < 5) { | 1447 | if (oldVersion < 5) { |
| 1301 | await _createDailyStressTable(db); | 1448 | await _createDailyStressTable(db); |
| 1302 | } | 1449 | } |
| 1450 | + if (oldVersion < 6) { | ||
| 1451 | + await _addRealtimeRawHrColumn(db); | ||
| 1452 | + } | ||
| 1303 | }, | 1453 | }, |
| 1304 | ), | 1454 | ), |
| 1305 | ); | 1455 | ); |
| @@ -1332,6 +1482,7 @@ CREATE TABLE IF NOT EXISTS $hrvResultsTable ( | @@ -1332,6 +1482,7 @@ CREATE TABLE IF NOT EXISTS $hrvResultsTable ( | ||
| 1332 | CREATE TABLE IF NOT EXISTS $realtimeStressResultsTable ( | 1482 | CREATE TABLE IF NOT EXISTS $realtimeStressResultsTable ( |
| 1333 | raw_end_time INTEGER PRIMARY KEY, | 1483 | raw_end_time INTEGER PRIMARY KEY, |
| 1334 | user_id INTEGER NOT NULL, | 1484 | user_id INTEGER NOT NULL, |
| 1485 | + raw_hr REAL NOT NULL DEFAULT 0, | ||
| 1335 | result REAL NOT NULL, | 1486 | result REAL NOT NULL, |
| 1336 | source_start_time INTEGER NOT NULL, | 1487 | source_start_time INTEGER NOT NULL, |
| 1337 | source_end_time INTEGER NOT NULL, | 1488 | source_end_time INTEGER NOT NULL, |
| @@ -1408,6 +1559,16 @@ CREATE TABLE IF NOT EXISTS $dailyStressResultsTable ( | @@ -1408,6 +1559,16 @@ CREATE TABLE IF NOT EXISTS $dailyStressResultsTable ( | ||
| 1408 | } | 1559 | } |
| 1409 | } | 1560 | } |
| 1410 | 1561 | ||
| 1562 | + Future<void> _addRealtimeRawHrColumn(DatabaseExecutor db) async { | ||
| 1563 | + try { | ||
| 1564 | + await db.execute( | ||
| 1565 | + 'ALTER TABLE $realtimeStressResultsTable ADD COLUMN raw_hr REAL NOT NULL DEFAULT 0', | ||
| 1566 | + ); | ||
| 1567 | + } on DatabaseException catch (error) { | ||
| 1568 | + if (!error.isDuplicateColumnError()) rethrow; | ||
| 1569 | + } | ||
| 1570 | + } | ||
| 1571 | + | ||
| 1411 | Future<int?> _latestSourceStartTime(int userId, String table) async { | 1572 | Future<int?> _latestSourceStartTime(int userId, String table) async { |
| 1412 | final db = await _database(userId); | 1573 | final db = await _database(userId); |
| 1413 | final rows = await db.query( | 1574 | final rows = await db.query( |
| @@ -1436,6 +1597,36 @@ CREATE TABLE IF NOT EXISTS $dailyStressResultsTable ( | @@ -1436,6 +1597,36 @@ CREATE TABLE IF NOT EXISTS $dailyStressResultsTable ( | ||
| 1436 | ); | 1597 | ); |
| 1437 | } | 1598 | } |
| 1438 | 1599 | ||
| 1600 | + Future<void> _markUploadedUntil({ | ||
| 1601 | + required int userId, | ||
| 1602 | + required String table, | ||
| 1603 | + required String timeColumn, | ||
| 1604 | + required int time, | ||
| 1605 | + }) async { | ||
| 1606 | + if (time <= 0) return; | ||
| 1607 | + final db = await _database(userId); | ||
| 1608 | + await db.update( | ||
| 1609 | + table, | ||
| 1610 | + {'uploaded': 1}, | ||
| 1611 | + where: '$timeColumn <= ? AND uploaded != 1', | ||
| 1612 | + whereArgs: [time], | ||
| 1613 | + ); | ||
| 1614 | + } | ||
| 1615 | + | ||
| 1616 | + Future<bool> _hasPendingUploads({ | ||
| 1617 | + required int userId, | ||
| 1618 | + required String table, | ||
| 1619 | + }) async { | ||
| 1620 | + final db = await _database(userId); | ||
| 1621 | + final rows = await db.query( | ||
| 1622 | + table, | ||
| 1623 | + columns: ['uploaded'], | ||
| 1624 | + where: 'uploaded != 1', | ||
| 1625 | + limit: 1, | ||
| 1626 | + ); | ||
| 1627 | + return rows.isNotEmpty; | ||
| 1628 | + } | ||
| 1629 | + | ||
| 1439 | Map<String, Object?> _hrvRow(HealthRawHrvStressPoint point) { | 1630 | Map<String, Object?> _hrvRow(HealthRawHrvStressPoint point) { |
| 1440 | return <String, Object?>{ | 1631 | return <String, Object?>{ |
| 1441 | 'raw_end_time': point.rawEndTime, | 1632 | 'raw_end_time': point.rawEndTime, |
| @@ -1458,6 +1649,7 @@ CREATE TABLE IF NOT EXISTS $dailyStressResultsTable ( | @@ -1458,6 +1649,7 @@ CREATE TABLE IF NOT EXISTS $dailyStressResultsTable ( | ||
| 1458 | return <String, Object?>{ | 1649 | return <String, Object?>{ |
| 1459 | 'raw_end_time': point.rawEndTime, | 1650 | 'raw_end_time': point.rawEndTime, |
| 1460 | 'user_id': point.userId, | 1651 | 'user_id': point.userId, |
| 1652 | + 'raw_hr': point.rawHr, | ||
| 1461 | 'result': point.result, | 1653 | 'result': point.result, |
| 1462 | 'source_start_time': point.sourceStartTime, | 1654 | 'source_start_time': point.sourceStartTime, |
| 1463 | 'source_end_time': point.sourceEndTime, | 1655 | 'source_end_time': point.sourceEndTime, |
| @@ -1492,16 +1684,32 @@ CREATE TABLE IF NOT EXISTS $dailyStressResultsTable ( | @@ -1492,16 +1684,32 @@ CREATE TABLE IF NOT EXISTS $dailyStressResultsTable ( | ||
| 1492 | String table, | 1684 | String table, |
| 1493 | Map<String, Object?> row, | 1685 | Map<String, Object?> row, |
| 1494 | ) async { | 1686 | ) async { |
| 1495 | - await db.insert( | 1687 | + final existing = await db.query( |
| 1496 | table, | 1688 | table, |
| 1689 | + where: 'raw_end_time = ?', | ||
| 1690 | + whereArgs: [row['raw_end_time']], | ||
| 1691 | + limit: 1, | ||
| 1692 | + ); | ||
| 1693 | + if (existing.isEmpty) { | ||
| 1694 | + await db.insert(table, row); | ||
| 1695 | + return; | ||
| 1696 | + } | ||
| 1697 | + final existingRow = existing.first; | ||
| 1698 | + if (_matchesStoredValues( | ||
| 1699 | + existingRow, row, _rawResultStoredValueKeys(row))) { | ||
| 1700 | + return; | ||
| 1701 | + } | ||
| 1702 | + final uploadPayloadChanged = !_matchesStoredValues( | ||
| 1703 | + existingRow, | ||
| 1497 | row, | 1704 | row, |
| 1498 | - conflictAlgorithm: ConflictAlgorithm.ignore, | 1705 | + _rawResultUploadValueKeys(row), |
| 1499 | ); | 1706 | ); |
| 1500 | await db.update( | 1707 | await db.update( |
| 1501 | table, | 1708 | table, |
| 1502 | <String, Object?>{ | 1709 | <String, Object?>{ |
| 1503 | 'user_id': row['user_id'], | 1710 | 'user_id': row['user_id'], |
| 1504 | if (row.containsKey('raw_hrv')) 'raw_hrv': row['raw_hrv'], | 1711 | if (row.containsKey('raw_hrv')) 'raw_hrv': row['raw_hrv'], |
| 1712 | + if (row.containsKey('raw_hr')) 'raw_hr': row['raw_hr'], | ||
| 1505 | 'result': row['result'], | 1713 | 'result': row['result'], |
| 1506 | 'source_start_time': row['source_start_time'], | 1714 | 'source_start_time': row['source_start_time'], |
| 1507 | 'source_end_time': row['source_end_time'], | 1715 | 'source_end_time': row['source_end_time'], |
| @@ -1518,7 +1726,7 @@ CREATE TABLE IF NOT EXISTS $dailyStressResultsTable ( | @@ -1518,7 +1726,7 @@ CREATE TABLE IF NOT EXISTS $dailyStressResultsTable ( | ||
| 1518 | 'is_workout_recovery': row['is_workout_recovery'], | 1726 | 'is_workout_recovery': row['is_workout_recovery'], |
| 1519 | 'is_sleep_likely': row['is_sleep_likely'], | 1727 | 'is_sleep_likely': row['is_sleep_likely'], |
| 1520 | 'is_suspected_activity': row['is_suspected_activity'], | 1728 | 'is_suspected_activity': row['is_suspected_activity'], |
| 1521 | - 'uploaded': 0, | 1729 | + 'uploaded': uploadPayloadChanged ? 0 : existingRow['uploaded'], |
| 1522 | }, | 1730 | }, |
| 1523 | where: 'raw_end_time = ?', | 1731 | where: 'raw_end_time = ?', |
| 1524 | whereArgs: [row['raw_end_time']], | 1732 | whereArgs: [row['raw_end_time']], |
| @@ -1529,10 +1737,21 @@ CREATE TABLE IF NOT EXISTS $dailyStressResultsTable ( | @@ -1529,10 +1737,21 @@ CREATE TABLE IF NOT EXISTS $dailyStressResultsTable ( | ||
| 1529 | DatabaseExecutor db, | 1737 | DatabaseExecutor db, |
| 1530 | Map<String, Object?> row, | 1738 | Map<String, Object?> row, |
| 1531 | ) async { | 1739 | ) async { |
| 1532 | - await db.insert( | 1740 | + final existing = await db.query( |
| 1533 | dailyStressResultsTable, | 1741 | dailyStressResultsTable, |
| 1742 | + where: 'date = ?', | ||
| 1743 | + whereArgs: [row['date']], | ||
| 1744 | + limit: 1, | ||
| 1745 | + ); | ||
| 1746 | + if (existing.isEmpty) { | ||
| 1747 | + await db.insert(dailyStressResultsTable, row); | ||
| 1748 | + return; | ||
| 1749 | + } | ||
| 1750 | + final existingRow = existing.first; | ||
| 1751 | + final valueChanged = !_matchesStoredValues( | ||
| 1752 | + existingRow, | ||
| 1534 | row, | 1753 | row, |
| 1535 | - conflictAlgorithm: ConflictAlgorithm.ignore, | 1754 | + const ['user_id', 'stress_value', 'stress_score', 'state'], |
| 1536 | ); | 1755 | ); |
| 1537 | await db.update( | 1756 | await db.update( |
| 1538 | dailyStressResultsTable, | 1757 | dailyStressResultsTable, |
| @@ -1542,10 +1761,68 @@ CREATE TABLE IF NOT EXISTS $dailyStressResultsTable ( | @@ -1542,10 +1761,68 @@ CREATE TABLE IF NOT EXISTS $dailyStressResultsTable ( | ||
| 1542 | 'stress_score': row['stress_score'], | 1761 | 'stress_score': row['stress_score'], |
| 1543 | 'state': row['state'], | 1762 | 'state': row['state'], |
| 1544 | 'data_time': row['data_time'], | 1763 | 'data_time': row['data_time'], |
| 1545 | - 'uploaded': 0, | 1764 | + 'uploaded': valueChanged ? 0 : existingRow['uploaded'], |
| 1546 | }, | 1765 | }, |
| 1547 | where: 'date = ?', | 1766 | where: 'date = ?', |
| 1548 | whereArgs: [row['date']], | 1767 | whereArgs: [row['date']], |
| 1549 | ); | 1768 | ); |
| 1550 | } | 1769 | } |
| 1770 | + | ||
| 1771 | + List<String> _rawResultStoredValueKeys(Map<String, Object?> row) { | ||
| 1772 | + return <String>[ | ||
| 1773 | + 'user_id', | ||
| 1774 | + if (row.containsKey('raw_hrv')) 'raw_hrv', | ||
| 1775 | + if (row.containsKey('raw_hr')) 'raw_hr', | ||
| 1776 | + 'result', | ||
| 1777 | + 'source_start_time', | ||
| 1778 | + 'source_end_time', | ||
| 1779 | + if (row.containsKey('state')) 'state', | ||
| 1780 | + if (row.containsKey('baseline_hrv')) 'baseline_hrv', | ||
| 1781 | + if (row.containsKey('baseline_awake_hrv')) 'baseline_awake_hrv', | ||
| 1782 | + if (row.containsKey('baseline_sleep_hrv')) 'baseline_sleep_hrv', | ||
| 1783 | + if (row.containsKey('baseline_resting_hr')) 'baseline_resting_hr', | ||
| 1784 | + 'is_workout', | ||
| 1785 | + 'is_workout_recovery', | ||
| 1786 | + 'is_sleep_likely', | ||
| 1787 | + 'is_suspected_activity', | ||
| 1788 | + ]; | ||
| 1789 | + } | ||
| 1790 | + | ||
| 1791 | + List<String> _rawResultUploadValueKeys(Map<String, Object?> row) { | ||
| 1792 | + return <String>[ | ||
| 1793 | + 'user_id', | ||
| 1794 | + if (row.containsKey('raw_hrv')) 'raw_hrv', | ||
| 1795 | + if (row.containsKey('raw_hr')) 'raw_hr', | ||
| 1796 | + 'result', | ||
| 1797 | + if (row.containsKey('state')) 'state', | ||
| 1798 | + if (row.containsKey('baseline_hrv')) 'baseline_hrv', | ||
| 1799 | + if (row.containsKey('baseline_awake_hrv')) 'baseline_awake_hrv', | ||
| 1800 | + if (row.containsKey('baseline_sleep_hrv')) 'baseline_sleep_hrv', | ||
| 1801 | + if (row.containsKey('baseline_resting_hr')) 'baseline_resting_hr', | ||
| 1802 | + 'is_workout', | ||
| 1803 | + 'is_workout_recovery', | ||
| 1804 | + 'is_sleep_likely', | ||
| 1805 | + 'is_suspected_activity', | ||
| 1806 | + ]; | ||
| 1807 | + } | ||
| 1808 | + | ||
| 1809 | + bool _matchesStoredValues( | ||
| 1810 | + Map<String, Object?> existing, | ||
| 1811 | + Map<String, Object?> next, | ||
| 1812 | + Iterable<String> keys, | ||
| 1813 | + ) { | ||
| 1814 | + for (final key in keys) { | ||
| 1815 | + if (!_storedValueEquals(existing[key], next[key])) { | ||
| 1816 | + return false; | ||
| 1817 | + } | ||
| 1818 | + } | ||
| 1819 | + return true; | ||
| 1820 | + } | ||
| 1821 | + | ||
| 1822 | + bool _storedValueEquals(Object? a, Object? b) { | ||
| 1823 | + if (a is num && b is num) { | ||
| 1824 | + return (a.toDouble() - b.toDouble()).abs() < 0.000001; | ||
| 1825 | + } | ||
| 1826 | + return a == b; | ||
| 1827 | + } | ||
| 1551 | } | 1828 | } |
| @@ -365,6 +365,7 @@ class HealthRawStressCalculator { | @@ -365,6 +365,7 @@ class HealthRawStressCalculator { | ||
| 365 | HealthRawRealtimeStressPoint( | 365 | HealthRawRealtimeStressPoint( |
| 366 | userId: userId, | 366 | userId: userId, |
| 367 | rawEndTime: currentRawPoint.endTime, | 367 | rawEndTime: currentRawPoint.endTime, |
| 368 | + rawHr: currentRawPoint.value ?? 0, | ||
| 368 | result: stress, | 369 | result: stress, |
| 369 | sourceStartTime: sourceRange.start, | 370 | sourceStartTime: sourceRange.start, |
| 370 | sourceEndTime: sourceRange.end, | 371 | sourceEndTime: sourceRange.end, |
| @@ -9,6 +9,7 @@ import 'package:doublefeel_flutter/data/models/health/sleep/sleep_statistics_dat | @@ -9,6 +9,7 @@ import 'package:doublefeel_flutter/data/models/health/sleep/sleep_statistics_dat | ||
| 9 | 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'; |
| 10 | 10 | ||
| 11 | const _sleepTypeInBed = 0; | 11 | const _sleepTypeInBed = 0; |
| 12 | +const _sleepTypeAsleep = 1; | ||
| 12 | const _sleepTypeAsleepUnspecified = 1; | 13 | const _sleepTypeAsleepUnspecified = 1; |
| 13 | const _sleepTypeAwake = 2; | 14 | const _sleepTypeAwake = 2; |
| 14 | const _sleepTypeAsleepCore = 3; | 15 | const _sleepTypeAsleepCore = 3; |
| @@ -499,7 +500,7 @@ class LocalHealthDataConvert { | @@ -499,7 +500,7 @@ class LocalHealthDataConvert { | ||
| 499 | final sleepEvaluate = _sleepEvaluate(score); | 500 | final sleepEvaluate = _sleepEvaluate(score); |
| 500 | final sleepDuration = nullableScore == null || sleepEvaluate == null | 501 | final sleepDuration = nullableScore == null || sleepEvaluate == null |
| 501 | ? 0 | 502 | ? 0 |
| 502 | - : windowEnd - windowStart; | 503 | + : summary.totalAsleepMinutes.round() * 60; |
| 503 | 504 | ||
| 504 | return _SleepDaySummary( | 505 | return _SleepDaySummary( |
| 505 | day: day, | 506 | day: day, |
| @@ -748,11 +749,10 @@ class LocalHealthDataConvert { | @@ -748,11 +749,10 @@ class LocalHealthDataConvert { | ||
| 748 | remMinutes += minutes; | 749 | remMinutes += minutes; |
| 749 | asleepMinutes += minutes; | 750 | asleepMinutes += minutes; |
| 750 | break; | 751 | break; |
| 751 | - case _sleepTypeAsleepUnspecified: | ||
| 752 | - asleepMinutes += minutes; | ||
| 753 | - break; | ||
| 754 | default: | 752 | default: |
| 755 | - asleepMinutes += minutes; | 753 | + if (_isAsleepSleepType(interval.dataType)) { |
| 754 | + asleepMinutes += minutes; | ||
| 755 | + } | ||
| 756 | break; | 756 | break; |
| 757 | } | 757 | } |
| 758 | } | 758 | } |
| @@ -815,6 +815,14 @@ class LocalHealthDataConvert { | @@ -815,6 +815,14 @@ class LocalHealthDataConvert { | ||
| 815 | return 3; | 815 | return 3; |
| 816 | } | 816 | } |
| 817 | 817 | ||
| 818 | + static bool _isAsleepSleepType(int dataType) { | ||
| 819 | + return dataType == _sleepTypeAsleep || | ||
| 820 | + dataType == _sleepTypeAsleepUnspecified || | ||
| 821 | + dataType == _sleepTypeAsleepCore || | ||
| 822 | + dataType == _sleepTypeAsleepDeep || | ||
| 823 | + dataType == _sleepTypeAsleepRem; | ||
| 824 | + } | ||
| 825 | + | ||
| 818 | static int? _modeState(Iterable<int> states) { | 826 | static int? _modeState(Iterable<int> states) { |
| 819 | final counts = <int, int>{}; | 827 | final counts = <int, int>{}; |
| 820 | for (final state in states) { | 828 | for (final state in states) { |
| @@ -42,6 +42,7 @@ void main() { | @@ -42,6 +42,7 @@ void main() { | ||
| 42 | rawDataApi: api, | 42 | rawDataApi: api, |
| 43 | localStore: store, | 43 | localStore: store, |
| 44 | userIdProvider: () => 42, | 44 | userIdProvider: () => 42, |
| 45 | + uploadResultsAfterCalculation: false, | ||
| 45 | ); | 46 | ); |
| 46 | 47 | ||
| 47 | final first = await service.startCoreCaculate( | 48 | final first = await service.startCoreCaculate( |
| @@ -99,10 +100,10 @@ void main() { | @@ -99,10 +100,10 @@ void main() { | ||
| 99 | realtime.map((e) => e.rawEndTime), | 100 | realtime.map((e) => e.rawEndTime), |
| 100 | [base + 60, base + 420, base + 720], | 101 | [base + 60, base + 420, base + 720], |
| 101 | ); | 102 | ); |
| 102 | - expect(hrv.firstWhere((e) => e.rawEndTime == base + 420).uploaded, isFalse); | 103 | + expect(hrv.firstWhere((e) => e.rawEndTime == base + 420).uploaded, isTrue); |
| 103 | expect( | 104 | expect( |
| 104 | realtime.firstWhere((e) => e.rawEndTime == base + 420).uploaded, | 105 | realtime.firstWhere((e) => e.rawEndTime == base + 420).uploaded, |
| 105 | - isFalse, | 106 | + isTrue, |
| 106 | ); | 107 | ); |
| 107 | expect( | 108 | expect( |
| 108 | realtime | 109 | realtime |
| @@ -169,6 +170,7 @@ void main() { | @@ -169,6 +170,7 @@ void main() { | ||
| 169 | rawDataApi: api, | 170 | rawDataApi: api, |
| 170 | localStore: store, | 171 | localStore: store, |
| 171 | userIdProvider: () => 42, | 172 | userIdProvider: () => 42, |
| 173 | + uploadResultsAfterCalculation: false, | ||
| 172 | ); | 174 | ); |
| 173 | 175 | ||
| 174 | final result = await service.syncAndStore( | 176 | final result = await service.syncAndStore( |
| @@ -230,6 +232,42 @@ void main() { | @@ -230,6 +232,42 @@ void main() { | ||
| 230 | expect(daily.single.uploaded, isFalse); | 232 | expect(daily.single.uploaded, isFalse); |
| 231 | }); | 233 | }); |
| 232 | 234 | ||
| 235 | + test('same calculated rows keep uploaded state', () async { | ||
| 236 | + final store = _MemoryHealthRawStressLocalStore(); | ||
| 237 | + const uploadedDaily = HealthRawDailyStressPoint( | ||
| 238 | + userId: 42, | ||
| 239 | + date: 20260720, | ||
| 240 | + stressValue: 66, | ||
| 241 | + stressScore: 70, | ||
| 242 | + state: HealthRawStressState.attention, | ||
| 243 | + dataTime: 1000, | ||
| 244 | + uploaded: true, | ||
| 245 | + ); | ||
| 246 | + store.insertDailyStress(uploadedDaily); | ||
| 247 | + | ||
| 248 | + await store.upsertDailyStressPoints( | ||
| 249 | + userId: 42, | ||
| 250 | + points: const [ | ||
| 251 | + HealthRawDailyStressPoint( | ||
| 252 | + userId: 42, | ||
| 253 | + date: 20260720, | ||
| 254 | + stressValue: 66, | ||
| 255 | + stressScore: 70, | ||
| 256 | + state: HealthRawStressState.attention, | ||
| 257 | + dataTime: 2000, | ||
| 258 | + ), | ||
| 259 | + ], | ||
| 260 | + ); | ||
| 261 | + | ||
| 262 | + final daily = await store.queryDailyStressPoints( | ||
| 263 | + userId: 42, | ||
| 264 | + startDate: 20260720, | ||
| 265 | + endDate: 20260720, | ||
| 266 | + ); | ||
| 267 | + expect(daily.single.dataTime, 2000); | ||
| 268 | + expect(daily.single.uploaded, isTrue); | ||
| 269 | + }); | ||
| 270 | + | ||
| 233 | test('startCoreCaculate skips raw reads when health auth is missing', | 271 | test('startCoreCaculate skips raw reads when health auth is missing', |
| 234 | () async { | 272 | () async { |
| 235 | final api = _FakeHealthKitRawDataHostApi(); | 273 | final api = _FakeHealthKitRawDataHostApi(); |
| @@ -238,6 +276,7 @@ void main() { | @@ -238,6 +276,7 @@ void main() { | ||
| 238 | rawDataApi: api, | 276 | rawDataApi: api, |
| 239 | localStore: _MemoryHealthRawStressLocalStore(), | 277 | localStore: _MemoryHealthRawStressLocalStore(), |
| 240 | userIdProvider: () => 42, | 278 | userIdProvider: () => 42, |
| 279 | + uploadResultsAfterCalculation: false, | ||
| 241 | ); | 280 | ); |
| 242 | 281 | ||
| 243 | final result = await service.startCoreCaculate(readChunkDays: 1); | 282 | final result = await service.startCoreCaculate(readChunkDays: 1); |
| @@ -262,6 +301,7 @@ void main() { | @@ -262,6 +301,7 @@ void main() { | ||
| 262 | rawDataApi: api, | 301 | rawDataApi: api, |
| 263 | localStore: _MemoryHealthRawStressLocalStore(), | 302 | localStore: _MemoryHealthRawStressLocalStore(), |
| 264 | userIdProvider: () => 42, | 303 | userIdProvider: () => 42, |
| 304 | + uploadResultsAfterCalculation: false, | ||
| 265 | ); | 305 | ); |
| 266 | final eventFuture = service.healthDataUpdatedStream.first; | 306 | final eventFuture = service.healthDataUpdatedStream.first; |
| 267 | 307 | ||
| @@ -328,6 +368,18 @@ class _FakeHealthKitRawDataHostApi extends HealthKitRawDataHostApi { | @@ -328,6 +368,18 @@ class _FakeHealthKitRawDataHostApi extends HealthKitRawDataHostApi { | ||
| 328 | } | 368 | } |
| 329 | 369 | ||
| 330 | @override | 370 | @override |
| 371 | + Future<int> performHRDataUpload({required String sqliteFilePath}) async => 0; | ||
| 372 | + | ||
| 373 | + @override | ||
| 374 | + Future<int> performHRVDataUpload({required String sqliteFilePath}) async => 0; | ||
| 375 | + | ||
| 376 | + @override | ||
| 377 | + Future<bool> performAvgRealtimeStressDataUpload({ | ||
| 378 | + required String sqliteFilePath, | ||
| 379 | + }) async => | ||
| 380 | + false; | ||
| 381 | + | ||
| 382 | + @override | ||
| 331 | Future<List<HealthKitRawSleepDataPoint>> getHealthKitRawSleepData( | 383 | Future<List<HealthKitRawSleepDataPoint>> getHealthKitRawSleepData( |
| 332 | int startTime, | 384 | int startTime, |
| 333 | int endTime, | 385 | int endTime, |
| @@ -397,6 +449,8 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | @@ -397,6 +449,8 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | ||
| 397 | point.rawEndTime: point, | 449 | point.rawEndTime: point, |
| 398 | }; | 450 | }; |
| 399 | for (final point in result.hrvStressPoints) { | 451 | for (final point in result.hrvStressPoints) { |
| 452 | + final existing = hrvByTime[point.rawEndTime]; | ||
| 453 | + final same = existing != null && _sameHrvStressPoint(existing, point); | ||
| 400 | hrvByTime[point.rawEndTime] = HealthRawHrvStressPoint( | 454 | hrvByTime[point.rawEndTime] = HealthRawHrvStressPoint( |
| 401 | userId: point.userId, | 455 | userId: point.userId, |
| 402 | rawEndTime: point.rawEndTime, | 456 | rawEndTime: point.rawEndTime, |
| @@ -410,6 +464,7 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | @@ -410,6 +464,7 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | ||
| 410 | baselineSleepHrv: point.baselineSleepHrv, | 464 | baselineSleepHrv: point.baselineSleepHrv, |
| 411 | baselineRestingHr: point.baselineRestingHr, | 465 | baselineRestingHr: point.baselineRestingHr, |
| 412 | flags: point.flags, | 466 | flags: point.flags, |
| 467 | + uploaded: same ? existing.uploaded : false, | ||
| 413 | ); | 468 | ); |
| 414 | } | 469 | } |
| 415 | _hrv[result.userId] = hrvByTime.values.toList() | 470 | _hrv[result.userId] = hrvByTime.values.toList() |
| @@ -421,13 +476,18 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | @@ -421,13 +476,18 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | ||
| 421 | point.rawEndTime: point, | 476 | point.rawEndTime: point, |
| 422 | }; | 477 | }; |
| 423 | for (final point in result.realtimeStressPoints) { | 478 | for (final point in result.realtimeStressPoints) { |
| 479 | + final existing = realtimeByTime[point.rawEndTime]; | ||
| 480 | + final same = | ||
| 481 | + existing != null && _sameRealtimeStressPoint(existing, point); | ||
| 424 | realtimeByTime[point.rawEndTime] = HealthRawRealtimeStressPoint( | 482 | realtimeByTime[point.rawEndTime] = HealthRawRealtimeStressPoint( |
| 425 | userId: point.userId, | 483 | userId: point.userId, |
| 426 | rawEndTime: point.rawEndTime, | 484 | rawEndTime: point.rawEndTime, |
| 485 | + rawHr: point.rawHr, | ||
| 427 | result: point.result, | 486 | result: point.result, |
| 428 | sourceStartTime: point.sourceStartTime, | 487 | sourceStartTime: point.sourceStartTime, |
| 429 | sourceEndTime: point.sourceEndTime, | 488 | sourceEndTime: point.sourceEndTime, |
| 430 | flags: point.flags, | 489 | flags: point.flags, |
| 490 | + uploaded: same ? existing.uploaded : false, | ||
| 431 | ); | 491 | ); |
| 432 | } | 492 | } |
| 433 | _realtime[result.userId] = realtimeByTime.values.toList() | 493 | _realtime[result.userId] = realtimeByTime.values.toList() |
| @@ -445,6 +505,12 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | @@ -445,6 +505,12 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | ||
| 445 | point.date: point, | 505 | point.date: point, |
| 446 | }; | 506 | }; |
| 447 | for (final point in points) { | 507 | for (final point in points) { |
| 508 | + final existing = byDate[point.date]; | ||
| 509 | + final same = existing != null && | ||
| 510 | + existing.userId == point.userId && | ||
| 511 | + existing.stressValue == point.stressValue && | ||
| 512 | + existing.stressScore == point.stressScore && | ||
| 513 | + existing.state == point.state; | ||
| 448 | byDate[point.date] = HealthRawDailyStressPoint( | 514 | byDate[point.date] = HealthRawDailyStressPoint( |
| 449 | userId: point.userId, | 515 | userId: point.userId, |
| 450 | date: point.date, | 516 | date: point.date, |
| @@ -452,6 +518,7 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | @@ -452,6 +518,7 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | ||
| 452 | stressScore: point.stressScore, | 518 | stressScore: point.stressScore, |
| 453 | state: point.state, | 519 | state: point.state, |
| 454 | dataTime: point.dataTime, | 520 | dataTime: point.dataTime, |
| 521 | + uploaded: same ? existing.uploaded : false, | ||
| 455 | ); | 522 | ); |
| 456 | } | 523 | } |
| 457 | _daily[userId] = byDate.values.toList() | 524 | _daily[userId] = byDate.values.toList() |
| @@ -570,6 +637,17 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | @@ -570,6 +637,17 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | ||
| 570 | } | 637 | } |
| 571 | 638 | ||
| 572 | @override | 639 | @override |
| 640 | + Future<void> markHrvStressUploadedUntil({ | ||
| 641 | + required int userId, | ||
| 642 | + required int rawEndTime, | ||
| 643 | + }) async { | ||
| 644 | + final times = (_hrv[userId] ?? <HealthRawHrvStressPoint>[]) | ||
| 645 | + .where((point) => point.rawEndTime <= rawEndTime) | ||
| 646 | + .map((point) => point.rawEndTime); | ||
| 647 | + await markHrvStressUploaded(userId: userId, rawEndTimes: times); | ||
| 648 | + } | ||
| 649 | + | ||
| 650 | + @override | ||
| 573 | Future<void> markRealtimeStressUploaded({ | 651 | Future<void> markRealtimeStressUploaded({ |
| 574 | required int userId, | 652 | required int userId, |
| 575 | required Iterable<int> rawEndTimes, | 653 | required Iterable<int> rawEndTimes, |
| @@ -581,6 +659,7 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | @@ -581,6 +659,7 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | ||
| 581 | return HealthRawRealtimeStressPoint( | 659 | return HealthRawRealtimeStressPoint( |
| 582 | userId: point.userId, | 660 | userId: point.userId, |
| 583 | rawEndTime: point.rawEndTime, | 661 | rawEndTime: point.rawEndTime, |
| 662 | + rawHr: point.rawHr, | ||
| 584 | result: point.result, | 663 | result: point.result, |
| 585 | sourceStartTime: point.sourceStartTime, | 664 | sourceStartTime: point.sourceStartTime, |
| 586 | sourceEndTime: point.sourceEndTime, | 665 | sourceEndTime: point.sourceEndTime, |
| @@ -590,6 +669,82 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | @@ -590,6 +669,82 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | ||
| 590 | }).toList(); | 669 | }).toList(); |
| 591 | } | 670 | } |
| 592 | 671 | ||
| 672 | + @override | ||
| 673 | + Future<void> markRealtimeStressUploadedUntil({ | ||
| 674 | + required int userId, | ||
| 675 | + required int rawEndTime, | ||
| 676 | + }) async { | ||
| 677 | + final times = (_realtime[userId] ?? <HealthRawRealtimeStressPoint>[]) | ||
| 678 | + .where((point) => point.rawEndTime <= rawEndTime) | ||
| 679 | + .map((point) => point.rawEndTime); | ||
| 680 | + await markRealtimeStressUploaded(userId: userId, rawEndTimes: times); | ||
| 681 | + } | ||
| 682 | + | ||
| 683 | + @override | ||
| 684 | + Future<void> markDailyStressUploaded({required int userId}) async { | ||
| 685 | + _daily[userId] = (_daily[userId] ?? <HealthRawDailyStressPoint>[]) | ||
| 686 | + .map((point) => HealthRawDailyStressPoint( | ||
| 687 | + userId: point.userId, | ||
| 688 | + date: point.date, | ||
| 689 | + stressValue: point.stressValue, | ||
| 690 | + stressScore: point.stressScore, | ||
| 691 | + state: point.state, | ||
| 692 | + dataTime: point.dataTime, | ||
| 693 | + uploaded: true, | ||
| 694 | + )) | ||
| 695 | + .toList(); | ||
| 696 | + } | ||
| 697 | + | ||
| 698 | + @override | ||
| 699 | + Future<bool> hasPendingHrvStressUploads({required int userId}) async { | ||
| 700 | + return (_hrv[userId] ?? <HealthRawHrvStressPoint>[]) | ||
| 701 | + .any((point) => !point.uploaded); | ||
| 702 | + } | ||
| 703 | + | ||
| 704 | + @override | ||
| 705 | + Future<bool> hasPendingRealtimeStressUploads({required int userId}) async { | ||
| 706 | + return (_realtime[userId] ?? <HealthRawRealtimeStressPoint>[]) | ||
| 707 | + .any((point) => !point.uploaded); | ||
| 708 | + } | ||
| 709 | + | ||
| 710 | + @override | ||
| 711 | + Future<bool> hasPendingDailyStressUploads({required int userId}) async { | ||
| 712 | + return (_daily[userId] ?? <HealthRawDailyStressPoint>[]) | ||
| 713 | + .any((point) => !point.uploaded); | ||
| 714 | + } | ||
| 715 | + | ||
| 716 | + bool _sameHrvStressPoint( | ||
| 717 | + HealthRawHrvStressPoint a, | ||
| 718 | + HealthRawHrvStressPoint b, | ||
| 719 | + ) { | ||
| 720 | + return a.userId == b.userId && | ||
| 721 | + a.rawHrv == b.rawHrv && | ||
| 722 | + a.result == b.result && | ||
| 723 | + a.state == b.state && | ||
| 724 | + a.baselineHrv == b.baselineHrv && | ||
| 725 | + a.baselineAwakeHrv == b.baselineAwakeHrv && | ||
| 726 | + a.baselineSleepHrv == b.baselineSleepHrv && | ||
| 727 | + a.baselineRestingHr == b.baselineRestingHr && | ||
| 728 | + _sameFlags(a.flags, b.flags); | ||
| 729 | + } | ||
| 730 | + | ||
| 731 | + bool _sameRealtimeStressPoint( | ||
| 732 | + HealthRawRealtimeStressPoint a, | ||
| 733 | + HealthRawRealtimeStressPoint b, | ||
| 734 | + ) { | ||
| 735 | + return a.userId == b.userId && | ||
| 736 | + a.rawHr == b.rawHr && | ||
| 737 | + a.result == b.result && | ||
| 738 | + _sameFlags(a.flags, b.flags); | ||
| 739 | + } | ||
| 740 | + | ||
| 741 | + bool _sameFlags(HealthRawPointFlags a, HealthRawPointFlags b) { | ||
| 742 | + return a.isWorkout == b.isWorkout && | ||
| 743 | + a.isWorkoutRecovery == b.isWorkoutRecovery && | ||
| 744 | + a.isSleepLikely == b.isSleepLikely && | ||
| 745 | + a.isSuspectedActivity == b.isSuspectedActivity; | ||
| 746 | + } | ||
| 747 | + | ||
| 593 | Map<String, Object?> _hrvRow(HealthRawHrvStressPoint point) { | 748 | Map<String, Object?> _hrvRow(HealthRawHrvStressPoint point) { |
| 594 | return <String, Object?>{ | 749 | return <String, Object?>{ |
| 595 | 'raw_end_time': point.rawEndTime, | 750 | 'raw_end_time': point.rawEndTime, |
| @@ -615,6 +770,7 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | @@ -615,6 +770,7 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | ||
| 615 | return <String, Object?>{ | 770 | return <String, Object?>{ |
| 616 | 'raw_end_time': point.rawEndTime, | 771 | 'raw_end_time': point.rawEndTime, |
| 617 | 'user_id': point.userId, | 772 | 'user_id': point.userId, |
| 773 | + 'raw_hr': point.rawHr, | ||
| 618 | 'result': point.result, | 774 | 'result': point.result, |
| 619 | 'source_start_time': point.sourceStartTime, | 775 | 'source_start_time': point.sourceStartTime, |
| 620 | 'source_end_time': point.sourceEndTime, | 776 | 'source_end_time': point.sourceEndTime, |
| @@ -81,6 +81,146 @@ void main() { | @@ -81,6 +81,146 @@ void main() { | ||
| 81 | expect(statistics.sleepTrendList?.single.sleepEvaluate, isNull); | 81 | expect(statistics.sleepTrendList?.single.sleepEvaluate, isNull); |
| 82 | expect(statistics.avgSleepDuration, isNull); | 82 | expect(statistics.avgSleepDuration, isNull); |
| 83 | }); | 83 | }); |
| 84 | + | ||
| 85 | + test('sleep duration excludes awake intervals inside sleep range', () { | ||
| 86 | + final day = DateTime(2026, 7, 16); | ||
| 87 | + final sleepStart = DateTime(2026, 7, 15, 23, 56); | ||
| 88 | + final awakeStart = DateTime(2026, 7, 16, 3); | ||
| 89 | + final awakeEnd = DateTime(2026, 7, 16, 3, 4); | ||
| 90 | + final sleepEnd = DateTime(2026, 7, 16, 8, 24); | ||
| 91 | + final intervals = [ | ||
| 92 | + HealthKitRawDataPoint( | ||
| 93 | + dataType: 3, | ||
| 94 | + startTime: LocalHealthDataConvert.unixSeconds(sleepStart), | ||
| 95 | + endTime: LocalHealthDataConvert.unixSeconds(awakeStart), | ||
| 96 | + ), | ||
| 97 | + HealthKitRawDataPoint( | ||
| 98 | + dataType: 2, | ||
| 99 | + startTime: LocalHealthDataConvert.unixSeconds(awakeStart), | ||
| 100 | + endTime: LocalHealthDataConvert.unixSeconds(awakeEnd), | ||
| 101 | + ), | ||
| 102 | + HealthKitRawDataPoint( | ||
| 103 | + dataType: 3, | ||
| 104 | + startTime: LocalHealthDataConvert.unixSeconds(awakeEnd), | ||
| 105 | + endTime: LocalHealthDataConvert.unixSeconds(sleepEnd), | ||
| 106 | + ), | ||
| 107 | + ]; | ||
| 108 | + | ||
| 109 | + final statistics = LocalHealthDataConvert.sleepStatistics( | ||
| 110 | + dateRangeType: 3, | ||
| 111 | + days: [day], | ||
| 112 | + previousDays: const [], | ||
| 113 | + sleepIntervals: intervals, | ||
| 114 | + heartRate: const [], | ||
| 115 | + ); | ||
| 116 | + | ||
| 117 | + expect( | ||
| 118 | + statistics.sleepTrendList?.single.totalTime, | ||
| 119 | + const Duration(hours: 8, minutes: 24).inSeconds, | ||
| 120 | + ); | ||
| 121 | + expect( | ||
| 122 | + statistics.avgSleepDuration, | ||
| 123 | + const Duration(hours: 8, minutes: 24).inSeconds, | ||
| 124 | + ); | ||
| 125 | + }); | ||
| 126 | + | ||
| 127 | + test('sleep duration is rounded to displayed Apple Health minutes', () { | ||
| 128 | + final day = DateTime(2026, 7, 16); | ||
| 129 | + final sleepStart = DateTime(2026, 7, 15, 23, 56, 20); | ||
| 130 | + final sleepEnd = DateTime(2026, 7, 16, 8, 20, 50); | ||
| 131 | + final statistics = LocalHealthDataConvert.sleepStatistics( | ||
| 132 | + dateRangeType: 3, | ||
| 133 | + days: [day], | ||
| 134 | + previousDays: const [], | ||
| 135 | + sleepIntervals: [ | ||
| 136 | + HealthKitRawDataPoint( | ||
| 137 | + dataType: 3, | ||
| 138 | + startTime: LocalHealthDataConvert.unixSeconds(sleepStart), | ||
| 139 | + endTime: LocalHealthDataConvert.unixSeconds(sleepEnd), | ||
| 140 | + ), | ||
| 141 | + ], | ||
| 142 | + heartRate: const [], | ||
| 143 | + ); | ||
| 144 | + | ||
| 145 | + expect( | ||
| 146 | + statistics.sleepTrendList?.single.totalTime, | ||
| 147 | + const Duration(hours: 8, minutes: 25).inSeconds, | ||
| 148 | + ); | ||
| 149 | + expect( | ||
| 150 | + statistics.avgSleepDuration, | ||
| 151 | + const Duration(hours: 8, minutes: 25).inSeconds, | ||
| 152 | + ); | ||
| 153 | + }); | ||
| 154 | + | ||
| 155 | + test('sleep duration only counts explicit asleep sleep stages', () { | ||
| 156 | + final day = DateTime(2026, 7, 16); | ||
| 157 | + final start = DateTime(2026, 7, 16, 1); | ||
| 158 | + final statistics = LocalHealthDataConvert.sleepStatistics( | ||
| 159 | + dateRangeType: 3, | ||
| 160 | + days: [day], | ||
| 161 | + previousDays: const [], | ||
| 162 | + sleepIntervals: [ | ||
| 163 | + HealthKitRawDataPoint( | ||
| 164 | + dataType: 0, | ||
| 165 | + startTime: LocalHealthDataConvert.unixSeconds(start), | ||
| 166 | + endTime: LocalHealthDataConvert.unixSeconds( | ||
| 167 | + start.add(const Duration(minutes: 10)), | ||
| 168 | + ), | ||
| 169 | + ), | ||
| 170 | + HealthKitRawDataPoint( | ||
| 171 | + dataType: 1, // .asleep / .asleepUnspecified | ||
| 172 | + startTime: LocalHealthDataConvert.unixSeconds( | ||
| 173 | + start.add(const Duration(minutes: 10)), | ||
| 174 | + ), | ||
| 175 | + endTime: LocalHealthDataConvert.unixSeconds( | ||
| 176 | + start.add(const Duration(minutes: 20)), | ||
| 177 | + ), | ||
| 178 | + ), | ||
| 179 | + HealthKitRawDataPoint( | ||
| 180 | + dataType: 3, | ||
| 181 | + startTime: LocalHealthDataConvert.unixSeconds( | ||
| 182 | + start.add(const Duration(minutes: 20)), | ||
| 183 | + ), | ||
| 184 | + endTime: LocalHealthDataConvert.unixSeconds( | ||
| 185 | + start.add(const Duration(minutes: 30)), | ||
| 186 | + ), | ||
| 187 | + ), | ||
| 188 | + HealthKitRawDataPoint( | ||
| 189 | + dataType: 4, | ||
| 190 | + startTime: LocalHealthDataConvert.unixSeconds( | ||
| 191 | + start.add(const Duration(minutes: 30)), | ||
| 192 | + ), | ||
| 193 | + endTime: LocalHealthDataConvert.unixSeconds( | ||
| 194 | + start.add(const Duration(minutes: 40)), | ||
| 195 | + ), | ||
| 196 | + ), | ||
| 197 | + HealthKitRawDataPoint( | ||
| 198 | + dataType: 5, | ||
| 199 | + startTime: LocalHealthDataConvert.unixSeconds( | ||
| 200 | + start.add(const Duration(minutes: 40)), | ||
| 201 | + ), | ||
| 202 | + endTime: LocalHealthDataConvert.unixSeconds( | ||
| 203 | + start.add(const Duration(minutes: 50)), | ||
| 204 | + ), | ||
| 205 | + ), | ||
| 206 | + HealthKitRawDataPoint( | ||
| 207 | + dataType: 99, | ||
| 208 | + startTime: LocalHealthDataConvert.unixSeconds( | ||
| 209 | + start.add(const Duration(minutes: 50)), | ||
| 210 | + ), | ||
| 211 | + endTime: LocalHealthDataConvert.unixSeconds( | ||
| 212 | + start.add(const Duration(minutes: 60)), | ||
| 213 | + ), | ||
| 214 | + ), | ||
| 215 | + ], | ||
| 216 | + heartRate: const [], | ||
| 217 | + ); | ||
| 218 | + | ||
| 219 | + expect( | ||
| 220 | + statistics.sleepTrendList?.single.totalTime, | ||
| 221 | + const Duration(minutes: 40).inSeconds, | ||
| 222 | + ); | ||
| 223 | + }); | ||
| 84 | }); | 224 | }); |
| 85 | 225 | ||
| 86 | group('HealthRawDailyStressCalculator', () { | 226 | group('HealthRawDailyStressCalculator', () { |
| @@ -109,6 +249,7 @@ void main() { | @@ -109,6 +249,7 @@ void main() { | ||
| 109 | return HealthRawRealtimeStressPoint( | 249 | return HealthRawRealtimeStressPoint( |
| 110 | userId: userId, | 250 | userId: userId, |
| 111 | rawEndTime: dayStart + offset, | 251 | rawEndTime: dayStart + offset, |
| 252 | + rawHr: 70, | ||
| 112 | result: value, | 253 | result: value, |
| 113 | sourceStartTime: dayStart + offset, | 254 | sourceStartTime: dayStart + offset, |
| 114 | sourceEndTime: dayStart + offset, | 255 | sourceEndTime: dayStart + offset, |
| @@ -156,6 +297,7 @@ void main() { | @@ -156,6 +297,7 @@ void main() { | ||
| 156 | HealthRawRealtimeStressPoint( | 297 | HealthRawRealtimeStressPoint( |
| 157 | userId: userId, | 298 | userId: userId, |
| 158 | rawEndTime: dayStart - 300, | 299 | rawEndTime: dayStart - 300, |
| 300 | + rawHr: 80, | ||
| 159 | result: 80, | 301 | result: 80, |
| 160 | sourceStartTime: dayStart - 300, | 302 | sourceStartTime: dayStart - 300, |
| 161 | sourceEndTime: dayStart - 300, | 303 | sourceEndTime: dayStart - 300, |
| @@ -164,6 +306,7 @@ void main() { | @@ -164,6 +306,7 @@ void main() { | ||
| 164 | HealthRawRealtimeStressPoint( | 306 | HealthRawRealtimeStressPoint( |
| 165 | userId: userId, | 307 | userId: userId, |
| 166 | rawEndTime: dayStart + 100, | 308 | rawEndTime: dayStart + 100, |
| 309 | + rawHr: 90, | ||
| 167 | result: 90, | 310 | result: 90, |
| 168 | sourceStartTime: dayStart + 100, | 311 | sourceStartTime: dayStart + 100, |
| 169 | sourceEndTime: dayStart + 100, | 312 | sourceEndTime: dayStart + 100, |
| @@ -171,6 +314,7 @@ void main() { | @@ -171,6 +314,7 @@ void main() { | ||
| 171 | HealthRawRealtimeStressPoint( | 314 | HealthRawRealtimeStressPoint( |
| 172 | userId: userId, | 315 | userId: userId, |
| 173 | rawEndTime: dayStart + 1000, | 316 | rawEndTime: dayStart + 1000, |
| 317 | + rawHr: 20, | ||
| 174 | result: 20, | 318 | result: 20, |
| 175 | sourceStartTime: dayStart + 1000, | 319 | sourceStartTime: dayStart + 1000, |
| 176 | sourceEndTime: dayStart + 1000, | 320 | sourceEndTime: dayStart + 1000, |
-
Please register or login to post a comment