Commit a5aac34f1fa2ab79d5f4db54d28aa32ba949f961

Authored by 权海
1 parent 60476ea8

feat(ui):添加新数据表和数据上传

  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,