Sync Server DONE!

This commit is contained in:
2025-09-28 23:12:46 -06:00
parent 036becb5be
commit 6e490d1598
41 changed files with 5684 additions and 551 deletions

View File

@@ -57,6 +57,8 @@ struct ContentView: View {
}
.onAppear {
setupNotificationObservers()
// Trigger auto-sync on app launch
dataManager.syncService.triggerAutoSync(dataManager: dataManager)
}
.onDisappear {
removeNotificationObservers()
@@ -101,6 +103,8 @@ struct ContentView: View {
Task {
try? await Task.sleep(nanoseconds: 300_000_000) // 0.3 seconds
await dataManager.onAppBecomeActive()
// Trigger auto-sync when app becomes active
dataManager.syncService.triggerAutoSync(dataManager: dataManager)
}
}

View File

@@ -1,10 +1,5 @@
//
// BackupFormat.swift
// OpenClimb
//
// Created by OpenClimb Team on 2024-12-19.
// Copyright © 2024 OpenClimb. All rights reserved.
//
import Foundation

View File

@@ -0,0 +1,978 @@
import Combine
import Foundation
import UIKit
@MainActor
class SyncService: ObservableObject {
@Published var isSyncing = false
@Published var lastSyncTime: Date?
@Published var syncError: String?
@Published var isConnected = false
@Published var isTesting = false
private let userDefaults = UserDefaults.standard
private enum Keys {
static let serverURL = "sync_server_url"
static let authToken = "sync_auth_token"
static let lastSyncTime = "last_sync_time"
static let isConnected = "sync_is_connected"
static let autoSyncEnabled = "auto_sync_enabled"
}
var serverURL: String {
get { userDefaults.string(forKey: Keys.serverURL) ?? "" }
set { userDefaults.set(newValue, forKey: Keys.serverURL) }
}
var authToken: String {
get { userDefaults.string(forKey: Keys.authToken) ?? "" }
set { userDefaults.set(newValue, forKey: Keys.authToken) }
}
var isConfigured: Bool {
return !serverURL.isEmpty && !authToken.isEmpty
}
var isAutoSyncEnabled: Bool {
get { userDefaults.bool(forKey: Keys.autoSyncEnabled) }
set { userDefaults.set(newValue, forKey: Keys.autoSyncEnabled) }
}
init() {
if let lastSync = userDefaults.object(forKey: Keys.lastSyncTime) as? Date {
self.lastSyncTime = lastSync
}
self.isConnected = userDefaults.bool(forKey: Keys.isConnected)
}
func downloadData() async throws -> ClimbDataBackup {
guard isConfigured else {
throw SyncError.notConfigured
}
guard let url = URL(string: "\(serverURL)/sync") else {
throw SyncError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Accept")
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw SyncError.invalidResponse
}
switch httpResponse.statusCode {
case 200:
break
case 401:
throw SyncError.unauthorized
default:
throw SyncError.serverError(httpResponse.statusCode)
}
do {
let backup = try JSONDecoder().decode(ClimbDataBackup.self, from: data)
return backup
} catch {
throw SyncError.decodingError(error)
}
}
func uploadData(_ backup: ClimbDataBackup) async throws -> ClimbDataBackup {
guard isConfigured else {
throw SyncError.notConfigured
}
guard let url = URL(string: "\(serverURL)/sync") else {
throw SyncError.invalidURL
}
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
let jsonData = try encoder.encode(backup)
var request = URLRequest(url: url)
request.httpMethod = "PUT"
request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.httpBody = jsonData
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw SyncError.invalidResponse
}
switch httpResponse.statusCode {
case 200:
break
case 401:
throw SyncError.unauthorized
case 400:
throw SyncError.badRequest
default:
throw SyncError.serverError(httpResponse.statusCode)
}
do {
let responseBackup = try JSONDecoder().decode(ClimbDataBackup.self, from: data)
return responseBackup
} catch {
throw SyncError.decodingError(error)
}
}
func uploadImage(filename: String, imageData: Data) async throws {
guard isConfigured else {
throw SyncError.notConfigured
}
guard let url = URL(string: "\(serverURL)/images/upload?filename=\(filename)") else {
throw SyncError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization")
request.setValue("application/octet-stream", forHTTPHeaderField: "Content-Type")
request.httpBody = imageData
let (_, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw SyncError.invalidResponse
}
switch httpResponse.statusCode {
case 200:
break
case 401:
throw SyncError.unauthorized
default:
throw SyncError.serverError(httpResponse.statusCode)
}
}
func downloadImage(filename: String) async throws -> Data {
guard isConfigured else {
throw SyncError.notConfigured
}
guard let url = URL(string: "\(serverURL)/images/download?filename=\(filename)") else {
throw SyncError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization")
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw SyncError.invalidResponse
}
switch httpResponse.statusCode {
case 200:
return data
case 401:
throw SyncError.unauthorized
case 404:
throw SyncError.imageNotFound
default:
throw SyncError.serverError(httpResponse.statusCode)
}
}
func syncWithServer(dataManager: ClimbingDataManager) async throws {
guard isConfigured else {
throw SyncError.notConfigured
}
guard isConnected else {
throw SyncError.notConnected
}
isSyncing = true
syncError = nil
defer {
isSyncing = false
}
do {
// Get local backup data
let localBackup = createBackupFromDataManager(dataManager)
// Download server data
let serverBackup = try await downloadData()
// Check if we have any local data
let hasLocalData =
!dataManager.gyms.isEmpty || !dataManager.problems.isEmpty
|| !dataManager.sessions.isEmpty || !dataManager.attempts.isEmpty
let hasServerData =
!serverBackup.gyms.isEmpty || !serverBackup.problems.isEmpty
|| !serverBackup.sessions.isEmpty || !serverBackup.attempts.isEmpty
if !hasLocalData && hasServerData {
// Case 1: No local data - do full restore from server
print("🔄 iOS SYNC: Case 1 - No local data, performing full restore from server")
print("Syncing images from server first...")
let imagePathMapping = try await syncImagesFromServer(
backup: serverBackup, dataManager: dataManager)
print("Importing data after images...")
try importBackupToDataManager(
serverBackup, dataManager: dataManager, imagePathMapping: imagePathMapping)
print("Full restore completed")
} else if hasLocalData && !hasServerData {
// Case 2: No server data - upload local data to server
print("🔄 iOS SYNC: Case 2 - No server data, uploading local data to server")
let currentBackup = createBackupFromDataManager(dataManager)
_ = try await uploadData(currentBackup)
print("Uploading local images to server...")
try await syncImagesToServer(dataManager: dataManager)
print("Initial upload completed")
} else if hasLocalData && hasServerData {
// Case 3: Both have data - compare timestamps (last writer wins)
let localTimestamp = parseISO8601ToMillis(timestamp: localBackup.exportedAt)
let serverTimestamp = parseISO8601ToMillis(timestamp: serverBackup.exportedAt)
print("🕐 DEBUG iOS Timestamp Comparison:")
print(" Local exportedAt: '\(localBackup.exportedAt)' -> \(localTimestamp)")
print(" Server exportedAt: '\(serverBackup.exportedAt)' -> \(serverTimestamp)")
print(
" DataStateManager last modified: '\(DataStateManager.shared.getLastModified())'"
)
print(" Comparison result: local=\(localTimestamp), server=\(serverTimestamp)")
if localTimestamp > serverTimestamp {
// Local is newer - replace server with local data
print("🔄 iOS SYNC: Case 3a - Local data is newer, replacing server content")
let currentBackup = createBackupFromDataManager(dataManager)
_ = try await uploadData(currentBackup)
try await syncImagesToServer(dataManager: dataManager)
print("Server replaced with local data")
} else if serverTimestamp > localTimestamp {
// Server is newer - replace local with server data
print("🔄 iOS SYNC: Case 3b - Server data is newer, replacing local content")
let imagePathMapping = try await syncImagesFromServer(
backup: serverBackup, dataManager: dataManager)
try importBackupToDataManager(
serverBackup, dataManager: dataManager, imagePathMapping: imagePathMapping)
print("Local data replaced with server data")
} else {
// Timestamps are equal - no sync needed
print(
"🔄 iOS SYNC: Case 3c - Data is in sync (timestamps equal), no action needed"
)
}
} else {
print("No data to sync")
}
// Update last sync time
lastSyncTime = Date()
userDefaults.set(lastSyncTime, forKey: Keys.lastSyncTime)
} catch {
syncError = error.localizedDescription
throw error
}
}
/// Parses ISO8601 timestamp to milliseconds for comparison
private func parseISO8601ToMillis(timestamp: String) -> Int64 {
let formatter = ISO8601DateFormatter()
if let date = formatter.date(from: timestamp) {
return Int64(date.timeIntervalSince1970 * 1000)
}
print("Failed to parse timestamp: \(timestamp), using 0")
return 0
}
private func syncImagesFromServer(backup: ClimbDataBackup, dataManager: ClimbingDataManager)
async throws -> [String: String]
{
var imagePathMapping: [String: String] = [:]
// Process images by problem to maintain consistent naming
for problem in backup.problems {
guard let imagePaths = problem.imagePaths, !imagePaths.isEmpty else { continue }
for (index, imagePath) in imagePaths.enumerated() {
let serverFilename = URL(fileURLWithPath: imagePath).lastPathComponent
do {
let imageData = try await downloadImage(filename: serverFilename)
// Generate consistent filename if needed
let consistentFilename =
ImageNamingUtils.isValidImageFilename(serverFilename)
? serverFilename
: ImageNamingUtils.generateImageFilename(
problemId: problem.id, imageIndex: index)
// Save image with consistent filename
let imageManager = ImageManager.shared
let localPath = try imageManager.saveImportedImage(
imageData, filename: consistentFilename)
// Map server filename to consistent local filename
imagePathMapping[serverFilename] = consistentFilename
print("Downloaded and mapped image: \(serverFilename) -> \(consistentFilename)")
} catch SyncError.imageNotFound {
print("Image not found on server: \(serverFilename)")
continue
} catch {
print("Failed to download image \(serverFilename): \(error)")
continue
}
}
}
return imagePathMapping
}
private func syncImagesToServer(dataManager: ClimbingDataManager) async throws {
// Process images by problem to ensure consistent naming
for problem in dataManager.problems {
guard !problem.imagePaths.isEmpty else { continue }
for (index, imagePath) in problem.imagePaths.enumerated() {
let filename = URL(fileURLWithPath: imagePath).lastPathComponent
// Ensure filename follows consistent naming convention
let consistentFilename =
ImageNamingUtils.isValidImageFilename(filename)
? filename
: ImageNamingUtils.generateImageFilename(
problemId: problem.id.uuidString, imageIndex: index)
// Load image data
let imageManager = ImageManager.shared
let fullPath = imageManager.imagesDirectory.appendingPathComponent(filename).path
if let imageData = imageManager.loadImageData(fromPath: fullPath) {
do {
// If filename changed, rename local file
if filename != consistentFilename {
let newPath = imageManager.imagesDirectory.appendingPathComponent(
consistentFilename
).path
do {
try FileManager.default.moveItem(atPath: fullPath, toPath: newPath)
print("Renamed local image: \(filename) -> \(consistentFilename)")
// Update problem's image path in memory for consistency
// Note: This would require updating the problem in the data manager
} catch {
print("Failed to rename local image, using original: \(error)")
}
}
try await uploadImage(filename: consistentFilename, imageData: imageData)
print("Successfully uploaded image: \(consistentFilename)")
} catch {
print("Failed to upload image \(consistentFilename): \(error)")
// Continue with other images even if one fails
}
}
}
}
}
private func createBackupFromDataManager(_ dataManager: ClimbingDataManager) -> ClimbDataBackup
{
return ClimbDataBackup(
exportedAt: DataStateManager.shared.getLastModified(),
gyms: dataManager.gyms.map { BackupGym(from: $0) },
problems: dataManager.problems.map { BackupProblem(from: $0) },
sessions: dataManager.sessions.map { BackupClimbSession(from: $0) },
attempts: dataManager.attempts.map { BackupAttempt(from: $0) }
)
}
private func importBackupToDataManager(
_ backup: ClimbDataBackup, dataManager: ClimbingDataManager,
imagePathMapping: [String: String] = [:]
) throws {
do {
// Update problem image paths to point to downloaded images
let updatedBackup: ClimbDataBackup
if !imagePathMapping.isEmpty {
let updatedProblems = backup.problems.map { problem in
let updatedImagePaths = problem.imagePaths?.compactMap { oldPath in
imagePathMapping[oldPath] ?? oldPath
}
return BackupProblem(
id: problem.id,
gymId: problem.gymId,
name: problem.name,
description: problem.description,
climbType: problem.climbType,
difficulty: problem.difficulty,
tags: problem.tags,
location: problem.location,
imagePaths: updatedImagePaths,
isActive: problem.isActive,
dateSet: problem.dateSet,
notes: problem.notes,
createdAt: problem.createdAt,
updatedAt: problem.updatedAt
)
}
updatedBackup = ClimbDataBackup(
exportedAt: backup.exportedAt,
version: backup.version,
formatVersion: backup.formatVersion,
gyms: backup.gyms,
problems: updatedProblems,
sessions: backup.sessions,
attempts: backup.attempts
)
} else {
updatedBackup = backup
}
// Create a minimal ZIP with just the JSON data for existing import mechanism
let zipData = try createMinimalZipFromBackup(updatedBackup)
// Use existing import method which properly handles data restoration
try dataManager.importData(from: zipData)
// Update local data state to match imported data timestamp
DataStateManager.shared.setLastModified(backup.exportedAt)
print("Data state synchronized to imported timestamp: \(backup.exportedAt)")
} catch {
throw SyncError.importFailed(error)
}
}
private func createMinimalZipFromBackup(_ backup: ClimbDataBackup) throws -> Data {
// Create JSON data
let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted
encoder.dateEncodingStrategy = .custom { date, encoder in
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS"
var container = encoder.singleValueContainer()
try container.encode(formatter.string(from: date))
}
let jsonData = try encoder.encode(backup)
// Collect all downloaded images from ImageManager
let imageManager = ImageManager.shared
var imageFiles: [(filename: String, data: Data)] = []
let imagePaths = Set(backup.problems.flatMap { $0.imagePaths ?? [] })
for imagePath in imagePaths {
let filename = URL(fileURLWithPath: imagePath).lastPathComponent
let fullPath = imageManager.imagesDirectory.appendingPathComponent(filename).path
if let imageData = imageManager.loadImageData(fromPath: fullPath) {
imageFiles.append((filename: filename, data: imageData))
}
}
// Create ZIP with data.json, metadata, and images
var zipData = Data()
var fileEntries: [(name: String, data: Data, offset: UInt32)] = []
var currentOffset: UInt32 = 0
// Add data.json to ZIP
try addFileToMinimalZip(
filename: "data.json",
fileData: jsonData,
zipData: &zipData,
fileEntries: &fileEntries,
currentOffset: &currentOffset
)
// Add metadata with correct image count
let metadata = "export_version=2.0\nformat_version=2.0\nimage_count=\(imageFiles.count)"
let metadataData = metadata.data(using: .utf8) ?? Data()
try addFileToMinimalZip(
filename: "metadata.txt",
fileData: metadataData,
zipData: &zipData,
fileEntries: &fileEntries,
currentOffset: &currentOffset
)
// Add images to ZIP in images/ directory
for imageFile in imageFiles {
try addFileToMinimalZip(
filename: "images/\(imageFile.filename)",
fileData: imageFile.data,
zipData: &zipData,
fileEntries: &fileEntries,
currentOffset: &currentOffset
)
}
// Add central directory
var centralDirectory = Data()
for entry in fileEntries {
centralDirectory.append(createCentralDirectoryHeader(entry: entry))
}
// Add end of central directory record
let endOfCentralDir = createEndOfCentralDirectoryRecord(
fileCount: UInt16(fileEntries.count),
centralDirSize: UInt32(centralDirectory.count),
centralDirOffset: currentOffset
)
zipData.append(centralDirectory)
zipData.append(endOfCentralDir)
return zipData
}
private func addFileToMinimalZip(
filename: String,
fileData: Data,
zipData: inout Data,
fileEntries: inout [(name: String, data: Data, offset: UInt32)],
currentOffset: inout UInt32
) throws {
let localFileHeader = createLocalFileHeader(
filename: filename, fileSize: UInt32(fileData.count))
fileEntries.append((name: filename, data: fileData, offset: currentOffset))
zipData.append(localFileHeader)
zipData.append(fileData)
currentOffset += UInt32(localFileHeader.count + fileData.count)
}
private func createLocalFileHeader(filename: String, fileSize: UInt32) -> Data {
var header = Data()
// Local file header signature
header.append(Data([0x50, 0x4b, 0x03, 0x04]))
// Version needed to extract (2.0)
header.append(Data([0x14, 0x00]))
// General purpose bit flag
header.append(Data([0x00, 0x00]))
// Compression method (no compression)
header.append(Data([0x00, 0x00]))
// Last mod file time & date (dummy values)
header.append(Data([0x00, 0x00, 0x00, 0x00]))
// CRC-32 (dummy - we're not compressing)
header.append(Data([0x00, 0x00, 0x00, 0x00]))
// Compressed size
withUnsafeBytes(of: fileSize.littleEndian) { header.append(Data($0)) }
// Uncompressed size
withUnsafeBytes(of: fileSize.littleEndian) { header.append(Data($0)) }
// File name length
let filenameData = filename.data(using: .utf8) ?? Data()
let filenameLength = UInt16(filenameData.count)
withUnsafeBytes(of: filenameLength.littleEndian) { header.append(Data($0)) }
// Extra field length
header.append(Data([0x00, 0x00]))
// File name
header.append(filenameData)
return header
}
private func createCentralDirectoryHeader(entry: (name: String, data: Data, offset: UInt32))
-> Data
{
var header = Data()
// Central directory signature
header.append(Data([0x50, 0x4b, 0x01, 0x02]))
// Version made by
header.append(Data([0x14, 0x00]))
// Version needed to extract
header.append(Data([0x14, 0x00]))
// General purpose bit flag
header.append(Data([0x00, 0x00]))
// Compression method
header.append(Data([0x00, 0x00]))
// Last mod file time & date
header.append(Data([0x00, 0x00, 0x00, 0x00]))
// CRC-32
header.append(Data([0x00, 0x00, 0x00, 0x00]))
// Compressed size
let compressedSize = UInt32(entry.data.count)
withUnsafeBytes(of: compressedSize.littleEndian) { header.append(Data($0)) }
// Uncompressed size
withUnsafeBytes(of: compressedSize.littleEndian) { header.append(Data($0)) }
// File name length
let filenameData = entry.name.data(using: .utf8) ?? Data()
let filenameLength = UInt16(filenameData.count)
withUnsafeBytes(of: filenameLength.littleEndian) { header.append(Data($0)) }
// Extra field length
header.append(Data([0x00, 0x00]))
// File comment length
header.append(Data([0x00, 0x00]))
// Disk number start
header.append(Data([0x00, 0x00]))
// Internal file attributes
header.append(Data([0x00, 0x00]))
// External file attributes
header.append(Data([0x00, 0x00, 0x00, 0x00]))
// Relative offset of local header
withUnsafeBytes(of: entry.offset.littleEndian) { header.append(Data($0)) }
// File name
header.append(filenameData)
return header
}
private func createEndOfCentralDirectoryRecord(
fileCount: UInt16, centralDirSize: UInt32, centralDirOffset: UInt32
) -> Data {
var record = Data()
// End of central dir signature
record.append(Data([0x50, 0x4b, 0x05, 0x06]))
// Number of this disk
record.append(Data([0x00, 0x00]))
// Number of the disk with the start of the central directory
record.append(Data([0x00, 0x00]))
// Total number of entries in the central directory on this disk
withUnsafeBytes(of: fileCount.littleEndian) { record.append(Data($0)) }
// Total number of entries in the central directory
withUnsafeBytes(of: fileCount.littleEndian) { record.append(Data($0)) }
// Size of the central directory
withUnsafeBytes(of: centralDirSize.littleEndian) { record.append(Data($0)) }
// Offset of start of central directory
withUnsafeBytes(of: centralDirOffset.littleEndian) { record.append(Data($0)) }
// ZIP file comment length
record.append(Data([0x00, 0x00]))
return record
}
func testConnection() async throws {
guard isConfigured else {
throw SyncError.notConfigured
}
isTesting = true
defer { isTesting = false }
guard let url = URL(string: "\(serverURL)/health") else {
throw SyncError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.timeoutInterval = 10
let (_, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw SyncError.invalidResponse
}
guard httpResponse.statusCode == 200 else {
throw SyncError.serverError(httpResponse.statusCode)
}
// Connection successful, mark as connected
isConnected = true
userDefaults.set(true, forKey: Keys.isConnected)
}
func triggerAutoSync(dataManager: ClimbingDataManager) {
guard isConnected && isConfigured && isAutoSyncEnabled else { return }
Task {
do {
try await syncWithServer(dataManager: dataManager)
} catch {
print("Auto-sync failed: \(error)")
// Don't show UI errors for auto-sync failures
}
}
}
// 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() {
isConnected = false
lastSyncTime = nil
syncError = nil
userDefaults.set(false, forKey: Keys.isConnected)
userDefaults.removeObject(forKey: Keys.lastSyncTime)
}
func clearConfiguration() {
serverURL = ""
authToken = ""
lastSyncTime = nil
isConnected = false
isAutoSyncEnabled = true
userDefaults.removeObject(forKey: Keys.lastSyncTime)
userDefaults.removeObject(forKey: Keys.isConnected)
userDefaults.removeObject(forKey: Keys.autoSyncEnabled)
}
}
// Removed SyncTrigger enum - now using simple auto sync on any data change
enum SyncError: LocalizedError {
case notConfigured
case notConnected
case invalidURL
case invalidResponse
case unauthorized
case badRequest
case serverError(Int)
case decodingError(Error)
case exportFailed
case importFailed(Error)
case imageNotFound
case imageUploadFailed
var errorDescription: String? {
switch self {
case .notConfigured:
return "Sync server not configured. Please set server URL and auth token."
case .notConnected:
return "Not connected to sync server. Please test connection first."
case .invalidURL:
return "Invalid server URL."
case .invalidResponse:
return "Invalid response from server."
case .unauthorized:
return "Authentication failed. Check your auth token."
case .badRequest:
return "Bad request. Check your data format."
case .serverError(let code):
return "Server error (code \(code))."
case .decodingError(let error):
return "Failed to decode response: \(error.localizedDescription)"
case .exportFailed:
return "Failed to export local data."
case .importFailed(let error):
return "Failed to import data: \(error.localizedDescription)"
case .imageNotFound:
return "Image not found on server."
case .imageUploadFailed:
return "Failed to upload image to server."
}
}
}

View File

@@ -0,0 +1,85 @@
//
// DataStateManager.swift
import Foundation
/// Manages the overall data state timestamp for sync purposes. This tracks when any data in the
/// local database was last modified, independent of individual entity timestamps.
class DataStateManager {
private let userDefaults = UserDefaults.standard
private enum Keys {
static let lastModified = "openclimb_data_last_modified"
static let initialized = "openclimb_data_state_initialized"
}
/// Shared instance for app-wide use
static let shared = DataStateManager()
private init() {
// Initialize with current timestamp if this is the first time
if !isInitialized() {
print("DataStateManager: First time initialization")
// Set initial timestamp to a very old date so server data will be considered newer
let epochTime = "1970-01-01T00:00:00.000Z"
userDefaults.set(epochTime, forKey: Keys.lastModified)
markAsInitialized()
print("DataStateManager initialized with epoch timestamp: \(epochTime)")
} else {
print("DataStateManager: Already initialized, current timestamp: \(getLastModified())")
}
}
/// Updates the data state timestamp to the current time. Call this whenever any data is modified
/// (create, update, delete).
func updateDataState() {
let now = ISO8601DateFormatter().string(from: Date())
userDefaults.set(now, forKey: Keys.lastModified)
print("📝 iOS Data state updated to: \(now)")
}
/// Gets the current data state timestamp. This represents when any data was last modified
/// locally.
func getLastModified() -> String {
if let storedTimestamp = userDefaults.string(forKey: Keys.lastModified) {
print("📅 iOS DataStateManager returning stored timestamp: \(storedTimestamp)")
return storedTimestamp
}
// If no timestamp is stored, return epoch time to indicate very old data
// This ensures server data will be considered newer than uninitialized local data
let epochTime = "1970-01-01T00:00:00.000Z"
print("⚠️ No data state timestamp found - returning epoch time: \(epochTime)")
return epochTime
}
/// Sets the data state timestamp to a specific value. Used when importing data from server to
/// sync the state.
func setLastModified(_ timestamp: String) {
userDefaults.set(timestamp, forKey: Keys.lastModified)
print("Data state set to: \(timestamp)")
}
/// Resets the data state (for testing or complete data wipe).
func reset() {
userDefaults.removeObject(forKey: Keys.lastModified)
userDefaults.removeObject(forKey: Keys.initialized)
print("Data state reset")
}
/// Checks if the data state has been initialized.
private func isInitialized() -> Bool {
return userDefaults.bool(forKey: Keys.initialized)
}
/// Marks the data state as initialized.
private func markAsInitialized() {
userDefaults.set(true, forKey: Keys.initialized)
}
/// Gets debug information about the current state.
func getDebugInfo() -> String {
return "DataState(lastModified=\(getLastModified()), initialized=\(isInitialized()))"
}
}

View File

@@ -0,0 +1,176 @@
//
// ImageNamingUtils.swift
import CryptoKit
import Foundation
/// Utility for creating consistent image filenames across iOS and Android platforms.
/// Uses deterministic naming based on problem ID and timestamp to ensure sync compatibility.
class ImageNamingUtils {
private static let imageExtension = ".jpg"
private static let hashLength = 12 // First 12 chars of SHA-256
/// Generates a deterministic filename for a problem image.
/// Format: "problem_{hash}_{index}.jpg"
///
/// - Parameters:
/// - problemId: The ID of the problem this image belongs to
/// - timestamp: ISO8601 timestamp when the image was created
/// - imageIndex: The index of this image for the problem (0, 1, 2, etc.)
/// - Returns: A consistent filename that will be the same across platforms
static func generateImageFilename(problemId: String, timestamp: String, imageIndex: Int)
-> String
{
// Create a deterministic hash from problemId + timestamp + index
let input = "\(problemId)_\(timestamp)_\(imageIndex)"
let hash = createHash(from: input)
return "problem_\(hash)_\(imageIndex)\(imageExtension)"
}
/// Generates a deterministic filename for a problem image using current timestamp.
///
/// - Parameters:
/// - problemId: The ID of the problem this image belongs to
/// - imageIndex: The index of this image for the problem (0, 1, 2, etc.)
/// - Returns: A consistent filename
static func generateImageFilename(problemId: String, imageIndex: Int) -> String {
let timestamp = ISO8601DateFormatter().string(from: Date())
return generateImageFilename(
problemId: problemId, timestamp: timestamp, imageIndex: imageIndex)
}
/// Extracts problem ID from an image filename created by this utility.
/// Returns nil if the filename doesn't match our naming convention.
///
/// - Parameter filename: The image filename
/// - Returns: The hash identifier or nil if not a valid filename
static func extractProblemIdFromFilename(_ filename: String) -> String? {
guard filename.hasPrefix("problem_") && filename.hasSuffix(imageExtension) else {
return nil
}
// Format: problem_{hash}_{index}.jpg
let nameWithoutExtension = String(filename.dropLast(imageExtension.count))
let parts = nameWithoutExtension.components(separatedBy: "_")
guard parts.count == 3 && parts[0] == "problem" else {
return nil
}
// Return the hash as identifier
return parts[1]
}
/// Validates if a filename follows our naming convention.
///
/// - Parameter filename: The filename to validate
/// - Returns: true if it matches our convention, false otherwise
static func isValidImageFilename(_ filename: String) -> Bool {
guard filename.hasPrefix("problem_") && filename.hasSuffix(imageExtension) else {
return false
}
let nameWithoutExtension = String(filename.dropLast(imageExtension.count))
let parts = nameWithoutExtension.components(separatedBy: "_")
return parts.count == 3 && parts[0] == "problem" && parts[1].count == hashLength
&& Int(parts[2]) != nil
}
/// Migrates an existing UUID-based filename to our naming convention.
/// This is used during sync to rename downloaded images.
///
/// - Parameters:
/// - oldFilename: The existing filename (UUID-based)
/// - problemId: The problem ID this image belongs to
/// - imageIndex: The index of this image
/// - Returns: The new filename following our convention
static func migrateFilename(oldFilename: String, problemId: String, imageIndex: Int) -> String {
// If it's already using our convention, keep it
if isValidImageFilename(oldFilename) {
return oldFilename
}
// Generate new deterministic name
// Use current timestamp to maintain some consistency
let timestamp = ISO8601DateFormatter().string(from: Date())
return generateImageFilename(
problemId: problemId, timestamp: timestamp, imageIndex: imageIndex)
}
/// Creates a deterministic hash from input string.
/// Uses SHA-256 and takes first 12 characters for filename safety.
///
/// - Parameter input: The input string to hash
/// - Returns: First 12 characters of SHA-256 hash in lowercase
private static func createHash(from input: String) -> String {
let inputData = Data(input.utf8)
let hashed = SHA256.hash(data: inputData)
let hashString = hashed.compactMap { String(format: "%02x", $0) }.joined()
return String(hashString.prefix(hashLength))
}
/// Batch renames images for a problem to use our naming convention.
/// Returns a mapping of old filename -> new filename.
///
/// - Parameters:
/// - problemId: The problem ID
/// - existingFilenames: List of current image filenames for this problem
/// - Returns: Dictionary mapping old filename to new filename
static func batchRenameForProblem(problemId: String, existingFilenames: [String]) -> [String:
String]
{
var renameMap: [String: String] = [:]
for (index, oldFilename) in existingFilenames.enumerated() {
let newFilename = migrateFilename(
oldFilename: oldFilename, problemId: problemId, imageIndex: index)
if newFilename != oldFilename {
renameMap[oldFilename] = newFilename
}
}
return renameMap
}
/// Validates that a collection of filenames follow our naming convention.
///
/// - Parameter filenames: Array of filenames to validate
/// - Returns: Dictionary with validation results
static func validateFilenames(_ filenames: [String]) -> ImageValidationResult {
var validImages: [String] = []
var invalidImages: [String] = []
for filename in filenames {
if isValidImageFilename(filename) {
validImages.append(filename)
} else {
invalidImages.append(filename)
}
}
return ImageValidationResult(
totalImages: filenames.count,
validImages: validImages,
invalidImages: invalidImages
)
}
}
/// Result of image filename validation
struct ImageValidationResult {
let totalImages: Int
let validImages: [String]
let invalidImages: [String]
var isAllValid: Bool {
return invalidImages.isEmpty
}
var validPercentage: Double {
guard totalImages > 0 else { return 100.0 }
return (Double(validImages.count) / Double(totalImages)) * 100.0
}
}

View File

@@ -29,6 +29,9 @@ class ClimbingDataManager: ObservableObject {
private let decoder = JSONDecoder()
private var liveActivityObserver: NSObjectProtocol?
// Sync service for automatic syncing
let syncService = SyncService()
private enum Keys {
static let gyms = "openclimb_gyms"
static let problems = "openclimb_problems"
@@ -200,6 +203,7 @@ class ClimbingDataManager: ObservableObject {
func addGym(_ gym: Gym) {
gyms.append(gym)
saveGyms()
DataStateManager.shared.updateDataState()
successMessage = "Gym added successfully"
clearMessageAfterDelay()
}
@@ -208,6 +212,7 @@ class ClimbingDataManager: ObservableObject {
if let index = gyms.firstIndex(where: { $0.id == gym.id }) {
gyms[index] = gym
saveGyms()
DataStateManager.shared.updateDataState()
successMessage = "Gym updated successfully"
clearMessageAfterDelay()
}
@@ -229,6 +234,7 @@ class ClimbingDataManager: ObservableObject {
// Delete the gym
gyms.removeAll { $0.id == gym.id }
saveGyms()
DataStateManager.shared.updateDataState()
successMessage = "Gym deleted successfully"
clearMessageAfterDelay()
}
@@ -240,14 +246,19 @@ class ClimbingDataManager: ObservableObject {
func addProblem(_ problem: Problem) {
problems.append(problem)
saveProblems()
DataStateManager.shared.updateDataState()
successMessage = "Problem added successfully"
clearMessageAfterDelay()
// Trigger auto-sync if enabled
syncService.triggerAutoSync(dataManager: self)
}
func updateProblem(_ problem: Problem) {
if let index = problems.firstIndex(where: { $0.id == problem.id }) {
problems[index] = problem
saveProblems()
DataStateManager.shared.updateDataState()
successMessage = "Problem updated successfully"
clearMessageAfterDelay()
}
@@ -264,6 +275,7 @@ class ClimbingDataManager: ObservableObject {
// Delete the problem
problems.removeAll { $0.id == problem.id }
saveProblems()
DataStateManager.shared.updateDataState()
}
func problem(withId id: UUID) -> Problem? {
@@ -290,6 +302,7 @@ class ClimbingDataManager: ObservableObject {
saveActiveSession()
saveSessions()
DataStateManager.shared.updateDataState()
successMessage = "Session started successfully"
clearMessageAfterDelay()
@@ -317,9 +330,13 @@ class ClimbingDataManager: ObservableObject {
saveActiveSession()
saveSessions()
DataStateManager.shared.updateDataState()
successMessage = "Session completed successfully"
clearMessageAfterDelay()
// Trigger auto-sync if enabled
syncService.triggerAutoSync(dataManager: self)
// MARK: - End Live Activity after session ends
Task {
await LiveActivityManager.shared.endLiveActivity()
@@ -337,6 +354,7 @@ class ClimbingDataManager: ObservableObject {
}
saveSessions()
DataStateManager.shared.updateDataState()
successMessage = "Session updated successfully"
clearMessageAfterDelay()
@@ -359,6 +377,7 @@ class ClimbingDataManager: ObservableObject {
// Delete the session
sessions.removeAll { $0.id == session.id }
saveSessions()
DataStateManager.shared.updateDataState()
successMessage = "Session deleted successfully"
clearMessageAfterDelay()
}
@@ -380,8 +399,12 @@ class ClimbingDataManager: ObservableObject {
func addAttempt(_ attempt: Attempt) {
attempts.append(attempt)
saveAttempts()
DataStateManager.shared.updateDataState()
successMessage = "Attempt logged successfully"
// Trigger auto-sync if enabled
syncService.triggerAutoSync(dataManager: self)
clearMessageAfterDelay()
// Update Live Activity when new attempt is added
@@ -392,6 +415,7 @@ class ClimbingDataManager: ObservableObject {
if let index = attempts.firstIndex(where: { $0.id == attempt.id }) {
attempts[index] = attempt
saveAttempts()
DataStateManager.shared.updateDataState()
successMessage = "Attempt updated successfully"
clearMessageAfterDelay()
@@ -403,6 +427,7 @@ class ClimbingDataManager: ObservableObject {
func deleteAttempt(_ attempt: Attempt) {
attempts.removeAll { $0.id == attempt.id }
saveAttempts()
DataStateManager.shared.updateDataState()
successMessage = "Attempt deleted successfully"
clearMessageAfterDelay()
@@ -464,6 +489,7 @@ class ClimbingDataManager: ObservableObject {
userDefaults.removeObject(forKey: Keys.attempts)
userDefaults.removeObject(forKey: Keys.activeSession)
DataStateManager.shared.reset()
successMessage = "All data has been reset"
clearMessageAfterDelay()
}
@@ -557,6 +583,9 @@ class ClimbingDataManager: ObservableObject {
saveSessions()
saveAttempts()
// Update data state to current time since we just imported new data
DataStateManager.shared.updateDataState()
successMessage =
"Data imported successfully with \(importResult.imagePathMapping.count) images"
clearMessageAfterDelay()

View File

@@ -12,6 +12,9 @@ struct SettingsView: View {
var body: some View {
List {
SyncSection()
.environmentObject(dataManager.syncService)
DataManagementSection(
activeSheet: $activeSheet
)
@@ -303,6 +306,361 @@ struct ExportDataView: View {
}
}
struct SyncSection: View {
@EnvironmentObject var syncService: SyncService
@EnvironmentObject var dataManager: ClimbingDataManager
@State private var showingSyncSettings = false
@State private var showingDisconnectAlert = false
var body: some View {
Section("Sync") {
// Sync Status
HStack {
Image(
systemName: syncService.isConnected
? "checkmark.circle.fill"
: syncService.isConfigured
? "exclamationmark.triangle.fill"
: "exclamationmark.circle.fill"
)
.foregroundColor(
syncService.isConnected
? .green
: syncService.isConfigured
? .orange
: .red
)
VStack(alignment: .leading) {
Text("Sync Server")
.font(.headline)
Text(
syncService.isConnected
? "Connected"
: syncService.isConfigured
? "Configured - Not tested"
: "Not configured"
)
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
}
// Configure Server
Button(action: {
showingSyncSettings = true
}) {
HStack {
Image(systemName: "gear")
.foregroundColor(.blue)
Text("Configure Server")
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundColor(.secondary)
}
}
.foregroundColor(.primary)
if syncService.isConfigured {
// Sync Now - only show if connected
if syncService.isConnected {
Button(action: {
performSync()
}) {
HStack {
if syncService.isSyncing {
ProgressView()
.scaleEffect(0.8)
Text("Syncing...")
.foregroundColor(.secondary)
} else {
Image(systemName: "arrow.triangle.2.circlepath")
.foregroundColor(.green)
Text("Sync Now")
Spacer()
if let lastSync = syncService.lastSyncTime {
Text(
RelativeDateTimeFormatter().localizedString(
for: lastSync, relativeTo: Date())
)
.font(.caption)
.foregroundColor(.secondary)
}
}
}
}
.disabled(syncService.isSyncing)
.foregroundColor(.primary)
}
// Auto-sync configuration - always visible for testing
HStack {
VStack(alignment: .leading) {
Text("Auto-sync")
Text("Sync automatically on app launch and data changes")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Toggle(
"",
isOn: Binding(
get: { syncService.isAutoSyncEnabled },
set: { syncService.isAutoSyncEnabled = $0 }
)
)
.disabled(!syncService.isConnected)
}
.foregroundColor(.primary)
// Disconnect option - only show if connected
if syncService.isConnected {
Button(action: {
showingDisconnectAlert = true
}) {
HStack {
Image(systemName: "power")
.foregroundColor(.orange)
Text("Disconnect")
Spacer()
}
}
.foregroundColor(.primary)
}
if let error = syncService.syncError {
Text(error)
.font(.caption)
.foregroundColor(.red)
.padding(.leading, 24)
}
}
}
.sheet(isPresented: $showingSyncSettings) {
SyncSettingsView()
.environmentObject(syncService)
}
.alert("Disconnect from Server", isPresented: $showingDisconnectAlert) {
Button("Cancel", role: .cancel) {}
Button("Disconnect", role: .destructive) {
syncService.disconnect()
}
} message: {
Text(
"This will sign you out but keep your server settings. You'll need to test the connection again to sync."
)
}
}
private func performSync() {
Task {
do {
try await syncService.syncWithServer(dataManager: dataManager)
} catch {
print("Sync failed: \(error)")
}
}
}
}
struct SyncSettingsView: View {
@EnvironmentObject var syncService: SyncService
@Environment(\.dismiss) private var dismiss
@State private var serverURL: String = ""
@State private var authToken: String = ""
@State private var showingDisconnectAlert = false
@State private var isTesting = false
@State private var showingTestResult = false
@State private var testResultMessage = ""
var body: some View {
NavigationView {
Form {
Section {
TextField("Server URL", text: $serverURL)
.textFieldStyle(.roundedBorder)
.keyboardType(.URL)
.autocapitalization(.none)
.disableAutocorrection(true)
.placeholder(when: serverURL.isEmpty) {
Text("http://your-server:8080")
.foregroundColor(.secondary)
}
TextField("Auth Token", text: $authToken)
.textFieldStyle(.roundedBorder)
.autocapitalization(.none)
.disableAutocorrection(true)
.placeholder(when: authToken.isEmpty) {
Text("your-secret-token")
.foregroundColor(.secondary)
}
} header: {
Text("Server Configuration")
} footer: {
Text(
"Enter your sync server URL and authentication token. You must test the connection before syncing is available."
)
}
Section {
Button(action: {
testConnection()
}) {
HStack {
if isTesting {
ProgressView()
.scaleEffect(0.8)
Text("Testing...")
.foregroundColor(.secondary)
} else {
Image(systemName: "network")
.foregroundColor(.blue)
Text("Test Connection")
Spacer()
if syncService.isConnected {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
}
}
}
}
.disabled(
isTesting
|| serverURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|| authToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
)
.foregroundColor(.primary)
} header: {
Text("Connection")
} footer: {
Text("Test the connection to verify your server settings before saving.")
}
Section {
Button("Disconnect from Server") {
showingDisconnectAlert = true
}
.foregroundColor(.orange)
Button("Clear Configuration") {
syncService.clearConfiguration()
serverURL = ""
authToken = ""
}
.foregroundColor(.red)
} footer: {
Text(
"Disconnect will sign you out but keep settings. Clear Configuration removes all sync settings."
)
}
}
.navigationTitle("Sync Settings")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Save") {
let newURL = serverURL.trimmingCharacters(in: .whitespacesAndNewlines)
let newToken = authToken.trimmingCharacters(in: .whitespacesAndNewlines)
// Mark as disconnected if settings changed
if newURL != syncService.serverURL || newToken != syncService.authToken {
syncService.isConnected = false
UserDefaults.standard.set(false, forKey: "sync_is_connected")
}
syncService.serverURL = newURL
syncService.authToken = newToken
dismiss()
}
.fontWeight(.semibold)
}
}
}
.onAppear {
serverURL = syncService.serverURL
authToken = syncService.authToken
}
.alert("Disconnect from Server", isPresented: $showingDisconnectAlert) {
Button("Cancel", role: .cancel) {}
Button("Disconnect", role: .destructive) {
syncService.disconnect()
dismiss()
}
} message: {
Text(
"This will sign you out but keep your server settings. You'll need to test the connection again to sync."
)
}
.alert("Connection Test", isPresented: $showingTestResult) {
Button("OK") {}
} message: {
Text(testResultMessage)
}
}
private func testConnection() {
isTesting = true
let testURL = serverURL.trimmingCharacters(in: .whitespacesAndNewlines)
let testToken = authToken.trimmingCharacters(in: .whitespacesAndNewlines)
// Store original values in case test fails
let originalURL = syncService.serverURL
let originalToken = syncService.authToken
Task {
do {
// Temporarily set the values for testing
syncService.serverURL = testURL
syncService.authToken = testToken
try await syncService.testConnection()
await MainActor.run {
isTesting = false
testResultMessage =
"Connection successful! You can now save and sync your data."
showingTestResult = true
}
} catch {
// Restore original values if test failed
syncService.serverURL = originalURL
syncService.authToken = originalToken
await MainActor.run {
isTesting = false
testResultMessage = "Connection failed: \(error.localizedDescription)"
showingTestResult = true
}
}
}
}
}
// Removed AutoSyncSettingsView - now using simple toggle in main settings
extension View {
func placeholder<Content: View>(
when shouldShow: Bool,
alignment: Alignment = .leading,
@ViewBuilder placeholder: () -> Content
) -> some View {
ZStack(alignment: alignment) {
placeholder().opacity(shouldShow ? 1 : 0)
self
}
}
}
struct ImportDataView: View {
@EnvironmentObject var dataManager: ClimbingDataManager
@Environment(\.dismiss) private var dismiss