iOS 1.2.1 - Better auto sync and sync indicator

This commit is contained in:
2025-09-29 13:47:50 -06:00
parent 2160ce30bd
commit 8fbb40d453
14 changed files with 177 additions and 212 deletions

View File

@@ -1,6 +1,5 @@
import Combine import Combine
import Foundation import Foundation
import UIKit
@MainActor @MainActor
class SyncService: ObservableObject { class SyncService: ObservableObject {
@@ -455,7 +454,7 @@ class SyncService: ObservableObject {
let zipData = try createMinimalZipFromBackup(updatedBackup) let zipData = try createMinimalZipFromBackup(updatedBackup)
// Use existing import method which properly handles data restoration // Use existing import method which properly handles data restoration
try dataManager.importData(from: zipData) try dataManager.importData(from: zipData, showSuccessMessage: false)
// Update local data state to match imported data timestamp // Update local data state to match imported data timestamp
DataStateManager.shared.setLastModified(backup.exportedAt) DataStateManager.shared.setLastModified(backup.exportedAt)
@@ -735,180 +734,29 @@ class SyncService: ObservableObject {
} }
func triggerAutoSync(dataManager: ClimbingDataManager) { func triggerAutoSync(dataManager: ClimbingDataManager) {
guard isConnected && isConfigured && isAutoSyncEnabled else { return } // Early exit if sync cannot proceed - don't set isSyncing
guard isConnected && isConfigured && isAutoSyncEnabled else {
// Ensure isSyncing is false when sync is not possible
if isSyncing {
isSyncing = false
}
return
}
// Prevent multiple simultaneous syncs
guard !isSyncing else {
return
}
Task { Task {
do { do {
try await syncWithServer(dataManager: dataManager) try await syncWithServer(dataManager: dataManager)
} catch { } catch {
print("Auto-sync failed: \(error)") await MainActor.run {
// Don't show UI errors for auto-sync failures self.isSyncing = false
} }
} }
} }
// DEPRECATED: Complex merge logic replaced with simple timestamp-based sync
// These methods are no longer used but kept for reference
@available(*, deprecated, message: "Use simple timestamp-based sync instead")
private func performIntelligentMerge(local: ClimbDataBackup, server: ClimbDataBackup) throws
-> ClimbDataBackup
{
print("Merging data - preserving all entities to prevent data loss")
// Merge gyms by ID, keeping most recently updated
let mergedGyms = mergeGyms(local: local.gyms, server: server.gyms)
// Merge problems by ID, keeping most recently updated
let mergedProblems = mergeProblems(local: local.problems, server: server.problems)
// Merge sessions by ID, keeping most recently updated
let mergedSessions = mergeSessions(local: local.sessions, server: server.sessions)
// Merge attempts by ID, keeping most recently updated
let mergedAttempts = mergeAttempts(local: local.attempts, server: server.attempts)
print(
"Merge results: gyms=\(mergedGyms.count), problems=\(mergedProblems.count), sessions=\(mergedSessions.count), attempts=\(mergedAttempts.count)"
)
return ClimbDataBackup(
exportedAt: ISO8601DateFormatter().string(from: Date()),
version: "2.0",
formatVersion: "2.0",
gyms: mergedGyms,
problems: mergedProblems,
sessions: mergedSessions,
attempts: mergedAttempts
)
}
private func mergeGyms(local: [BackupGym], server: [BackupGym]) -> [BackupGym] {
var merged: [String: BackupGym] = [:]
// Add all local gyms
for gym in local {
merged[gym.id] = gym
}
// Add server gyms, replacing if newer
for serverGym in server {
if let localGym = merged[serverGym.id] {
// Keep the most recently updated
if isNewerThan(serverGym.updatedAt, localGym.updatedAt) {
merged[serverGym.id] = serverGym
}
} else {
// New gym from server
merged[serverGym.id] = serverGym
}
}
return Array(merged.values)
}
private func mergeProblems(local: [BackupProblem], server: [BackupProblem]) -> [BackupProblem] {
var merged: [String: BackupProblem] = [:]
// Add all local problems
for problem in local {
merged[problem.id] = problem
}
// Add server problems, replacing if newer or merging image paths
for serverProblem in server {
if let localProblem = merged[serverProblem.id] {
// Merge image paths from both sources
let localImages = Set(localProblem.imagePaths ?? [])
let serverImages = Set(serverProblem.imagePaths ?? [])
let mergedImages = Array(localImages.union(serverImages))
// Use most recently updated problem data but with merged images
let newerProblem =
isNewerThan(serverProblem.updatedAt, localProblem.updatedAt)
? serverProblem : localProblem
merged[serverProblem.id] = BackupProblem(
id: newerProblem.id,
gymId: newerProblem.gymId,
name: newerProblem.name,
description: newerProblem.description,
climbType: newerProblem.climbType,
difficulty: newerProblem.difficulty,
tags: newerProblem.tags,
location: newerProblem.location,
imagePaths: mergedImages.isEmpty ? nil : mergedImages,
isActive: newerProblem.isActive,
dateSet: newerProblem.dateSet,
notes: newerProblem.notes,
createdAt: newerProblem.createdAt,
updatedAt: newerProblem.updatedAt
)
} else {
// New problem from server
merged[serverProblem.id] = serverProblem
}
}
return Array(merged.values)
}
private func mergeSessions(local: [BackupClimbSession], server: [BackupClimbSession])
-> [BackupClimbSession]
{
var merged: [String: BackupClimbSession] = [:]
// Add all local sessions
for session in local {
merged[session.id] = session
}
// Add server sessions, replacing if newer
for serverSession in server {
if let localSession = merged[serverSession.id] {
// Keep the most recently updated
if isNewerThan(serverSession.updatedAt, localSession.updatedAt) {
merged[serverSession.id] = serverSession
}
} else {
// New session from server
merged[serverSession.id] = serverSession
}
}
return Array(merged.values)
}
private func mergeAttempts(local: [BackupAttempt], server: [BackupAttempt]) -> [BackupAttempt] {
var merged: [String: BackupAttempt] = [:]
// Add all local attempts
for attempt in local {
merged[attempt.id] = attempt
}
// Add server attempts, replacing if newer
for serverAttempt in server {
if let localAttempt = merged[serverAttempt.id] {
// Keep the most recently created (attempts don't typically get updated)
if isNewerThan(serverAttempt.createdAt, localAttempt.createdAt) {
merged[serverAttempt.id] = serverAttempt
}
} else {
// New attempt from server
merged[serverAttempt.id] = serverAttempt
}
}
return Array(merged.values)
}
private func isNewerThan(_ dateString1: String, _ dateString2: String) -> Bool {
let formatter = ISO8601DateFormatter()
guard let date1 = formatter.date(from: dateString1),
let date2 = formatter.date(from: dateString2)
else {
return false
}
return date1 > date2
} }
func disconnect() { func disconnect() {
@@ -931,8 +779,6 @@ class SyncService: ObservableObject {
} }
} }
// Removed SyncTrigger enum - now using simple auto sync on any data change
enum SyncError: LocalizedError { enum SyncError: LocalizedError {
case notConfigured case notConfigured
case notConnected case notConnected

View File

@@ -32,6 +32,9 @@ class ClimbingDataManager: ObservableObject {
// Sync service for automatic syncing // Sync service for automatic syncing
let syncService = SyncService() let syncService = SyncService()
// Published property to propagate sync state changes
@Published var isSyncing = false
private enum Keys { private enum Keys {
static let gyms = "openclimb_gyms" static let gyms = "openclimb_gyms"
static let problems = "openclimb_problems" static let problems = "openclimb_problems"
@@ -67,6 +70,10 @@ class ClimbingDataManager: ObservableObject {
migrateImagePaths() migrateImagePaths()
setupLiveActivityNotifications() setupLiveActivityNotifications()
// Keep our published isSyncing in sync with syncService.isSyncing
syncService.$isSyncing
.assign(to: &$isSyncing)
Task { Task {
try? await Task.sleep(nanoseconds: 2_000_000_000) try? await Task.sleep(nanoseconds: 2_000_000_000)
await performImageMaintenance() await performImageMaintenance()
@@ -206,6 +213,9 @@ class ClimbingDataManager: ObservableObject {
DataStateManager.shared.updateDataState() DataStateManager.shared.updateDataState()
successMessage = "Gym added successfully" successMessage = "Gym added successfully"
clearMessageAfterDelay() clearMessageAfterDelay()
// Trigger auto-sync if enabled
syncService.triggerAutoSync(dataManager: self)
} }
func updateGym(_ gym: Gym) { func updateGym(_ gym: Gym) {
@@ -215,6 +225,9 @@ class ClimbingDataManager: ObservableObject {
DataStateManager.shared.updateDataState() DataStateManager.shared.updateDataState()
successMessage = "Gym updated successfully" successMessage = "Gym updated successfully"
clearMessageAfterDelay() clearMessageAfterDelay()
// Trigger auto-sync if enabled
syncService.triggerAutoSync(dataManager: self)
} }
} }
@@ -237,6 +250,9 @@ class ClimbingDataManager: ObservableObject {
DataStateManager.shared.updateDataState() DataStateManager.shared.updateDataState()
successMessage = "Gym deleted successfully" successMessage = "Gym deleted successfully"
clearMessageAfterDelay() clearMessageAfterDelay()
// Trigger auto-sync if enabled
syncService.triggerAutoSync(dataManager: self)
} }
func gym(withId id: UUID) -> Gym? { func gym(withId id: UUID) -> Gym? {
@@ -261,6 +277,9 @@ class ClimbingDataManager: ObservableObject {
DataStateManager.shared.updateDataState() DataStateManager.shared.updateDataState()
successMessage = "Problem updated successfully" successMessage = "Problem updated successfully"
clearMessageAfterDelay() clearMessageAfterDelay()
// Trigger auto-sync if enabled
syncService.triggerAutoSync(dataManager: self)
} }
} }
@@ -276,6 +295,9 @@ class ClimbingDataManager: ObservableObject {
problems.removeAll { $0.id == problem.id } problems.removeAll { $0.id == problem.id }
saveProblems() saveProblems()
DataStateManager.shared.updateDataState() DataStateManager.shared.updateDataState()
// Trigger auto-sync if enabled
syncService.triggerAutoSync(dataManager: self)
} }
func problem(withId id: UUID) -> Problem? { func problem(withId id: UUID) -> Problem? {
@@ -291,7 +313,7 @@ class ClimbingDataManager: ObservableObject {
} }
func startSession(gymId: UUID, notes: String? = nil) { func startSession(gymId: UUID, notes: String? = nil) {
// End any currently active session
if let currentActive = activeSession { if let currentActive = activeSession {
endSession(currentActive.id) endSession(currentActive.id)
} }
@@ -314,6 +336,9 @@ class ClimbingDataManager: ObservableObject {
for: newSession, gymName: gym.name) for: newSession, gymName: gym.name)
} }
} }
// Trigger auto-sync if enabled
syncService.triggerAutoSync(dataManager: self)
} }
func endSession(_ sessionId: UUID) { func endSession(_ sessionId: UUID) {
@@ -358,8 +383,11 @@ class ClimbingDataManager: ObservableObject {
successMessage = "Session updated successfully" successMessage = "Session updated successfully"
clearMessageAfterDelay() clearMessageAfterDelay()
// Update Live Activity when session updates // Update Live Activity when session is updated
updateLiveActivityForActiveSession() updateLiveActivityForActiveSession()
// Trigger auto-sync if enabled
syncService.triggerAutoSync(dataManager: self)
} }
} }
@@ -368,7 +396,7 @@ class ClimbingDataManager: ObservableObject {
attempts.removeAll { $0.sessionId == session.id } attempts.removeAll { $0.sessionId == session.id }
saveAttempts() saveAttempts()
// Remove from active session if it's the current one // If this is the active session, clear it
if activeSession?.id == session.id { if activeSession?.id == session.id {
activeSession = nil activeSession = nil
saveActiveSession() saveActiveSession()
@@ -380,6 +408,12 @@ class ClimbingDataManager: ObservableObject {
DataStateManager.shared.updateDataState() DataStateManager.shared.updateDataState()
successMessage = "Session deleted successfully" successMessage = "Session deleted successfully"
clearMessageAfterDelay() clearMessageAfterDelay()
// Update Live Activity when session is deleted
updateLiveActivityForActiveSession()
// Trigger auto-sync if enabled
syncService.triggerAutoSync(dataManager: self)
} }
func session(withId id: UUID) -> ClimbSession? { func session(withId id: UUID) -> ClimbSession? {
@@ -421,6 +455,9 @@ class ClimbingDataManager: ObservableObject {
// Update Live Activity when attempt is updated // Update Live Activity when attempt is updated
updateLiveActivityForActiveSession() updateLiveActivityForActiveSession()
// Trigger auto-sync if enabled
syncService.triggerAutoSync(dataManager: self)
} }
} }
@@ -433,6 +470,9 @@ class ClimbingDataManager: ObservableObject {
// Update Live Activity when attempt is deleted // Update Live Activity when attempt is deleted
updateLiveActivityForActiveSession() updateLiveActivityForActiveSession()
// Trigger auto-sync if enabled
syncService.triggerAutoSync(dataManager: self)
} }
func attempts(forSession sessionId: UUID) -> [Attempt] { func attempts(forSession sessionId: UUID) -> [Attempt] {
@@ -476,7 +516,7 @@ class ClimbingDataManager: ObservableObject {
return gym(withId: mostUsedGymId) return gym(withId: mostUsedGymId)
} }
func resetAllData() { func resetAllData(showSuccessMessage: Bool = true) {
gyms.removeAll() gyms.removeAll()
problems.removeAll() problems.removeAll()
sessions.removeAll() sessions.removeAll()
@@ -490,9 +530,12 @@ class ClimbingDataManager: ObservableObject {
userDefaults.removeObject(forKey: Keys.activeSession) userDefaults.removeObject(forKey: Keys.activeSession)
DataStateManager.shared.reset() DataStateManager.shared.reset()
if showSuccessMessage {
successMessage = "All data has been reset" successMessage = "All data has been reset"
clearMessageAfterDelay() clearMessageAfterDelay()
} }
}
func exportData() -> Data? { func exportData() -> Data? {
do { do {
@@ -530,7 +573,7 @@ class ClimbingDataManager: ObservableObject {
} }
} }
func importData(from data: Data) throws { func importData(from data: Data, showSuccessMessage: Bool = true) throws {
do { do {
let importResult = try ZipUtils.extractImportZip(data: data) let importResult = try ZipUtils.extractImportZip(data: data)
@@ -566,7 +609,7 @@ class ClimbingDataManager: ObservableObject {
try validateImportData(importData) try validateImportData(importData)
resetAllData() resetAllData(showSuccessMessage: showSuccessMessage)
let updatedProblems = updateProblemImagePaths( let updatedProblems = updateProblemImagePaths(
problems: importData.problems, problems: importData.problems,
@@ -586,9 +629,11 @@ class ClimbingDataManager: ObservableObject {
// Update data state to current time since we just imported new data // Update data state to current time since we just imported new data
DataStateManager.shared.updateDataState() DataStateManager.shared.updateDataState()
if showSuccessMessage {
successMessage = successMessage =
"Data imported successfully with \(importResult.imagePathMapping.count) images" "Data imported successfully with \(importResult.imagePathMapping.count) images"
clearMessageAfterDelay() clearMessageAfterDelay()
}
} catch { } catch {
setError("Import failed: \(error.localizedDescription)") setError("Import failed: \(error.localizedDescription)")
throw error throw error

View File

@@ -20,6 +20,27 @@ struct AnalyticsView: View {
.padding() .padding()
} }
.navigationTitle("Analytics") .navigationTitle("Analytics")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
if dataManager.isSyncing {
HStack(spacing: 2) {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .blue))
.scaleEffect(0.6)
}
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(
Circle()
.fill(.regularMaterial)
)
.transition(.scale.combined(with: .opacity))
.animation(
.easeInOut(duration: 0.2), value: dataManager.isSyncing
)
}
}
}
} }
} }
} }

