1.6.0 for Android and 1.1.0 for iOS - Finalizing Export and Import
formats :)
This commit is contained in:
@@ -57,6 +57,8 @@ struct ContentView: View {
|
||||
}
|
||||
.onAppear {
|
||||
setupNotificationObservers()
|
||||
// Trigger auto-sync on app launch
|
||||
dataManager.syncService.triggerAutoSync(dataManager: dataManager)
|
||||
}
|
||||
.onDisappear {
|
||||
removeNotificationObservers()
|
||||
@@ -100,7 +102,9 @@ struct ContentView: View {
|
||||
print("📱 App did become active - checking Live Activity status")
|
||||
Task {
|
||||
try? await Task.sleep(nanoseconds: 300_000_000) // 0.3 seconds
|
||||
dataManager.onAppBecomeActive()
|
||||
await dataManager.onAppBecomeActive()
|
||||
// Trigger auto-sync when app becomes active
|
||||
await dataManager.syncService.triggerAutoSync(dataManager: dataManager)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
447
ios/OpenClimb/Models/BackupFormat.swift
Normal file
447
ios/OpenClimb/Models/BackupFormat.swift
Normal file
@@ -0,0 +1,447 @@
|
||||
//
|
||||
// BackupFormat.swift
|
||||
|
||||
import Foundation
|
||||
|
||||
// MARK: - Backup Format Specification v2.0
|
||||
// Platform-neutral backup format for cross-platform compatibility
|
||||
// This format ensures portability between iOS and Android while maintaining
|
||||
// platform-specific implementations
|
||||
|
||||
/// Root structure for OpenClimb backup data
|
||||
struct ClimbDataBackup: Codable {
|
||||
let exportedAt: String
|
||||
let version: String
|
||||
let formatVersion: String
|
||||
let gyms: [BackupGym]
|
||||
let problems: [BackupProblem]
|
||||
let sessions: [BackupClimbSession]
|
||||
let attempts: [BackupAttempt]
|
||||
|
||||
init(
|
||||
exportedAt: String,
|
||||
version: String = "2.0",
|
||||
formatVersion: String = "2.0",
|
||||
gyms: [BackupGym],
|
||||
problems: [BackupProblem],
|
||||
sessions: [BackupClimbSession],
|
||||
attempts: [BackupAttempt]
|
||||
) {
|
||||
self.exportedAt = exportedAt
|
||||
self.version = version
|
||||
self.formatVersion = formatVersion
|
||||
self.gyms = gyms
|
||||
self.problems = problems
|
||||
self.sessions = sessions
|
||||
self.attempts = attempts
|
||||
}
|
||||
}
|
||||
|
||||
/// Platform-neutral gym representation for backup/restore
|
||||
struct BackupGym: Codable {
|
||||
let id: String
|
||||
let name: String
|
||||
let location: String?
|
||||
let supportedClimbTypes: [ClimbType]
|
||||
let difficultySystems: [DifficultySystem]
|
||||
let customDifficultyGrades: [String]
|
||||
let notes: String?
|
||||
let createdAt: String // ISO 8601 format
|
||||
let updatedAt: String // ISO 8601 format
|
||||
|
||||
/// Initialize from native iOS Gym model
|
||||
init(from gym: Gym) {
|
||||
self.id = gym.id.uuidString
|
||||
self.name = gym.name
|
||||
self.location = gym.location
|
||||
self.supportedClimbTypes = gym.supportedClimbTypes
|
||||
self.difficultySystems = gym.difficultySystems
|
||||
self.customDifficultyGrades = gym.customDifficultyGrades
|
||||
self.notes = gym.notes
|
||||
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
self.createdAt = formatter.string(from: gym.createdAt)
|
||||
self.updatedAt = formatter.string(from: gym.updatedAt)
|
||||
}
|
||||
|
||||
/// Initialize with explicit parameters for import
|
||||
init(
|
||||
id: String,
|
||||
name: String,
|
||||
location: String?,
|
||||
supportedClimbTypes: [ClimbType],
|
||||
difficultySystems: [DifficultySystem],
|
||||
customDifficultyGrades: [String] = [],
|
||||
notes: String?,
|
||||
createdAt: String,
|
||||
updatedAt: String
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.location = location
|
||||
self.supportedClimbTypes = supportedClimbTypes
|
||||
self.difficultySystems = difficultySystems
|
||||
self.customDifficultyGrades = customDifficultyGrades
|
||||
self.notes = notes
|
||||
self.createdAt = createdAt
|
||||
self.updatedAt = updatedAt
|
||||
}
|
||||
|
||||
/// Convert to native iOS Gym model
|
||||
func toGym() throws -> Gym {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
|
||||
guard let uuid = UUID(uuidString: id),
|
||||
let createdDate = formatter.date(from: createdAt),
|
||||
let updatedDate = formatter.date(from: updatedAt)
|
||||
else {
|
||||
throw BackupError.invalidDateFormat
|
||||
}
|
||||
|
||||
return Gym.fromImport(
|
||||
id: uuid,
|
||||
name: name,
|
||||
location: location,
|
||||
supportedClimbTypes: supportedClimbTypes,
|
||||
difficultySystems: difficultySystems,
|
||||
customDifficultyGrades: customDifficultyGrades,
|
||||
notes: notes,
|
||||
createdAt: createdDate,
|
||||
updatedAt: updatedDate
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Platform-neutral problem representation for backup/restore
|
||||
struct BackupProblem: Codable {
|
||||
let id: String
|
||||
let gymId: String
|
||||
let name: String?
|
||||
let description: String?
|
||||
let climbType: ClimbType
|
||||
let difficulty: DifficultyGrade
|
||||
let tags: [String]
|
||||
let location: String?
|
||||
let imagePaths: [String]?
|
||||
let isActive: Bool
|
||||
let dateSet: String? // ISO 8601 format
|
||||
let notes: String?
|
||||
let createdAt: String // ISO 8601 format
|
||||
let updatedAt: String // ISO 8601 format
|
||||
|
||||
/// Initialize from native iOS Problem model
|
||||
init(from problem: Problem) {
|
||||
self.id = problem.id.uuidString
|
||||
self.gymId = problem.gymId.uuidString
|
||||
self.name = problem.name
|
||||
self.description = problem.description
|
||||
self.climbType = problem.climbType
|
||||
self.difficulty = problem.difficulty
|
||||
self.tags = problem.tags
|
||||
self.location = problem.location
|
||||
self.imagePaths = problem.imagePaths.isEmpty ? nil : problem.imagePaths
|
||||
self.isActive = problem.isActive
|
||||
self.notes = problem.notes
|
||||
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
self.dateSet = problem.dateSet.map { formatter.string(from: $0) }
|
||||
self.createdAt = formatter.string(from: problem.createdAt)
|
||||
self.updatedAt = formatter.string(from: problem.updatedAt)
|
||||
}
|
||||
|
||||
/// Initialize with explicit parameters for import
|
||||
init(
|
||||
id: String,
|
||||
gymId: String,
|
||||
name: String?,
|
||||
description: String?,
|
||||
climbType: ClimbType,
|
||||
difficulty: DifficultyGrade,
|
||||
tags: [String] = [],
|
||||
location: String?,
|
||||
imagePaths: [String]?,
|
||||
isActive: Bool,
|
||||
dateSet: String?,
|
||||
notes: String?,
|
||||
createdAt: String,
|
||||
updatedAt: String
|
||||
) {
|
||||
self.id = id
|
||||
self.gymId = gymId
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.climbType = climbType
|
||||
self.difficulty = difficulty
|
||||
self.tags = tags
|
||||
self.location = location
|
||||
self.imagePaths = imagePaths
|
||||
self.isActive = isActive
|
||||
self.dateSet = dateSet
|
||||
self.notes = notes
|
||||
self.createdAt = createdAt
|
||||
self.updatedAt = updatedAt
|
||||
}
|
||||
|
||||
/// Convert to native iOS Problem model
|
||||
func toProblem() throws -> Problem {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
|
||||
guard let uuid = UUID(uuidString: id),
|
||||
let gymUuid = UUID(uuidString: gymId),
|
||||
let createdDate = formatter.date(from: createdAt),
|
||||
let updatedDate = formatter.date(from: updatedAt)
|
||||
else {
|
||||
throw BackupError.invalidDateFormat
|
||||
}
|
||||
|
||||
let dateSetDate = dateSet.flatMap { formatter.date(from: $0) }
|
||||
|
||||
return Problem.fromImport(
|
||||
id: uuid,
|
||||
gymId: gymUuid,
|
||||
name: name,
|
||||
description: description,
|
||||
climbType: climbType,
|
||||
difficulty: difficulty,
|
||||
tags: tags,
|
||||
location: location,
|
||||
imagePaths: imagePaths ?? [],
|
||||
isActive: isActive,
|
||||
dateSet: dateSetDate,
|
||||
notes: notes,
|
||||
createdAt: createdDate,
|
||||
updatedAt: updatedDate
|
||||
)
|
||||
}
|
||||
|
||||
/// Create a copy with updated image paths for import processing
|
||||
func withUpdatedImagePaths(_ newImagePaths: [String]) -> BackupProblem {
|
||||
return BackupProblem(
|
||||
id: self.id,
|
||||
gymId: self.gymId,
|
||||
name: self.name,
|
||||
description: self.description,
|
||||
climbType: self.climbType,
|
||||
difficulty: self.difficulty,
|
||||
tags: self.tags,
|
||||
location: self.location,
|
||||
imagePaths: newImagePaths.isEmpty ? nil : newImagePaths,
|
||||
isActive: self.isActive,
|
||||
dateSet: self.dateSet,
|
||||
notes: self.notes,
|
||||
createdAt: self.createdAt,
|
||||
updatedAt: self.updatedAt
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Platform-neutral climb session representation for backup/restore
|
||||
struct BackupClimbSession: Codable {
|
||||
let id: String
|
||||
let gymId: String
|
||||
let date: String // ISO 8601 format
|
||||
let startTime: String? // ISO 8601 format
|
||||
let endTime: String? // ISO 8601 format
|
||||
let duration: Int64? // Duration in seconds
|
||||
let status: SessionStatus
|
||||
let notes: String?
|
||||
let createdAt: String // ISO 8601 format
|
||||
let updatedAt: String // ISO 8601 format
|
||||
|
||||
/// Initialize from native iOS ClimbSession model
|
||||
init(from session: ClimbSession) {
|
||||
self.id = session.id.uuidString
|
||||
self.gymId = session.gymId.uuidString
|
||||
self.status = session.status
|
||||
self.notes = session.notes
|
||||
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
self.date = formatter.string(from: session.date)
|
||||
self.startTime = session.startTime.map { formatter.string(from: $0) }
|
||||
self.endTime = session.endTime.map { formatter.string(from: $0) }
|
||||
self.duration = session.duration.map { Int64($0) }
|
||||
self.createdAt = formatter.string(from: session.createdAt)
|
||||
self.updatedAt = formatter.string(from: session.updatedAt)
|
||||
}
|
||||
|
||||
/// Initialize with explicit parameters for import
|
||||
init(
|
||||
id: String,
|
||||
gymId: String,
|
||||
date: String,
|
||||
startTime: String?,
|
||||
endTime: String?,
|
||||
duration: Int64?,
|
||||
status: SessionStatus,
|
||||
notes: String?,
|
||||
createdAt: String,
|
||||
updatedAt: String
|
||||
) {
|
||||
self.id = id
|
||||
self.gymId = gymId
|
||||
self.date = date
|
||||
self.startTime = startTime
|
||||
self.endTime = endTime
|
||||
self.duration = duration
|
||||
self.status = status
|
||||
self.notes = notes
|
||||
self.createdAt = createdAt
|
||||
self.updatedAt = updatedAt
|
||||
}
|
||||
|
||||
/// Convert to native iOS ClimbSession model
|
||||
func toClimbSession() throws -> ClimbSession {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
|
||||
guard let uuid = UUID(uuidString: id),
|
||||
let gymUuid = UUID(uuidString: gymId),
|
||||
let dateValue = formatter.date(from: date),
|
||||
let createdDate = formatter.date(from: createdAt),
|
||||
let updatedDate = formatter.date(from: updatedAt)
|
||||
else {
|
||||
throw BackupError.invalidDateFormat
|
||||
}
|
||||
|
||||
let startTimeValue = startTime.flatMap { formatter.date(from: $0) }
|
||||
let endTimeValue = endTime.flatMap { formatter.date(from: $0) }
|
||||
let durationValue = duration.map { Int($0) }
|
||||
|
||||
return ClimbSession.fromImport(
|
||||
id: uuid,
|
||||
gymId: gymUuid,
|
||||
date: dateValue,
|
||||
startTime: startTimeValue,
|
||||
endTime: endTimeValue,
|
||||
duration: durationValue,
|
||||
status: status,
|
||||
notes: notes,
|
||||
createdAt: createdDate,
|
||||
updatedAt: updatedDate
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Platform-neutral attempt representation for backup/restore
|
||||
struct BackupAttempt: Codable {
|
||||
let id: String
|
||||
let sessionId: String
|
||||
let problemId: String
|
||||
let result: AttemptResult
|
||||
let highestHold: String?
|
||||
let notes: String?
|
||||
let duration: Int64? // Duration in seconds
|
||||
let restTime: Int64? // Rest time in seconds
|
||||
let timestamp: String // ISO 8601 format
|
||||
let createdAt: String // ISO 8601 format
|
||||
|
||||
/// Initialize from native iOS Attempt model
|
||||
init(from attempt: Attempt) {
|
||||
self.id = attempt.id.uuidString
|
||||
self.sessionId = attempt.sessionId.uuidString
|
||||
self.problemId = attempt.problemId.uuidString
|
||||
self.result = attempt.result
|
||||
self.highestHold = attempt.highestHold
|
||||
self.notes = attempt.notes
|
||||
self.duration = attempt.duration.map { Int64($0) }
|
||||
self.restTime = attempt.restTime.map { Int64($0) }
|
||||
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
self.timestamp = formatter.string(from: attempt.timestamp)
|
||||
self.createdAt = formatter.string(from: attempt.createdAt)
|
||||
}
|
||||
|
||||
/// Initialize with explicit parameters for import
|
||||
init(
|
||||
id: String,
|
||||
sessionId: String,
|
||||
problemId: String,
|
||||
result: AttemptResult,
|
||||
highestHold: String?,
|
||||
notes: String?,
|
||||
duration: Int64?,
|
||||
restTime: Int64?,
|
||||
timestamp: String,
|
||||
createdAt: String
|
||||
) {
|
||||
self.id = id
|
||||
self.sessionId = sessionId
|
||||
self.problemId = problemId
|
||||
self.result = result
|
||||
self.highestHold = highestHold
|
||||
self.notes = notes
|
||||
self.duration = duration
|
||||
self.restTime = restTime
|
||||
self.timestamp = timestamp
|
||||
self.createdAt = createdAt
|
||||
}
|
||||
|
||||
/// Convert to native iOS Attempt model
|
||||
func toAttempt() throws -> Attempt {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
|
||||
guard let uuid = UUID(uuidString: id),
|
||||
let sessionUuid = UUID(uuidString: sessionId),
|
||||
let problemUuid = UUID(uuidString: problemId),
|
||||
let timestampDate = formatter.date(from: timestamp),
|
||||
let createdDate = formatter.date(from: createdAt)
|
||||
else {
|
||||
throw BackupError.invalidDateFormat
|
||||
}
|
||||
|
||||
let durationValue = duration.map { Int($0) }
|
||||
let restTimeValue = restTime.map { Int($0) }
|
||||
|
||||
return Attempt.fromImport(
|
||||
id: uuid,
|
||||
sessionId: sessionUuid,
|
||||
problemId: problemUuid,
|
||||
result: result,
|
||||
highestHold: highestHold,
|
||||
notes: notes,
|
||||
duration: durationValue,
|
||||
restTime: restTimeValue,
|
||||
timestamp: timestampDate,
|
||||
createdAt: createdDate
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Backup Format Errors
|
||||
|
||||
enum BackupError: LocalizedError {
|
||||
case invalidDateFormat
|
||||
case invalidUUID
|
||||
case missingRequiredField(String)
|
||||
case unsupportedFormatVersion(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidDateFormat:
|
||||
return "Invalid date format in backup data"
|
||||
case .invalidUUID:
|
||||
return "Invalid UUID format in backup data"
|
||||
case .missingRequiredField(let field):
|
||||
return "Missing required field: \(field)"
|
||||
case .unsupportedFormatVersion(let version):
|
||||
return "Unsupported backup format version: \(version)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Extensions
|
||||
|
||||
// MARK: - Helper Extensions for Optional Mapping
|
||||
|
||||
extension Optional {
|
||||
func map<T>(_ transform: (Wrapped) -> T) -> T? {
|
||||
return self.flatMap { .some(transform($0)) }
|
||||
}
|
||||
}
|
||||
978
ios/OpenClimb/Services/SyncService.swift
Normal file
978
ios/OpenClimb/Services/SyncService.swift
Normal 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
|
||||
_ = 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: ¤tOffset
|
||||
)
|
||||
|
||||
// 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: ¤tOffset
|
||||
)
|
||||
|
||||
// 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: ¤tOffset
|
||||
)
|
||||
}
|
||||
|
||||
// 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."
|
||||
}
|
||||
}
|
||||
}
|
||||
85
ios/OpenClimb/Utils/DataStateManager.swift
Normal file
85
ios/OpenClimb/Utils/DataStateManager.swift
Normal 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()))"
|
||||
}
|
||||
}
|
||||
176
ios/OpenClimb/Utils/ImageNamingUtils.swift
Normal file
176
ios/OpenClimb/Utils/ImageNamingUtils.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
import Compression
|
||||
import Foundation
|
||||
import zlib
|
||||
@@ -10,7 +9,7 @@ struct ZipUtils {
|
||||
private static let METADATA_FILENAME = "metadata.txt"
|
||||
|
||||
static func createExportZip(
|
||||
exportData: ClimbDataExport,
|
||||
exportData: ClimbDataBackup,
|
||||
referencedImagePaths: Set<String>
|
||||
) throws -> Data {
|
||||
|
||||
@@ -196,7 +195,7 @@ struct ZipUtils {
|
||||
}
|
||||
|
||||
private static func createMetadata(
|
||||
exportData: ClimbDataExport,
|
||||
exportData: ClimbDataBackup,
|
||||
referencedImagePaths: Set<String>
|
||||
) -> String {
|
||||
return """
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -473,13 +499,14 @@ class ClimbingDataManager: ObservableObject {
|
||||
let dateFormatter = DateFormatter()
|
||||
dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS"
|
||||
|
||||
let exportData = ClimbDataExport(
|
||||
let exportData = ClimbDataBackup(
|
||||
exportedAt: dateFormatter.string(from: Date()),
|
||||
version: "2.0",
|
||||
gyms: gyms.map { AndroidGym(from: $0) },
|
||||
problems: problems.map { AndroidProblem(from: $0) },
|
||||
sessions: sessions.map { AndroidClimbSession(from: $0) },
|
||||
attempts: attempts.map { AndroidAttempt(from: $0) }
|
||||
formatVersion: "2.0",
|
||||
gyms: gyms.map { BackupGym(from: $0) },
|
||||
problems: problems.map { BackupProblem(from: $0) },
|
||||
sessions: sessions.map { BackupClimbSession(from: $0) },
|
||||
attempts: attempts.map { BackupAttempt(from: $0) }
|
||||
)
|
||||
|
||||
// Collect referenced image paths
|
||||
@@ -529,7 +556,7 @@ class ClimbingDataManager: ObservableObject {
|
||||
print("Raw JSON content preview:")
|
||||
print(String(decoding: importResult.jsonData.prefix(500), as: UTF8.self) + "...")
|
||||
|
||||
let importData = try decoder.decode(ClimbDataExport.self, from: importResult.jsonData)
|
||||
let importData = try decoder.decode(ClimbDataBackup.self, from: importResult.jsonData)
|
||||
|
||||
print("Successfully decoded import data:")
|
||||
print("- Gyms: \(importData.gyms.count)")
|
||||
@@ -546,16 +573,19 @@ class ClimbingDataManager: ObservableObject {
|
||||
imagePathMapping: importResult.imagePathMapping
|
||||
)
|
||||
|
||||
self.gyms = importData.gyms.map { $0.toGym() }
|
||||
self.problems = updatedProblems.map { $0.toProblem() }
|
||||
self.sessions = importData.sessions.map { $0.toClimbSession() }
|
||||
self.attempts = importData.attempts.map { $0.toAttempt() }
|
||||
self.gyms = try importData.gyms.map { try $0.toGym() }
|
||||
self.problems = try updatedProblems.map { try $0.toProblem() }
|
||||
self.sessions = try importData.sessions.map { try $0.toClimbSession() }
|
||||
self.attempts = try importData.attempts.map { try $0.toAttempt() }
|
||||
|
||||
saveGyms()
|
||||
saveProblems()
|
||||
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()
|
||||
@@ -584,337 +614,6 @@ class ClimbingDataManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
struct ClimbDataExport: Codable {
|
||||
let exportedAt: String
|
||||
let version: String
|
||||
let gyms: [AndroidGym]
|
||||
let problems: [AndroidProblem]
|
||||
let sessions: [AndroidClimbSession]
|
||||
let attempts: [AndroidAttempt]
|
||||
|
||||
init(
|
||||
exportedAt: String, version: String = "2.0", gyms: [AndroidGym], problems: [AndroidProblem],
|
||||
sessions: [AndroidClimbSession], attempts: [AndroidAttempt]
|
||||
) {
|
||||
self.exportedAt = exportedAt
|
||||
self.version = version
|
||||
self.gyms = gyms
|
||||
self.problems = problems
|
||||
self.sessions = sessions
|
||||
self.attempts = attempts
|
||||
}
|
||||
}
|
||||
|
||||
struct AndroidGym: Codable {
|
||||
let id: String
|
||||
let name: String
|
||||
let location: String?
|
||||
let supportedClimbTypes: [ClimbType]
|
||||
let difficultySystems: [DifficultySystem]
|
||||
let customDifficultyGrades: [String]
|
||||
let notes: String?
|
||||
let createdAt: String
|
||||
let updatedAt: String
|
||||
|
||||
init(from gym: Gym) {
|
||||
self.id = gym.id.uuidString
|
||||
self.name = gym.name
|
||||
self.location = gym.location
|
||||
self.supportedClimbTypes = gym.supportedClimbTypes
|
||||
self.difficultySystems = gym.difficultySystems
|
||||
self.customDifficultyGrades = gym.customDifficultyGrades
|
||||
self.notes = gym.notes
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS"
|
||||
self.createdAt = formatter.string(from: gym.createdAt)
|
||||
self.updatedAt = formatter.string(from: gym.updatedAt)
|
||||
}
|
||||
|
||||
init(
|
||||
id: String, name: String, location: String?, supportedClimbTypes: [ClimbType],
|
||||
difficultySystems: [DifficultySystem], customDifficultyGrades: [String] = [],
|
||||
notes: String?, createdAt: String, updatedAt: String
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.location = location
|
||||
self.supportedClimbTypes = supportedClimbTypes
|
||||
self.difficultySystems = difficultySystems
|
||||
self.customDifficultyGrades = customDifficultyGrades
|
||||
self.notes = notes
|
||||
self.createdAt = createdAt
|
||||
self.updatedAt = updatedAt
|
||||
}
|
||||
|
||||
func toGym() -> Gym {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS"
|
||||
|
||||
let gymId = UUID(uuidString: id) ?? UUID()
|
||||
let createdDate = formatter.date(from: createdAt) ?? Date()
|
||||
let updatedDate = formatter.date(from: updatedAt) ?? Date()
|
||||
|
||||
return Gym.fromImport(
|
||||
id: gymId,
|
||||
name: name,
|
||||
location: location,
|
||||
supportedClimbTypes: supportedClimbTypes,
|
||||
difficultySystems: difficultySystems,
|
||||
customDifficultyGrades: customDifficultyGrades,
|
||||
notes: notes,
|
||||
createdAt: createdDate,
|
||||
updatedAt: updatedDate
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct AndroidProblem: Codable {
|
||||
let id: String
|
||||
let gymId: String
|
||||
let name: String?
|
||||
let description: String?
|
||||
let climbType: ClimbType
|
||||
let difficulty: DifficultyGrade
|
||||
let tags: [String]
|
||||
let location: String?
|
||||
let imagePaths: [String]?
|
||||
let isActive: Bool
|
||||
let dateSet: String?
|
||||
let notes: String?
|
||||
let createdAt: String
|
||||
let updatedAt: String
|
||||
|
||||
init(from problem: Problem) {
|
||||
self.id = problem.id.uuidString
|
||||
self.gymId = problem.gymId.uuidString
|
||||
self.name = problem.name
|
||||
self.description = problem.description
|
||||
self.climbType = problem.climbType
|
||||
self.difficulty = problem.difficulty
|
||||
self.tags = problem.tags
|
||||
self.location = problem.location
|
||||
self.imagePaths = problem.imagePaths.isEmpty ? nil : problem.imagePaths
|
||||
self.isActive = problem.isActive
|
||||
self.notes = problem.notes
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS"
|
||||
self.dateSet = problem.dateSet != nil ? formatter.string(from: problem.dateSet!) : nil
|
||||
self.createdAt = formatter.string(from: problem.createdAt)
|
||||
self.updatedAt = formatter.string(from: problem.updatedAt)
|
||||
}
|
||||
|
||||
init(
|
||||
id: String, gymId: String, name: String?, description: String?, climbType: ClimbType,
|
||||
difficulty: DifficultyGrade, tags: [String] = [],
|
||||
location: String? = nil,
|
||||
imagePaths: [String]? = nil, isActive: Bool = true, dateSet: String? = nil,
|
||||
notes: String? = nil,
|
||||
createdAt: String, updatedAt: String
|
||||
) {
|
||||
self.id = id
|
||||
self.gymId = gymId
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.climbType = climbType
|
||||
self.difficulty = difficulty
|
||||
self.tags = tags
|
||||
self.location = location
|
||||
self.imagePaths = imagePaths
|
||||
self.isActive = isActive
|
||||
self.dateSet = dateSet
|
||||
self.notes = notes
|
||||
self.createdAt = createdAt
|
||||
self.updatedAt = updatedAt
|
||||
}
|
||||
|
||||
func toProblem() -> Problem {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS"
|
||||
|
||||
let problemId = UUID(uuidString: id) ?? UUID()
|
||||
let preservedGymId = UUID(uuidString: gymId) ?? UUID()
|
||||
let createdDate = formatter.date(from: createdAt) ?? Date()
|
||||
let updatedDate = formatter.date(from: updatedAt) ?? Date()
|
||||
|
||||
return Problem.fromImport(
|
||||
id: problemId,
|
||||
gymId: preservedGymId,
|
||||
name: name,
|
||||
description: description,
|
||||
climbType: climbType,
|
||||
difficulty: difficulty,
|
||||
tags: tags,
|
||||
location: location,
|
||||
imagePaths: imagePaths ?? [],
|
||||
isActive: isActive,
|
||||
dateSet: dateSet != nil ? formatter.date(from: dateSet!) : nil,
|
||||
notes: notes,
|
||||
createdAt: createdDate,
|
||||
updatedAt: updatedDate
|
||||
)
|
||||
}
|
||||
|
||||
func withUpdatedImagePaths(_ newImagePaths: [String]) -> AndroidProblem {
|
||||
return AndroidProblem(
|
||||
id: self.id,
|
||||
gymId: self.gymId,
|
||||
name: self.name,
|
||||
description: self.description,
|
||||
climbType: self.climbType,
|
||||
difficulty: self.difficulty,
|
||||
tags: self.tags,
|
||||
location: self.location,
|
||||
imagePaths: newImagePaths.isEmpty ? nil : newImagePaths,
|
||||
isActive: self.isActive,
|
||||
dateSet: self.dateSet,
|
||||
notes: self.notes,
|
||||
createdAt: self.createdAt,
|
||||
updatedAt: self.updatedAt
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct AndroidClimbSession: Codable {
|
||||
let id: String
|
||||
let gymId: String
|
||||
let date: String
|
||||
let startTime: String?
|
||||
let endTime: String?
|
||||
let duration: Int64?
|
||||
let status: SessionStatus
|
||||
let notes: String?
|
||||
let createdAt: String
|
||||
let updatedAt: String
|
||||
|
||||
init(from session: ClimbSession) {
|
||||
self.id = session.id.uuidString
|
||||
self.gymId = session.gymId.uuidString
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS"
|
||||
self.date = formatter.string(from: session.date)
|
||||
self.startTime = session.startTime != nil ? formatter.string(from: session.startTime!) : nil
|
||||
self.endTime = session.endTime != nil ? formatter.string(from: session.endTime!) : nil
|
||||
self.duration = session.duration != nil ? Int64(session.duration!) : nil
|
||||
self.status = session.status
|
||||
self.notes = session.notes
|
||||
self.createdAt = formatter.string(from: session.createdAt)
|
||||
self.updatedAt = formatter.string(from: session.updatedAt)
|
||||
}
|
||||
|
||||
init(
|
||||
id: String, gymId: String, date: String, startTime: String?, endTime: String?,
|
||||
duration: Int64?, status: SessionStatus, notes: String? = nil, createdAt: String,
|
||||
updatedAt: String
|
||||
) {
|
||||
self.id = id
|
||||
self.gymId = gymId
|
||||
self.date = date
|
||||
self.startTime = startTime
|
||||
self.endTime = endTime
|
||||
self.duration = duration
|
||||
self.status = status
|
||||
self.notes = notes
|
||||
self.createdAt = createdAt
|
||||
self.updatedAt = updatedAt
|
||||
}
|
||||
|
||||
func toClimbSession() -> ClimbSession {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS"
|
||||
|
||||
// Preserve original IDs and dates
|
||||
let sessionId = UUID(uuidString: id) ?? UUID()
|
||||
let preservedGymId = UUID(uuidString: gymId) ?? UUID()
|
||||
let sessionDate = formatter.date(from: date) ?? Date()
|
||||
let sessionStartTime = startTime != nil ? formatter.date(from: startTime!) : nil
|
||||
let sessionEndTime = endTime != nil ? formatter.date(from: endTime!) : nil
|
||||
let createdDate = formatter.date(from: createdAt) ?? Date()
|
||||
let updatedDate = formatter.date(from: updatedAt) ?? Date()
|
||||
|
||||
return ClimbSession.fromImport(
|
||||
id: sessionId,
|
||||
gymId: preservedGymId,
|
||||
date: sessionDate,
|
||||
startTime: sessionStartTime,
|
||||
endTime: sessionEndTime,
|
||||
duration: duration != nil ? Int(duration!) : nil,
|
||||
status: status,
|
||||
notes: notes,
|
||||
createdAt: createdDate,
|
||||
updatedAt: updatedDate
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct AndroidAttempt: Codable {
|
||||
let id: String
|
||||
let sessionId: String
|
||||
let problemId: String
|
||||
let result: AttemptResult
|
||||
let highestHold: String?
|
||||
let notes: String?
|
||||
let duration: Int64?
|
||||
let restTime: Int64?
|
||||
let timestamp: String
|
||||
let createdAt: String
|
||||
|
||||
init(from attempt: Attempt) {
|
||||
self.id = attempt.id.uuidString
|
||||
self.sessionId = attempt.sessionId.uuidString
|
||||
self.problemId = attempt.problemId.uuidString
|
||||
self.result = attempt.result
|
||||
self.highestHold = attempt.highestHold
|
||||
self.notes = attempt.notes
|
||||
self.duration = attempt.duration != nil ? Int64(attempt.duration!) : nil
|
||||
self.restTime = attempt.restTime != nil ? Int64(attempt.restTime!) : nil
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS"
|
||||
self.timestamp = formatter.string(from: attempt.timestamp)
|
||||
self.createdAt = formatter.string(from: attempt.createdAt)
|
||||
}
|
||||
|
||||
init(
|
||||
id: String, sessionId: String, problemId: String, result: AttemptResult,
|
||||
highestHold: String?, notes: String?, duration: Int64?, restTime: Int64?,
|
||||
timestamp: String, createdAt: String
|
||||
) {
|
||||
self.id = id
|
||||
self.sessionId = sessionId
|
||||
self.problemId = problemId
|
||||
self.result = result
|
||||
self.highestHold = highestHold
|
||||
self.notes = notes
|
||||
self.duration = duration
|
||||
self.restTime = restTime
|
||||
self.timestamp = timestamp
|
||||
self.createdAt = createdAt
|
||||
}
|
||||
|
||||
func toAttempt() -> Attempt {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS"
|
||||
|
||||
let attemptId = UUID(uuidString: id) ?? UUID()
|
||||
let preservedSessionId = UUID(uuidString: sessionId) ?? UUID()
|
||||
let preservedProblemId = UUID(uuidString: problemId) ?? UUID()
|
||||
let attemptTimestamp = formatter.date(from: timestamp) ?? Date()
|
||||
let createdDate = formatter.date(from: createdAt) ?? Date()
|
||||
|
||||
return Attempt.fromImport(
|
||||
id: attemptId,
|
||||
sessionId: preservedSessionId,
|
||||
problemId: preservedProblemId,
|
||||
result: result,
|
||||
highestHold: highestHold,
|
||||
notes: notes,
|
||||
duration: duration != nil ? Int(duration!) : nil,
|
||||
restTime: restTime != nil ? Int(restTime!) : nil,
|
||||
timestamp: attemptTimestamp,
|
||||
createdAt: createdDate
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension ClimbingDataManager {
|
||||
private func collectReferencedImagePaths() -> Set<String> {
|
||||
var imagePaths = Set<String>()
|
||||
@@ -949,9 +648,9 @@ extension ClimbingDataManager {
|
||||
}
|
||||
|
||||
private func updateProblemImagePaths(
|
||||
problems: [AndroidProblem],
|
||||
problems: [BackupProblem],
|
||||
imagePathMapping: [String: String]
|
||||
) -> [AndroidProblem] {
|
||||
) -> [BackupProblem] {
|
||||
return problems.map { problem in
|
||||
let updatedImagePaths = (problem.imagePaths ?? []).compactMap { oldPath in
|
||||
let fileName = URL(fileURLWithPath: oldPath).lastPathComponent
|
||||
@@ -1298,7 +997,7 @@ extension ClimbingDataManager {
|
||||
saveAttempts()
|
||||
}
|
||||
|
||||
private func validateImportData(_ importData: ClimbDataExport) throws {
|
||||
private func validateImportData(_ importData: ClimbDataBackup) throws {
|
||||
if importData.gyms.isEmpty {
|
||||
throw NSError(
|
||||
domain: "ImportError", code: 1,
|
||||
|
||||
@@ -130,14 +130,8 @@ final class LiveActivityManager {
|
||||
completedProblems: completedProblems
|
||||
)
|
||||
|
||||
do {
|
||||
await currentActivity.update(.init(state: updatedContentState, staleDate: nil))
|
||||
print("✅ Live Activity updated successfully")
|
||||
} catch {
|
||||
print("❌ Failed to update Live Activity: \(error)")
|
||||
// If update fails, the activity might have been dismissed
|
||||
self.currentActivity = nil
|
||||
}
|
||||
await currentActivity.update(.init(state: updatedContentState, staleDate: nil))
|
||||
print("✅ Live Activity updated successfully")
|
||||
}
|
||||
|
||||
/// Call this when a ClimbSession ends to end the Live Activity
|
||||
|
||||
@@ -168,15 +168,9 @@ struct ActiveSessionBanner: View {
|
||||
.onDisappear {
|
||||
stopTimer()
|
||||
}
|
||||
.background(
|
||||
NavigationLink(
|
||||
destination: SessionDetailView(sessionId: session.id),
|
||||
isActive: $navigateToDetail
|
||||
) {
|
||||
EmptyView()
|
||||
}
|
||||
.hidden()
|
||||
)
|
||||
.navigationDestination(isPresented: $navigateToDetail) {
|
||||
SessionDetailView(sessionId: session.id)
|
||||
}
|
||||
}
|
||||
|
||||
private func formatDuration(from start: Date, to end: Date) -> String {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user