Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 44 additions & 128 deletions ios/Features/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,41 +6,35 @@
//

import SwiftUI
import HealthKit

struct ContentView: View {

// MARK: - UI state

@State private var viewModel = ContentViewModel()

// MARK: - Runtime control state

@State private var observersConfigured = false
@State private var pendingAutoSyncWorkItem: DispatchWorkItem?

// MARK: - View

var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 24) {
readinessCard
syncStatusView
}
.padding()
}
.navigationTitle("Today")
.onAppear {
viewModel.reloadSyncState()
viewModel.loadTodayReadiness()

if !observersConfigured {
HealthKitService.shared.enableObservers()
setupObservers()
observersConfigured = true
}

performInitialSync()
}
.onReceive(NotificationCenter.default.publisher(for: .syncStateDidChange)) { _ in
viewModel.reloadSyncState()
}
.onReceive(NotificationCenter.default.publisher(for: .autoSyncDidFinish)) { _ in
viewModel.reloadSyncState()
viewModel.loadTodayReadiness()
}
}
}
Expand Down Expand Up @@ -148,120 +142,10 @@ struct ContentView: View {
}
}

// MARK: - Auto sync / observers

/// Подписка на локальные уведомления, которые публикует HealthKitService
/// при обновлении соответствующих типов данных.
private func setupObservers() {
NotificationCenter.default.addObserver(
forName: .healthKitHRVUpdated,
object: nil,
queue: .main
) { _ in
triggerAutoSync(reason: "HRV updated")
}

NotificationCenter.default.addObserver(
forName: .healthKitRestingHRUpdated,
object: nil,
queue: .main
) { _ in
triggerAutoSync(reason: "Resting HR updated")
}

NotificationCenter.default.addObserver(
forName: .healthKitSleepUpdated,
object: nil,
queue: .main
) { _ in
triggerAutoSync(reason: "Sleep updated")
}
}

/// Debounce для auto sync:
/// если несколько сигналов приходят подряд, выполняем только один sync.
private func triggerAutoSync(reason: String) {
pendingAutoSyncWorkItem?.cancel()

let workItem = DispatchWorkItem {
guard !viewModel.isSyncInProgress else { return }

viewModel.statusMessage = "Auto sync triggered: \(reason)"
performIncrementalSync()
}

pendingAutoSyncWorkItem = workItem
viewModel.statusMessage = "Auto sync scheduled: \(reason)"

DispatchQueue.main.asyncAfter(deadline: .now() + 5, execute: workItem)
}

// MARK: - Sync actions

/// Incremental sync:
/// использует anchors и отправляет только delta payload.
private func performIncrementalSync() {
viewModel.performIncrementalSync { result in
switch result {
case .success(let data):
guard let payload = data.payload else {
return
}

viewModel.newHRVSamples = data.newHRVSamples
viewModel.newRestingHRSamples = data.newRestingHRSamples
viewModel.newSleepNightAggregates = data.newSleepNightAggregates

if !data.newHRVSamples.isEmpty {
viewModel.hrvSamples = data.newHRVSamples
}

if !data.newRestingHRSamples.isEmpty {
viewModel.restingHRSamples = data.newRestingHRSamples
}

if !data.newSleepNightAggregates.isEmpty {
viewModel.sleepNightAggregates = data.newSleepNightAggregates
}

viewModel.sendPayload(payload, mode: .incremental) {
viewModel.isSyncInProgress = false
viewModel.loadTodayReadiness()
}

case .failure:
break
}
}
}

private func performInitialSync() {
viewModel.performInitialSyncIfNeeded { result in
switch result {
case .success(let data):
guard let payload = data.payload else { return }

if !data.newHRVSamples.isEmpty {
viewModel.hrvSamples = data.newHRVSamples
}

if !data.newRestingHRSamples.isEmpty {
viewModel.restingHRSamples = data.newRestingHRSamples
}

if !data.newSleepNightAggregates.isEmpty {
viewModel.sleepNightAggregates = data.newSleepNightAggregates
}

viewModel.sendPayload(payload, mode: .incremental) {
viewModel.isSyncInProgress = false
viewModel.loadTodayReadiness()
}

case .failure:
break
}
}
private var syncStatusView: some View {
Text(syncStatusText)
.font(.caption)
.foregroundStyle(.secondary)
}

// MARK: - Reusable blocks
Expand Down Expand Up @@ -318,6 +202,38 @@ struct ContentView: View {
return String(format: "%.1f", value)
}

private var syncStatusText: String {
if viewModel.syncState.lastErrorMessage != nil {
return "Sync failed, will retry"
}

guard let lastSuccessfulSyncAt = viewModel.syncState.lastSuccessfulSyncAt else {
return "No data yet"
}

return "Updated \(relativeTimeString(from: lastSuccessfulSyncAt))"
}

private func relativeTimeString(from date: Date) -> String {
let seconds = Int(Date().timeIntervalSince(date))

if seconds < 60 {
return "just now"
}

let minutes = seconds / 60
if minutes < 60 {
return minutes == 1 ? "1 min ago" : "\(minutes) min ago"
}

let hours = minutes / 60
if hours < 24 {
return hours == 1 ? "1 h ago" : "\(hours) h ago"
}

return DateFormatters.shortDateTime(date)
}

private func readinessColor(_ score: Double?) -> Color {
guard let score else { return .gray }

Expand Down
2 changes: 2 additions & 0 deletions ios/Support/Notifications.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,6 @@ extension Notification.Name {
static let healthKitHRVUpdated = Notification.Name("healthKitHRVUpdated")
static let healthKitRestingHRUpdated = Notification.Name("healthKitRestingHRUpdated")
static let healthKitSleepUpdated = Notification.Name("healthKitSleepUpdated")
static let autoSyncDidFinish = Notification.Name("autoSyncDidFinish")
static let syncStateDidChange = Notification.Name("syncStateDidChange")
}
Loading