View File

@@ -15,7 +15,25 @@ struct GymsView: View {
} }
.navigationTitle("Gyms") .navigationTitle("Gyms")
.toolbar { .toolbar {
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItemGroup(placement: .navigationBarTrailing) {
if dataManager.isSyncing {
HStack(spacing: 2) {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .blue))
.scaleEffect(0.6)
}
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(
Circle()
.fill(.regularMaterial)
)
.transition(.scale.combined(with: .opacity))
.animation(
.easeInOut(duration: 0.2), value: dataManager.isSyncing
)
}
Button("Add") { Button("Add") {
showingAddGym = true showingAddGym = true
} }

View File

@@ -1,9 +1,5 @@
// //
// LiveActivityDebugView.swift // LiveActivityDebugView.swift
// OpenClimb
//
// Created by Assistant on 2025-09-15.
//
import SwiftUI import SwiftUI

View File

@@ -62,7 +62,25 @@ struct ProblemsView: View {
.navigationTitle("Problems") .navigationTitle("Problems")
.searchable(text: $searchText, prompt: "Search problems...") .searchable(text: $searchText, prompt: "Search problems...")
.toolbar { .toolbar {
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItemGroup(placement: .navigationBarTrailing) {
if dataManager.isSyncing {
HStack(spacing: 2) {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .blue))
.scaleEffect(0.6)
}
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(
Circle()
.fill(.regularMaterial)
)
.transition(.scale.combined(with: .opacity))
.animation(
.easeInOut(duration: 0.2), value: dataManager.isSyncing
)
}
if !dataManager.gyms.isEmpty { if !dataManager.gyms.isEmpty {
Button("Add") { Button("Add") {
showingAddProblem = true showingAddProblem = true

View File

@@ -17,7 +17,25 @@ struct SessionsView: View {
.navigationTitle("Sessions") .navigationTitle("Sessions")
.navigationBarTitleDisplayMode(.automatic) .navigationBarTitleDisplayMode(.automatic)
.toolbar { .toolbar {
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItemGroup(placement: .navigationBarTrailing) {
if dataManager.isSyncing {
HStack(spacing: 2) {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .blue))
.scaleEffect(0.6)
}
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(
Circle()
.fill(.regularMaterial)
)
.transition(.scale.combined(with: .opacity))
.animation(
.easeInOut(duration: 0.2), value: dataManager.isSyncing
)
}
if dataManager.gyms.isEmpty { if dataManager.gyms.isEmpty {
EmptyView() EmptyView()
} else if dataManager.activeSession == nil { } else if dataManager.activeSession == nil {

View File

@@ -22,6 +22,27 @@ struct SettingsView: View {
AppInfoSection() AppInfoSection()
} }
.navigationTitle("Settings") .navigationTitle("Settings")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
if dataManager.isSyncing {
HStack(spacing: 2) {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .blue))
.scaleEffect(0.6)
}
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(
Circle()
.fill(.regularMaterial)
)
.transition(.scale.combined(with: .opacity))
.animation(
.easeInOut(duration: 0.2), value: dataManager.isSyncing
)
}
}
}
.sheet( .sheet(
item: Binding<SheetType?>( item: Binding<SheetType?>(
get: { activeSheet }, get: { activeSheet },
@@ -436,6 +457,7 @@ struct SyncSection: View {
.foregroundColor(.red) .foregroundColor(.red)
.padding(.leading, 24) .padding(.leading, 24)
} }
} }
} }
.sheet(isPresented: $showingSyncSettings) { .sheet(isPresented: $showingSyncSettings) {

View File

@@ -1,12 +1,8 @@
// //
// AppIntent.swift // AppIntent.swift
// SessionStatusLive
//
// Created by Atridad Lahiji on 2025-09-15.
//
import WidgetKit
import AppIntents import AppIntents
import WidgetKit
struct ConfigurationAppIntent: WidgetConfigurationIntent { struct ConfigurationAppIntent: WidgetConfigurationIntent {
static var title: LocalizedStringResource { "Configuration" } static var title: LocalizedStringResource { "Configuration" }

View File

@@ -1,9 +1,5 @@
// //
// SessionStatusLive.swift // SessionStatusLive.swift
// SessionStatusLive
//
// Created by Atridad Lahiji on 2025-09-15.
//
import SwiftUI import SwiftUI
import WidgetKit import WidgetKit

View File

@@ -1,12 +1,8 @@
// //
// SessionStatusLiveBundle.swift // SessionStatusLiveBundle.swift
// SessionStatusLive
//
// Created by Atridad Lahiji on 2025-09-15.
//
import WidgetKit
import SwiftUI import SwiftUI
import WidgetKit
@main @main
struct SessionStatusLiveBundle: WidgetBundle { struct SessionStatusLiveBundle: WidgetBundle {

View File

@@ -1,9 +1,5 @@
// //
// SessionStatusLiveControl.swift // SessionStatusLiveControl.swift
// SessionStatusLive
//
// Created by Atridad Lahiji on 2025-09-15.
//
import AppIntents import AppIntents
import SwiftUI import SwiftUI
@@ -43,7 +39,8 @@ extension SessionStatusLiveControl {
func currentValue(configuration: TimerConfiguration) async throws -> Value { func currentValue(configuration: TimerConfiguration) async throws -> Value {
let isRunning = true // Check if the timer is running let isRunning = true // Check if the timer is running
return SessionStatusLiveControl.Value(isRunning: isRunning, name: configuration.timerName) return SessionStatusLiveControl.Value(
isRunning: isRunning, name: configuration.timerName)
} }
} }
} }

View File

@@ -1,9 +1,5 @@
// //
// SessionStatusLiveLiveActivity.swift // SessionStatusLiveLiveActivity.swift
// SessionStatusLive
//
// Created by Atridad Lahiji on 2025-09-15.
//
import ActivityKit import ActivityKit
import SwiftUI import SwiftUI