iOS Build 23

This commit is contained in:
2025-10-11 18:54:24 -06:00
parent e7c46634da
commit 53fa74cc83
23 changed files with 1351 additions and 285 deletions

View File

@@ -11,6 +11,9 @@ class SyncService: ObservableObject {
@Published var isTesting = false
private let userDefaults = UserDefaults.standard
private var syncTask: Task<Void, Never>?
private var pendingChanges = false
private let syncDebounceDelay: TimeInterval = 2.0
private enum Keys {
static let serverURL = "sync_server_url"
@@ -44,6 +47,11 @@ class SyncService: ObservableObject {
self.lastSyncTime = lastSync
}
self.isConnected = userDefaults.bool(forKey: Keys.isConnected)
// Perform image naming migration on initialization
Task {
await performImageNamingMigration()
}
}
func downloadData() async throws -> ClimbDataBackup {
@@ -144,6 +152,9 @@ class SyncService: ObservableObject {
request.setValue("application/octet-stream", forHTTPHeaderField: "Content-Type")
request.httpBody = imageData
request.timeoutInterval = 60.0
request.cachePolicy = .reloadIgnoringLocalCacheData
let (_, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
@@ -173,6 +184,9 @@ class SyncService: ObservableObject {
request.httpMethod = "GET"
request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization")
request.timeoutInterval = 45.0
request.cachePolicy = .returnCacheDataElseLoad
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
@@ -283,7 +297,6 @@ class SyncService: ObservableObject {
{
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 }
@@ -293,19 +306,13 @@ class SyncService: ObservableObject {
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)
let consistentFilename = 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 {
@@ -329,12 +336,8 @@ class SyncService: ObservableObject {
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)
let consistentFilename = ImageNamingUtils.generateImageFilename(
problemId: problem.id.uuidString, imageIndex: index)
// Load image data
let imageManager = ImageManager.shared
@@ -392,6 +395,53 @@ class SyncService: ObservableObject {
)
}
func createBackupForExport(_ dataManager: ClimbingDataManager) -> ClimbDataBackup {
// Filter out active sessions and their attempts from sync
let completedSessions = dataManager.sessions.filter { $0.status != .active }
let activeSessionIds = Set(
dataManager.sessions.filter { $0.status == .active }.map { $0.id })
let completedAttempts = dataManager.attempts.filter {
!activeSessionIds.contains($0.sessionId)
}
// Create backup with normalized image paths for export
return ClimbDataBackup(
exportedAt: DataStateManager.shared.getLastModified(),
gyms: dataManager.gyms.map { BackupGym(from: $0) },
problems: dataManager.problems.map { problem in
var backupProblem = BackupProblem(from: problem)
if !problem.imagePaths.isEmpty {
let normalizedPaths = problem.imagePaths.enumerated().map { index, _ in
ImageNamingUtils.generateImageFilename(
problemId: problem.id.uuidString, imageIndex: index)
}
backupProblem = BackupProblem(
id: backupProblem.id,
gymId: backupProblem.gymId,
name: backupProblem.name,
description: backupProblem.description,
climbType: backupProblem.climbType,
difficulty: backupProblem.difficulty,
tags: backupProblem.tags,
location: backupProblem.location,
imagePaths: normalizedPaths,
isActive: backupProblem.isActive,
dateSet: backupProblem.dateSet,
notes: backupProblem.notes,
createdAt: backupProblem.createdAt,
updatedAt: backupProblem.updatedAt
)
}
return backupProblem
},
sessions: completedSessions.map { BackupClimbSession(from: $0) },
attempts: completedAttempts.map { BackupAttempt(from: $0) },
deletedItems: dataManager.getDeletedItems()
)
}
private func mergeDataSafely(
localBackup: ClimbDataBackup,
serverBackup: ClimbDataBackup,
@@ -620,17 +670,31 @@ class SyncService: ObservableObject {
}
let jsonData = try encoder.encode(backup)
// Collect all downloaded images from ImageManager
// Collect all 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))
// Get original problems to access actual image paths on disk
if let problemsData = userDefaults.data(forKey: "problems"),
let problems = try? JSONDecoder().decode([Problem].self, from: problemsData)
{
// Create a mapping from normalized paths to actual paths
for problem in problems {
for (index, imagePath) in problem.imagePaths.enumerated() {
// Get the actual filename on disk
let actualFilename = URL(fileURLWithPath: imagePath).lastPathComponent
let fullPath = imageManager.imagesDirectory.appendingPathComponent(
actualFilename
).path
// Generate the normalized filename for the ZIP
let normalizedFilename = ImageNamingUtils.generateImageFilename(
problemId: problem.id.uuidString, imageIndex: index)
if let imageData = imageManager.loadImageData(fromPath: fullPath) {
imageFiles.append((filename: normalizedFilename, data: imageData))
}
}
}
}
@@ -875,20 +939,51 @@ class SyncService: ObservableObject {
}
func triggerAutoSync(dataManager: ClimbingDataManager) {
// Early exit if sync cannot proceed - don't set isSyncing
guard isConnected && isConfigured && isAutoSyncEnabled else {
// Ensure isSyncing is false when sync is not possible
if isSyncing {
isSyncing = false
}
return
}
// Prevent multiple simultaneous syncs
guard !isSyncing else {
if isSyncing {
pendingChanges = true
return
}
syncTask?.cancel()
syncTask = Task {
try? await Task.sleep(nanoseconds: UInt64(syncDebounceDelay * 1_000_000_000))
guard !Task.isCancelled else { return }
repeat {
pendingChanges = false
do {
try await syncWithServer(dataManager: dataManager)
} catch {
await MainActor.run {
self.isSyncing = false
}
return
}
if pendingChanges {
try? await Task.sleep(nanoseconds: UInt64(syncDebounceDelay * 1_000_000_000))
}
} while pendingChanges && !Task.isCancelled
}
}
func forceSyncNow(dataManager: ClimbingDataManager) {
guard isConnected && isConfigured else { return }
syncTask?.cancel()
syncTask = nil
pendingChanges = false
Task {
do {
try await syncWithServer(dataManager: dataManager)
@@ -901,6 +996,10 @@ class SyncService: ObservableObject {
}
func disconnect() {
syncTask?.cancel()
syncTask = nil
pendingChanges = false
isSyncing = false
isConnected = false
lastSyncTime = nil
syncError = nil
@@ -917,6 +1016,112 @@ class SyncService: ObservableObject {
userDefaults.removeObject(forKey: Keys.lastSyncTime)
userDefaults.removeObject(forKey: Keys.isConnected)
userDefaults.removeObject(forKey: Keys.autoSyncEnabled)
syncTask?.cancel()
syncTask = nil
pendingChanges = false
}
deinit {
syncTask?.cancel()
}
// MARK: - Image Naming Migration
private func performImageNamingMigration() async {
let migrationKey = "image_naming_migration_completed_v2"
guard !userDefaults.bool(forKey: migrationKey) else {
print("Image naming migration already completed")
return
}
print("Starting image naming migration...")
var updateCount = 0
let imageManager = ImageManager.shared
// Get all problems from UserDefaults
if let problemsData = userDefaults.data(forKey: "problems"),
var problems = try? JSONDecoder().decode([Problem].self, from: problemsData)
{
for problemIndex in 0..<problems.count {
let problem = problems[problemIndex]
guard !problem.imagePaths.isEmpty else { continue }
var updatedImagePaths: [String] = []
var hasChanges = false
for (imageIndex, imagePath) in problem.imagePaths.enumerated() {
let currentFilename = URL(fileURLWithPath: imagePath).lastPathComponent
let consistentFilename = ImageNamingUtils.generateImageFilename(
problemId: problem.id.uuidString, imageIndex: imageIndex)
if currentFilename != consistentFilename {
let oldPath = imageManager.imagesDirectory.appendingPathComponent(
currentFilename
).path
let newPath = imageManager.imagesDirectory.appendingPathComponent(
consistentFilename
).path
if FileManager.default.fileExists(atPath: oldPath) {
do {
try FileManager.default.moveItem(atPath: oldPath, toPath: newPath)
updatedImagePaths.append(consistentFilename)
hasChanges = true
updateCount += 1
print("Migrated image: \(currentFilename) -> \(consistentFilename)")
} catch {
print("Failed to migrate image \(currentFilename): \(error)")
updatedImagePaths.append(imagePath)
}
} else {
updatedImagePaths.append(imagePath)
}
} else {
updatedImagePaths.append(imagePath)
}
}
if hasChanges {
// Decode problem to dictionary, update imagePaths, re-encode
if let problemData = try? JSONEncoder().encode(problem),
var problemDict = try? JSONSerialization.jsonObject(with: problemData)
as? [String: Any]
{
problemDict["imagePaths"] = updatedImagePaths
problemDict["updatedAt"] = ISO8601DateFormatter().string(from: Date())
if let updatedData = try? JSONSerialization.data(
withJSONObject: problemDict),
let updatedProblem = try? JSONDecoder().decode(
Problem.self, from: updatedData)
{
problems[problemIndex] = updatedProblem
}
}
}
}
if updateCount > 0 {
if let updatedData = try? JSONEncoder().encode(problems) {
userDefaults.set(updatedData, forKey: "problems")
print("Updated \(updateCount) image paths in UserDefaults")
}
}
}
userDefaults.set(true, forKey: migrationKey)
print("Image naming migration completed, updated \(updateCount) images")
// Notify ClimbingDataManager to reload data if images were updated
if updateCount > 0 {
DispatchQueue.main.async {
NotificationCenter.default.post(
name: NSNotification.Name("ImageMigrationCompleted"),
object: nil,
userInfo: ["updateCount": updateCount]
)
}
}
}
// MARK: - Safe Merge Functions
@@ -926,13 +1131,14 @@ class SyncService: ObservableObject {
var merged = local
let deletedGymIds = Set(deletedItems.filter { $0.type == "gym" }.map { $0.id })
// Remove items that were deleted on other devices
let localGymIds = Set(local.map { $0.id.uuidString })
merged.removeAll { deletedGymIds.contains($0.id.uuidString) }
// Add new items from server (excluding deleted ones)
for serverGym in server {
if let serverGymConverted = try? serverGym.toGym() {
let localHasGym = local.contains(where: { $0.id.uuidString == serverGym.id })
let localHasGym = localGymIds.contains(serverGym.id)
let isDeleted = deletedGymIds.contains(serverGym.id)
if !localHasGym && !isDeleted {
@@ -953,41 +1159,44 @@ class SyncService: ObservableObject {
var merged = local
let deletedProblemIds = Set(deletedItems.filter { $0.type == "problem" }.map { $0.id })
// Remove items that were deleted on other devices
let localProblemIds = Set(local.map { $0.id.uuidString })
merged.removeAll { deletedProblemIds.contains($0.id.uuidString) }
// Add new items from server (excluding deleted ones)
for serverProblem in server {
var problemToAdd = serverProblem
let localHasProblem = localProblemIds.contains(serverProblem.id)
let isDeleted = deletedProblemIds.contains(serverProblem.id)
// Update image paths if needed
if !imagePathMapping.isEmpty {
let updatedImagePaths = serverProblem.imagePaths?.compactMap { oldPath in
imagePathMapping[oldPath] ?? oldPath
if !localHasProblem && !isDeleted {
var problemToAdd = serverProblem
if !imagePathMapping.isEmpty, let imagePaths = serverProblem.imagePaths,
!imagePaths.isEmpty
{
let updatedImagePaths = imagePaths.compactMap { oldPath in
imagePathMapping[oldPath] ?? oldPath
}
if updatedImagePaths != imagePaths {
problemToAdd = BackupProblem(
id: serverProblem.id,
gymId: serverProblem.gymId,
name: serverProblem.name,
description: serverProblem.description,
climbType: serverProblem.climbType,
difficulty: serverProblem.difficulty,
tags: serverProblem.tags,
location: serverProblem.location,
imagePaths: updatedImagePaths,
isActive: serverProblem.isActive,
dateSet: serverProblem.dateSet,
notes: serverProblem.notes,
createdAt: serverProblem.createdAt,
updatedAt: serverProblem.updatedAt
)
}
}
problemToAdd = BackupProblem(
id: serverProblem.id,
gymId: serverProblem.gymId,
name: serverProblem.name,
description: serverProblem.description,
climbType: serverProblem.climbType,
difficulty: serverProblem.difficulty,
tags: serverProblem.tags,
location: serverProblem.location,
imagePaths: updatedImagePaths,
isActive: serverProblem.isActive,
dateSet: serverProblem.dateSet,
notes: serverProblem.notes,
createdAt: serverProblem.createdAt,
updatedAt: serverProblem.updatedAt
)
}
if let serverProblemConverted = try? problemToAdd.toProblem() {
let localHasProblem = local.contains(where: { $0.id.uuidString == problemToAdd.id })
let isDeleted = deletedProblemIds.contains(problemToAdd.id)
if !localHasProblem && !isDeleted {
if let serverProblemConverted = try? problemToAdd.toProblem() {
merged.append(serverProblemConverted)
}
}
@@ -1004,19 +1213,18 @@ class SyncService: ObservableObject {
var merged = local
let deletedSessionIds = Set(deletedItems.filter { $0.type == "session" }.map { $0.id })
// Remove items that were deleted on other devices (but never remove active sessions)
let localSessionIds = Set(local.map { $0.id.uuidString })
merged.removeAll { session in
deletedSessionIds.contains(session.id.uuidString) && session.status != .active
}
// Add new items from server (excluding deleted ones)
for serverSession in server {
if let serverSessionConverted = try? serverSession.toClimbSession() {
let localHasSession = local.contains(where: { $0.id.uuidString == serverSession.id }
)
let isDeleted = deletedSessionIds.contains(serverSession.id)
let localHasSession = localSessionIds.contains(serverSession.id)
let isDeleted = deletedSessionIds.contains(serverSession.id)
if !localHasSession && !isDeleted {
if !localHasSession && !isDeleted {
if let serverSessionConverted = try? serverSession.toClimbSession() {
merged.append(serverSessionConverted)
}
}
@@ -1031,6 +1239,8 @@ class SyncService: ObservableObject {
var merged = local
let deletedAttemptIds = Set(deletedItems.filter { $0.type == "attempt" }.map { $0.id })
let localAttemptIds = Set(local.map { $0.id.uuidString })
// Get active session IDs to protect their attempts
let activeSessionIds = Set(
local.compactMap { attempt in
@@ -1048,14 +1258,12 @@ class SyncService: ObservableObject {
&& !activeSessionIds.contains(attempt.sessionId)
}
// Add new items from server (excluding deleted ones)
for serverAttempt in server {
if let serverAttemptConverted = try? serverAttempt.toAttempt() {
let localHasAttempt = local.contains(where: { $0.id.uuidString == serverAttempt.id }
)
let isDeleted = deletedAttemptIds.contains(serverAttempt.id)
let localHasAttempt = localAttemptIds.contains(serverAttempt.id)
let isDeleted = deletedAttemptIds.contains(serverAttempt.id)
if !localHasAttempt && !isDeleted {
if !localHasAttempt && !isDeleted {
if let serverAttemptConverted = try? serverAttempt.toAttempt() {
merged.append(serverAttemptConverted)
}
}