diff --git a/apps/ios/Vitals.xcodeproj/project.pbxproj b/apps/ios/Vitals.xcodeproj/project.pbxproj index a817859..c121bdd 100644 --- a/apps/ios/Vitals.xcodeproj/project.pbxproj +++ b/apps/ios/Vitals.xcodeproj/project.pbxproj @@ -786,7 +786,7 @@ GENERATE_INFOPLIST_FILE = "NO"; ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon"; MARKETING_VERSION = "0.1"; - CURRENT_PROJECT_VERSION = "10"; + CURRENT_PROJECT_VERSION = "12"; LD_RUNPATH_SEARCH_PATHS = ("$(inherited)", "@executable_path/Frameworks",); }; name = "Debug"; @@ -807,7 +807,7 @@ GENERATE_INFOPLIST_FILE = "NO"; ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon"; MARKETING_VERSION = "0.1"; - CURRENT_PROJECT_VERSION = "10"; + CURRENT_PROJECT_VERSION = "12"; LD_RUNPATH_SEARCH_PATHS = ("$(inherited)", "@executable_path/Frameworks",); }; name = "Release"; diff --git a/apps/ios/Vitals/Services/HelioSleepArchive.swift b/apps/ios/Vitals/Services/HelioSleepArchive.swift index a20e064..21fd82c 100644 --- a/apps/ios/Vitals/Services/HelioSleepArchive.swift +++ b/apps/ios/Vitals/Services/HelioSleepArchive.swift @@ -136,16 +136,13 @@ enum HelioTransferError: Error { func message(for type: ZeppFetchType) -> String { switch self { - case .unverified: return "the strap’s transfer couldn’t be verified. pull down to retry." - case .stageFormat: return "Motion couldn’t read this strap’s sleep-stage format." - case .sleepDates: return "the strap returned a sleep record with unexpected dates." - case .stageBounds: return "the strap returned stages outside its recorded sleep period." - case .stageOverlap: return "the strap returned overlapping sleep stages." - case .unknownStages: return "this sleep record has no stages Motion recognizes." + case .unverified: return "Motion couldn’t finish downloading from your strap. try syncing again." + case .stageFormat, .sleepDates, .stageBounds, .stageOverlap, .unknownStages: + return "Motion couldn’t read some sleep data from your strap. try syncing again." case .readingDates: - let metric = type == .activity ? "heart-rate" : "breathing" - return "the strap returned \(metric) readings with unexpected dates." - case .unsupportedRecord: return "Motion couldn’t read this type of strap record." + let metric = type == .activity ? "heart rate" : "breathing" + return "Motion couldn’t read some \(metric) data from your strap. try syncing again." + case .unsupportedRecord: return "Motion couldn’t read some data from your strap. try syncing again." } } } diff --git a/apps/ios/Vitals/Services/HelioSleepSync.swift b/apps/ios/Vitals/Services/HelioSleepSync.swift index 29238a8..1b91a03 100644 --- a/apps/ios/Vitals/Services/HelioSleepSync.swift +++ b/apps/ios/Vitals/Services/HelioSleepSync.swift @@ -78,6 +78,13 @@ import Observation func sync(force: Bool = false) { #if DEBUG let args = ProcessInfo.processInfo.arguments + if args.contains("-ui-testing"), args.contains("-ui-testing-sleep-sync-error") { + let issue = HelioTransferError.stageBounds(startOffset: -120, endOffset: -360, duration: 240) + status = "sync incomplete" + message = issue.message(for: .sleepSession) + diagnosticDetails = issue.diagnosticDetails + return // Synthetic failure without Bluetooth, Keychain or archive writes. + } if !force, args.contains("-ui-testing"), args.contains("-ui-testing-strap") { return } #endif // Files/Keychain can be unavailable during a Bluetooth relaunch before first unlock. diff --git a/apps/ios/Vitals/UI/SleepView.swift b/apps/ios/Vitals/UI/SleepView.swift index e1df3f8..2f74c29 100644 --- a/apps/ios/Vitals/UI/SleepView.swift +++ b/apps/ios/Vitals/UI/SleepView.swift @@ -10,6 +10,11 @@ struct SleepTab: View { @State private var period: SleepPeriod = .daily @State private var date = Calendar.current.startOfDay(for: Date.now) @State private var selectedTime: Date? + @State private var showingSyncIssue = false + private var syncIssue: String? { + let messages = [helio.message, health.message].compactMap { $0 } + return messages.isEmpty ? nil : messages.joined(separator: "\n\n") + } private var measurements: SleepMeasurements { SleepSummary.combined( helio: SleepMeasurements(sessions: helio.archive.sleepSessions(endingOn: date), @@ -42,6 +47,13 @@ struct SleepTab: View { ProgressView() Text(helio.status).font(VitalsStyle.caption).foregroundStyle(VitalsStyle.secondary) } + } else if syncIssue != nil { + Button { showingSyncIssue = true } label: { + Label("sync incomplete", systemImage: "exclamationmark.circle") + .font(VitalsStyle.caption) + } + .foregroundStyle(VitalsStyle.secondary) + .accessibilityIdentifier("sleep-sync-issue") } if period == .weekly, !week.isEmpty { weeklyCharts(data, week: week) @@ -65,17 +77,6 @@ struct SleepTab: View { } .accessibilityIdentifier("sleep-empty") } - if let message = helio.message { - Text(message).font(VitalsStyle.caption).foregroundStyle(VitalsStyle.caution) - .contextMenu { - if let details = helio.diagnosticDetails { - Button("copy sync details") { UIPasteboard.general.string = details } - } - } - } - if let message = health.message { - Text(message).font(VitalsStyle.caption).foregroundStyle(VitalsStyle.caution) - } } .padding(VitalsStyle.gutter) } @@ -97,6 +98,12 @@ struct SleepTab: View { }.quietBackground() } } + .alert("sync incomplete", isPresented: $showingSyncIssue) { + Button("try again") { Task { await reload(force: true) } } + Button("close", role: .cancel) {} + } message: { + Text(syncIssue ?? "try syncing again.") + } .task(id: date) { selectedTime = nil if health.usesTestMeasurements { requested = true } diff --git a/apps/ios/VitalsTests/HelioSleepTests.swift b/apps/ios/VitalsTests/HelioSleepTests.swift index c5cd915..63ffe6a 100644 --- a/apps/ios/VitalsTests/HelioSleepTests.swift +++ b/apps/ios/VitalsTests/HelioSleepTests.swift @@ -142,7 +142,7 @@ final class HelioSleepTests: XCTestCase { retry.device.fetchData[.sleepSession] = (start, outside + napBytes()) installReadings(retry) retry.run() - XCTAssertTrue(retry.sync.message?.contains("outside its recorded sleep period") == true) + XCTAssertEqual(retry.sync.message, "Motion couldn’t read some sleep data from your strap. try syncing again.") XCTAssertEqual(retry.sync.diagnosticDetails, "sleep stage bounds; start offset: -120 min; end offset: -360 min; stage duration: 240 min") XCTAssertEqual(retry.sync.archive.sessions.count, 2) @@ -195,19 +195,19 @@ final class HelioSleepTests: XCTestCase { @MainActor func testUnsupportedSleepPreservesNightAndCursorButStillFetchesOtherMetrics() throws { let directory = try directory(), baseline = rig(directory) installSleep(baseline); baseline.run() - var invalidCases: [(bytes: [UInt8], message: String)] = [] + var invalidCases: [(bytes: [UInt8], detail: String)] = [] var overflow = sleepBytes(); overflow[0x54] = 101 - invalidCases.append((overflow, "sleep-stage format")) - invalidCases.append((sleepBytes(invalid: true), "outside its recorded sleep period")) + invalidCases.append((overflow, "stageFormat")) + invalidCases.append((sleepBytes(invalid: true), "sleep stage bounds")) var overlap = sleepBytes() overlap.replaceSubrange(0x5b..<0x5d, with: le(1439, count: 2)) - invalidCases.append((overlap, "overlapping sleep stages")) + invalidCases.append((overlap, "stageOverlap")) var future = sleepBytes() future.replaceSubrange(0..<4, with: le(UInt32(Date.now.timeIntervalSince1970 + 86_400))) - invalidCases.append((future, "unexpected dates")) + invalidCases.append((future, "sleepDates")) var unknown = sleepBytes() for index in 0..<3 { unknown[0x5a + index * 5] = 0x02 } - invalidCases.append((unknown, "no stages Motion recognizes")) + invalidCases.append((unknown, "unknownStages")) for invalid in invalidCases { let broken = rig(directory) broken.device.fetchData[.sleepSession] = (ZeppFetchTimestamp.encode(midnight, timeZone: TimeZone(secondsFromGMT: 0)!), invalid.bytes) @@ -215,7 +215,8 @@ final class HelioSleepTests: XCTestCase { broken.run() XCTAssertFalse(broken.sync.busy) XCTAssertTrue(broken.sync.hasKey) - XCTAssertTrue(broken.sync.message?.contains(invalid.message) == true, broken.sync.message ?? "missing message") + XCTAssertEqual(broken.sync.message, "Motion couldn’t read some sleep data from your strap. try syncing again.") + XCTAssertTrue(broken.sync.diagnosticDetails?.contains(invalid.detail) == true, broken.sync.diagnosticDetails ?? "missing diagnostics") XCTAssertEqual(broken.sync.archive.sessions.flatMap(\.segments), baseline.sync.archive.sessions.flatMap(\.segments)) XCTAssertEqual(broken.sync.archive.cursors["72"], baseline.sync.archive.cursors["72"]) XCTAssertEqual(broken.sync.archive.lastSync, baseline.sync.archive.lastSync) diff --git a/apps/ios/VitalsUITests/WorkoutFlowUITests.swift b/apps/ios/VitalsUITests/WorkoutFlowUITests.swift index 6713ff3..9331767 100644 --- a/apps/ios/VitalsUITests/WorkoutFlowUITests.swift +++ b/apps/ios/VitalsUITests/WorkoutFlowUITests.swift @@ -62,6 +62,54 @@ final class WorkoutFlowUITests: XCTestCase { capture(app, "weekly schedule keeps gaps between periods") } + @MainActor func testSleepSyncIssueIsCompactAndKeepsChartsVisible() { + continueAfterFailure = false + let app = XCUIApplication() + app.launchArguments = ["-ui-testing", "-ui-testing-sleep", "-ui-testing-strap", "-ui-testing-sleep-sync-error"] + app.launch() + XCTAssertTrue(app.buttons["tab-sleep"].waitForExistence(timeout: 15)) + app.buttons["tab-sleep"].tap() + let issue = app.buttons["sleep-sync-issue"] + XCTAssertTrue(issue.waitForExistence(timeout: 5)) + XCTAssertEqual(app.staticTexts["sleep-duration"].label, "7h 42m") + XCTAssertLessThan(issue.frame.maxY, app.staticTexts["sleep-duration"].frame.minY) + let explanation = "Motion couldn’t read some sleep data from your strap. try syncing again." + XCTAssertFalse(app.staticTexts[explanation].isHittable, "the hidden settings page must not expose details on Sleep") + issue.tap() + let alert = app.alerts["sync incomplete"] + XCTAssertTrue(alert.waitForExistence(timeout: 5)) + XCTAssertTrue(alert.staticTexts[explanation].exists) + XCTAssertTrue(alert.buttons["try again"].exists) + capture(app, "plain sync explanation") + alert.buttons["close"].tap() + app.swipeUp() + XCTAssertFalse(app.staticTexts[explanation].isHittable) + XCTAssertFalse(app.staticTexts["the strap returned stages outside its recorded sleep period."].exists) + XCTAssertFalse(app.buttons["copy sync details"].exists) + capture(app, "sleep charts without error footer") + app.segmentedControls["sleep-period"].buttons["weekly"].tap() + XCTAssertTrue(issue.isHittable) + XCTAssertTrue(app.otherElements["sleep-weekly-duration-chart"].firstMatch.exists) + capture(app, "weekly sleep with compact sync status") + } + + @MainActor func testEmptySleepStillExplainsSyncFailureAndOffersRetry() { + continueAfterFailure = false + let app = XCUIApplication() + app.launchArguments = ["-ui-testing", "-ui-testing-sleep", "-ui-testing-sleep-empty", "-ui-testing-sleep-sync-error"] + app.launch() + XCTAssertTrue(app.buttons["tab-sleep"].waitForExistence(timeout: 15)) + app.buttons["tab-sleep"].tap() + XCTAssertTrue(app.staticTexts["no sleep measurements for this date"].waitForExistence(timeout: 5)) + app.buttons["sleep-sync-issue"].tap() + XCTAssertTrue(app.alerts["sync incomplete"].waitForExistence(timeout: 5)) + app.alerts["sync incomplete"].buttons["try again"].tap() + XCTAssertTrue(app.buttons["sleep-sync-issue"].waitForExistence(timeout: 5)) + XCTAssertTrue(app.staticTexts["no sleep measurements for this date"].exists) + XCTAssertFalse(app.staticTexts["sleep-duration"].exists) + capture(app, "empty sleep keeps actionable sync status") + } + @MainActor func testStrapSyncIsBesideSettingsAndSetupOnlyManagesKey() { continueAfterFailure = false let app = XCUIApplication() diff --git a/apps/ios/docs/helio-sleep.md b/apps/ios/docs/helio-sleep.md index 85e927b..335331f 100644 --- a/apps/ios/docs/helio-sleep.md +++ b/apps/ios/docs/helio-sleep.md @@ -78,7 +78,8 @@ that account's band keys and local history. Signing out preserves its isolated c stage intervals reject that record without advancing the sleep cursor. Motion then continues with independent heart-rate and breathing downloads. Content errors identify the reason separately from failures writing the local archive. - Long-press a sync error in Settings or Sleep to **copy sync details**. These + Sleep shows a compact **sync incomplete** status above the charts. Tap it for a plain-language explanation and retry; diagnostics are never printed below the charts. + Long-press a sync error in Settings to **copy sync details**. These diagnostics contain relative minute offsets only, with no key, packets, absolute timestamps, account identity or heart/breathing readings; nothing is uploaded. - Every fetch acknowledgement uses **keep on device** (`03 09`), including failures. diff --git a/apps/ios/scripts/generate-project.py b/apps/ios/scripts/generate-project.py index 8968be0..1f76d7c 100755 --- a/apps/ios/scripts/generate-project.py +++ b/apps/ios/scripts/generate-project.py @@ -13,7 +13,7 @@ # Keep the registered bundle identifier stable across the motion rebrand and TestFlight updates. BUNDLE_IDENTIFIER = 'dev.gtfol.vitals' MARKETING_VERSION = '0.1' -CURRENT_PROJECT_VERSION = '10' +CURRENT_PROJECT_VERSION = '12' objects = {} def uid(name): return hashlib.sha256(name.encode()).hexdigest()[:24].upper()