1.2.2 - "Bug fixes and improvements"

This commit is contained in:
2025-10-01 21:34:22 -06:00
parent 23d662f97a
commit cb20efd58d
60 changed files with 3443 additions and 1423 deletions

View File

@@ -85,7 +85,7 @@ struct ContentView: View {
object: nil,
queue: .main
) { _ in
print("📱 App will enter foreground - preparing Live Activity check")
print("App will enter foreground - preparing Live Activity check")
Task {
// Small delay to ensure app is fully active
try? await Task.sleep(nanoseconds: 800_000_000) // 0.8 seconds
@@ -99,7 +99,7 @@ struct ContentView: View {
object: nil,
queue: .main
) { _ in
print("📱 App did become active - checking Live Activity status")
print("App did become active - checking Live Activity status")
Task {
try? await Task.sleep(nanoseconds: 300_000_000) // 0.3 seconds
await dataManager.onAppBecomeActive()

View File

@@ -4,9 +4,6 @@
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 {
@@ -37,7 +34,7 @@ struct ClimbDataBackup: Codable {
}
}
/// Platform-neutral gym representation for backup/restore
// Platform-neutral gym representation for backup/restore
struct BackupGym: Codable {
let id: String
let name: String
@@ -46,8 +43,8 @@ struct BackupGym: Codable {
let difficultySystems: [DifficultySystem]
let customDifficultyGrades: [String]
let notes: String?
let createdAt: String // ISO 8601 format
let updatedAt: String // ISO 8601 format
let createdAt: String
let updatedAt: String
/// Initialize from native iOS Gym model
init(from gym: Gym) {
@@ -114,7 +111,7 @@ struct BackupGym: Codable {
}
}
/// Platform-neutral problem representation for backup/restore
// Platform-neutral problem representation for backup/restore
struct BackupProblem: Codable {
let id: String
let gymId: String
@@ -128,8 +125,8 @@ struct BackupProblem: Codable {
let isActive: Bool
let dateSet: String? // ISO 8601 format
let notes: String?
let createdAt: String // ISO 8601 format
let updatedAt: String // ISO 8601 format
let createdAt: String
let updatedAt: String
/// Initialize from native iOS Problem model
init(from problem: Problem) {
@@ -239,7 +236,7 @@ struct BackupProblem: Codable {
}
}
/// Platform-neutral climb session representation for backup/restore
// Platform-neutral climb session representation for backup/restore
struct BackupClimbSession: Codable {
let id: String
let gymId: String
@@ -249,8 +246,8 @@ struct BackupClimbSession: Codable {
let duration: Int64? // Duration in seconds
let status: SessionStatus
let notes: String?
let createdAt: String // ISO 8601 format
let updatedAt: String // ISO 8601 format
let createdAt: String
let updatedAt: String
/// Initialize from native iOS ClimbSession model
init(from session: ClimbSession) {
@@ -327,7 +324,7 @@ struct BackupClimbSession: Codable {
}
}
/// Platform-neutral attempt representation for backup/restore
// Platform-neutral attempt representation for backup/restore
struct BackupAttempt: Codable {
let id: String
let sessionId: String
@@ -337,8 +334,8 @@ struct BackupAttempt: Codable {
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
let timestamp: String
let createdAt: String
/// Initialize from native iOS Attempt model
init(from attempt: Attempt) {

View File

@@ -230,7 +230,7 @@ class SyncService: ObservableObject {
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("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)
@@ -240,7 +240,7 @@ class SyncService: ObservableObject {
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")
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...")
@@ -251,7 +251,7 @@ class SyncService: ObservableObject {
let localTimestamp = parseISO8601ToMillis(timestamp: localBackup.exportedAt)
let serverTimestamp = parseISO8601ToMillis(timestamp: serverBackup.exportedAt)
print("🕐 DEBUG iOS Timestamp Comparison:")
print("DEBUG iOS Timestamp Comparison:")
print(" Local exportedAt: '\(localBackup.exportedAt)' -> \(localTimestamp)")
print(" Server exportedAt: '\(serverBackup.exportedAt)' -> \(serverTimestamp)")
print(
@@ -261,14 +261,14 @@ class SyncService: ObservableObject {
if localTimestamp > serverTimestamp {
// Local is newer - replace server with local data
print("🔄 iOS SYNC: Case 3a - Local data is newer, replacing server content")
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")
print("iOS SYNC: Case 3b - Server data is newer, replacing local content")
let imagePathMapping = try await syncImagesFromServer(
backup: serverBackup, dataManager: dataManager)
try importBackupToDataManager(
@@ -277,7 +277,7 @@ class SyncService: ObservableObject {
} else {
// Timestamps are equal - no sync needed
print(
"🔄 iOS SYNC: Case 3c - Data is in sync (timestamps equal), no action needed"
"iOS SYNC: Case 3c - Data is in sync (timestamps equal), no action needed"
)
}
} else {

View File

@@ -3,8 +3,7 @@
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.
/// Manages the overall data state timestamp for sync purposes
class DataStateManager {
private let userDefaults = UserDefaults.standard
@@ -14,7 +13,6 @@ class DataStateManager {
static let initialized = "openclimb_data_state_initialized"
}
/// Shared instance for app-wide use
static let shared = DataStateManager()
private init() {
@@ -36,21 +34,21 @@ class DataStateManager {
func updateDataState() {
let now = ISO8601DateFormatter().string(from: Date())
userDefaults.set(now, forKey: Keys.lastModified)
print("📝 iOS Data state updated to: \(now)")
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)")
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)")
print("WARNING: No data state timestamp found - returning epoch time: \(epochTime)")
return epochTime
}

View File

@@ -1,4 +1,3 @@
import Combine
import SwiftUI
@@ -11,7 +10,7 @@ import SwiftUI
@State private var testResults: [String] = []
var body: some View {
NavigationView {
NavigationStack {
List {
StatusSection()
@@ -263,10 +262,10 @@ import SwiftUI
ForEach(testResults.indices, id: \.self) { index in
HStack {
Image(
systemName: testResults[index].contains("")
systemName: testResults[index].contains("PASS")
? "checkmark.circle.fill" : "exclamationmark.triangle.fill"
)
.foregroundColor(testResults[index].contains("") ? .green : .orange)
.foregroundColor(testResults[index].contains("PASS") ? .green : .orange)
Text(testResults[index])
.font(.caption)
@@ -286,24 +285,24 @@ import SwiftUI
// Test 1: Check iOS version compatibility
if iconHelper.supportsModernIconFeatures {
testResults.append(" iOS 17+ features supported")
testResults.append("PASS: iOS 17+ features supported")
} else {
testResults.append(
"⚠️ Running on iOS version that doesn't support modern icon features")
"WARNING: Running on iOS version that doesn't support modern icon features")
}
// Test 2: Check dark mode detection
let detectedDarkMode = iconHelper.isInDarkMode(for: colorScheme)
let systemDarkMode = colorScheme == .dark
if detectedDarkMode == systemDarkMode {
testResults.append(" Dark mode detection matches system setting")
testResults.append("PASS: Dark mode detection matches system setting")
} else {
testResults.append("⚠️ Dark mode detection mismatch")
testResults.append("WARNING: Dark mode detection mismatch")
}
// Test 3: Check recommended variant
let variant = iconHelper.getRecommendedIconVariant(for: colorScheme)
testResults.append(" Recommended icon variant: \(variant.description)")
testResults.append("PASS: Recommended icon variant: \(variant.description)")
// Test 4: Test asset availability
validateAssetConfiguration()
@@ -316,7 +315,7 @@ import SwiftUI
iconHelper.updateDarkModeStatus(for: colorScheme)
let variant = iconHelper.getRecommendedIconVariant(for: colorScheme)
testResults.append(
" Icon appearance test completed - Current variant: \(variant.description)")
"PASS: Icon appearance test completed - Current variant: \(variant.description)")
}
private func validateAssetConfiguration() {
@@ -327,20 +326,20 @@ import SwiftUI
]
for asset in expectedAssets {
testResults.append(" Asset '\(asset)' configuration found")
testResults.append("PASS: Asset '\(asset)' configuration found")
}
}
private func checkBundleResources() {
// Check bundle identifier
let bundleId = Bundle.main.bundleIdentifier ?? "Unknown"
testResults.append(" Bundle ID: \(bundleId)")
testResults.append("PASS: Bundle ID: \(bundleId)")
// Check app version
let version =
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown"
let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "Unknown"
testResults.append(" App version: \(version) (\(build))")
testResults.append("PASS: App version: \(version) (\(build))")
}
}
@@ -364,7 +363,7 @@ import SwiftUI
@Environment(\.colorScheme) private var colorScheme
var body: some View {
NavigationView {
NavigationStack {
VStack(spacing: 30) {
Text("Icon Appearance Comparison")
.font(.title2)

View File

@@ -23,7 +23,7 @@ class ImageManager {
// Final integrity check
if !validateStorageIntegrity() {
print("🚨 CRITICAL: Storage integrity compromised - attempting emergency recovery")
print("CRITICAL: Storage integrity compromised - attempting emergency recovery")
emergencyImageRestore()
}
@@ -69,9 +69,9 @@ class ImageManager {
attributes: [
.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication
])
print("Created directory: \(directory.path)")
print("Created directory: \(directory.path)")
} catch {
print(" Failed to create directory \(directory.path): \(error)")
print("ERROR: Failed to create directory \(directory.path): \(error)")
}
}
}
@@ -88,9 +88,9 @@ class ImageManager {
var backupURL = backupDirectory
try imagesURL.setResourceValues(resourceValues)
try backupURL.setResourceValues(resourceValues)
print("Excluded image directories from iCloud backup")
print("Excluded image directories from iCloud backup")
} catch {
print("⚠️ Failed to exclude from iCloud backup: \(error)")
print("WARNING: Failed to exclude from iCloud backup: \(error)")
}
}
@@ -114,11 +114,11 @@ class ImageManager {
}
private func performRobustMigration() {
print("🔄 Starting robust image migration system...")
print("Starting robust image migration system...")
// Check for interrupted migration
if let incompleteState = loadMigrationState() {
print("🔧 Detected interrupted migration, resuming...")
print("Detected interrupted migration, resuming...")
resumeMigration(from: incompleteState)
} else {
// Start fresh migration
@@ -135,7 +135,7 @@ class ImageManager {
private func startNewMigration() {
// First check for images in previous Application Support directories
if let previousAppSupportImages = findPreviousAppSupportImages() {
print("📁 Found images in previous Application Support directory")
print("Found images in previous Application Support directory")
migratePreviousAppSupportImages(from: previousAppSupportImages)
return
}
@@ -145,7 +145,7 @@ class ImageManager {
let hasLegacyImportImages = fileManager.fileExists(atPath: legacyImportImagesDirectory.path)
guard hasLegacyImages || hasLegacyImportImages else {
print("No legacy images to migrate")
print("No legacy images to migrate")
return
}
@@ -160,7 +160,7 @@ class ImageManager {
let legacyFiles = try fileManager.contentsOfDirectory(
atPath: legacyImagesDirectory.path)
allLegacyFiles.append(contentsOf: legacyFiles)
print("📦 Found \(legacyFiles.count) images in OpenClimbImages")
print("Found \(legacyFiles.count) images in OpenClimbImages")
}
// Collect files from Documents/images directory
@@ -168,10 +168,10 @@ class ImageManager {
let importFiles = try fileManager.contentsOfDirectory(
atPath: legacyImportImagesDirectory.path)
allLegacyFiles.append(contentsOf: importFiles)
print("📦 Found \(importFiles.count) images in Documents/images")
print("Found \(importFiles.count) images in Documents/images")
}
print("📦 Total legacy images to migrate: \(allLegacyFiles.count)")
print("Total legacy images to migrate: \(allLegacyFiles.count)")
let initialState = MigrationState(
version: MigrationState.currentVersion,
@@ -186,24 +186,24 @@ class ImageManager {
performMigrationWithCheckpoints(files: allLegacyFiles, currentState: initialState)
} catch {
print(" Failed to start migration: \(error)")
print("ERROR: Failed to start migration: \(error)")
}
}
private func resumeMigration(from state: MigrationState) {
print("🔄 Resuming migration from checkpoint...")
print("📊 Progress: \(state.completedFiles.count)/\(state.totalFiles)")
print("Resuming migration from checkpoint...")
print("Progress: \(state.completedFiles.count)/\(state.totalFiles)")
do {
let legacyFiles = try fileManager.contentsOfDirectory(
atPath: legacyImagesDirectory.path)
let remainingFiles = legacyFiles.filter { !state.completedFiles.contains($0) }
print("📦 Resuming with \(remainingFiles.count) remaining files")
print("Resuming with \(remainingFiles.count) remaining files")
performMigrationWithCheckpoints(files: remainingFiles, currentState: state)
} catch {
print(" Failed to resume migration: \(error)")
print("ERROR: Failed to resume migration: \(error)")
// Fallback: start fresh
removeMigrationState()
startNewMigration()
@@ -270,11 +270,11 @@ class ImageManager {
completedFiles.append(fileName)
migratedCount += 1
print("Migrated: \(fileName) (\(migratedCount)/\(currentState.totalFiles))")
print("Migrated: \(fileName) (\(migratedCount)/\(currentState.totalFiles))")
} catch {
failedCount += 1
print(" Failed to migrate \(fileName): \(error)")
print("ERROR: Failed to migrate \(fileName): \(error)")
}
// Save checkpoint every 5 files or if interrupted
@@ -288,7 +288,7 @@ class ImageManager {
lastCheckpoint: Date()
)
saveMigrationState(checkpointState)
print("💾 Checkpoint saved: \(completedFiles.count)/\(currentState.totalFiles)")
print("Checkpoint saved: \(completedFiles.count)/\(currentState.totalFiles)")
}
}
}
@@ -304,7 +304,7 @@ class ImageManager {
)
saveMigrationState(finalState)
print("🏁 Migration complete: \(migratedCount) migrated, \(failedCount) failed")
print("Migration complete: \(migratedCount) migrated, \(failedCount) failed")
// Clean up legacy directory if no failures
if failedCount == 0 {
@@ -313,7 +313,7 @@ class ImageManager {
}
private func verifyMigrationIntegrity() {
print("🔍 Verifying migration integrity...")
print("Verifying migration integrity...")
var allLegacyFiles = Set<String>()
@@ -331,12 +331,12 @@ class ImageManager {
allLegacyFiles.formUnion(importFiles)
}
} catch {
print(" Failed to read legacy directories: \(error)")
print("ERROR: Failed to read legacy directories: \(error)")
return
}
guard !allLegacyFiles.isEmpty else {
print("No legacy directories to verify against")
print("No legacy directories to verify against")
return
}
@@ -347,10 +347,10 @@ class ImageManager {
let missingFiles = allLegacyFiles.subtracting(migratedFiles)
if missingFiles.isEmpty {
print("Migration integrity verified - all files present")
print("Migration integrity verified - all files present")
cleanupLegacyDirectory()
} else {
print("⚠️ Missing \(missingFiles.count) files, re-triggering migration")
print("WARNING: Missing \(missingFiles.count) files, re-triggering migration")
// Re-trigger migration for missing files
performMigrationWithCheckpoints(
files: Array(missingFiles),
@@ -364,16 +364,16 @@ class ImageManager {
))
}
} catch {
print(" Failed to verify migration integrity: \(error)")
print("ERROR: Failed to verify migration integrity: \(error)")
}
}
private func cleanupLegacyDirectory() {
do {
try fileManager.removeItem(at: legacyImagesDirectory)
print("🗑️ Cleaned up legacy directory")
print("Cleaned up legacy directory")
} catch {
print("⚠️ Failed to clean up legacy directory: \(error)")
print("WARNING: Failed to clean up legacy directory: \(error)")
}
}
@@ -395,14 +395,14 @@ class ImageManager {
// Check if state is too old (more than 1 hour)
if Date().timeIntervalSince(state.lastCheckpoint) > 3600 {
print("⚠️ Migration state is stale, starting fresh")
print("WARNING: Migration state is stale, starting fresh")
removeMigrationState()
return nil
}
return state.isComplete ? nil : state
} catch {
print(" Failed to load migration state: \(error)")
print("ERROR: Failed to load migration state: \(error)")
removeMigrationState()
return nil
}
@@ -413,7 +413,7 @@ class ImageManager {
let data = try JSONEncoder().encode(state)
try data.write(to: migrationStateURL)
} catch {
print(" Failed to save migration state: \(error)")
print("ERROR: Failed to save migration state: \(error)")
}
}
@@ -429,7 +429,7 @@ class ImageManager {
private func cleanupMigrationState() {
try? fileManager.removeItem(at: migrationStateURL)
try? fileManager.removeItem(at: migrationLockURL)
print("🧹 Cleaned up migration state files")
print("Cleaned up migration state files")
}
func saveImageData(_ data: Data, withName name: String? = nil) -> String? {
@@ -444,10 +444,10 @@ class ImageManager {
// Create backup copy
try data.write(to: backupPath)
print("Saved image with backup: \(fileName)")
print("Saved image with backup: \(fileName)")
return fileName
} catch {
print(" Failed to save image \(fileName): \(error)")
print("ERROR: Failed to save image \(fileName): \(error)")
return nil
}
}
@@ -467,7 +467,7 @@ class ImageManager {
if fileManager.fileExists(atPath: backupPath.path),
let data = try? Data(contentsOf: backupPath)
{
print("📦 Restored image from backup: \(path)")
print("Restored image from backup: \(path)")
// Restore to primary location
try? data.write(to: URL(fileURLWithPath: primaryPath))
@@ -497,7 +497,7 @@ class ImageManager {
do {
try fileManager.removeItem(atPath: primaryPath)
} catch {
print(" Failed to delete primary image at \(primaryPath): \(error)")
print("ERROR: Failed to delete primary image at \(primaryPath): \(error)")
success = false
}
}
@@ -507,7 +507,7 @@ class ImageManager {
do {
try fileManager.removeItem(at: backupPath)
} catch {
print(" Failed to delete backup image at \(backupPath.path): \(error)")
print("ERROR: Failed to delete backup image at \(backupPath.path): \(error)")
success = false
}
}
@@ -544,7 +544,7 @@ class ImageManager {
}
func performMaintenance() {
print("🔧 Starting image maintenance...")
print("Starting image maintenance...")
syncBackups()
validateImageIntegrity()
@@ -562,11 +562,11 @@ class ImageManager {
let backupPath = backupDirectory.appendingPathComponent(fileName)
try? fileManager.copyItem(at: primaryPath, to: backupPath)
print("🔄 Created missing backup for: \(fileName)")
print("Created missing backup for: \(fileName)")
}
}
} catch {
print(" Failed to sync backups: \(error)")
print("ERROR: Failed to sync backups: \(error)")
}
}
@@ -585,15 +585,15 @@ class ImageManager {
}
}
print("Validated \(validFiles) of \(files.count) image files")
print("Validated \(validFiles) of \(files.count) image files")
} catch {
print(" Failed to validate images: \(error)")
print("ERROR: Failed to validate images: \(error)")
}
}
private func cleanupOrphanedFiles() {
// This would need access to the data manager to check which files are actually referenced
print("🧹 Cleanup would require coordination with data manager")
print("Cleanup would require coordination with data manager")
}
func getStorageInfo() -> (primaryCount: Int, backupCount: Int, totalSize: Int64) {
@@ -623,7 +623,7 @@ class ImageManager {
let previousDir = findPreviousAppSupportImages()
print(
"""
📁 OpenClimb Image Storage:
OpenClimb Image Storage:
- App Support: \(appSupportDirectory.path)
- Images: \(imagesDirectory.path) (\(info.primaryCount) files)
- Backups: \(backupDirectory.path) (\(info.backupCount) files)
@@ -635,7 +635,7 @@ class ImageManager {
}
func forceRecoveryMigration() {
print("🚨 FORCE RECOVERY: Starting manual migration recovery...")
print("FORCE RECOVERY: Starting manual migration recovery...")
// Remove any stale state
removeMigrationState()
@@ -644,7 +644,7 @@ class ImageManager {
// Force fresh migration
startNewMigration()
print("🚨 FORCE RECOVERY: Migration recovery completed")
print("FORCE RECOVERY: Migration recovery completed")
}
func saveImportedImage(_ imageData: Data, filename: String) throws -> String {
@@ -657,12 +657,12 @@ class ImageManager {
// Create backup
try? imageData.write(to: backupPath)
print("📥 Imported image: \(filename)")
print("Imported image: \(filename)")
return filename
}
func emergencyImageRestore() {
print("🆘 EMERGENCY: Attempting image restoration...")
print("EMERGENCY: Attempting image restoration...")
// Try to restore from backup directory
do {
@@ -680,14 +680,14 @@ class ImageManager {
}
}
print("🆘 EMERGENCY: Restored \(restoredCount) images from backup")
print("EMERGENCY: Restored \(restoredCount) images from backup")
} catch {
print("🆘 EMERGENCY: Failed to restore from backup: \(error)")
print("EMERGENCY: Failed to restore from backup: \(error)")
}
// Try previous Application Support directories first
if let previousAppSupportImages = findPreviousAppSupportImages() {
print("🆘 EMERGENCY: Found previous Application Support images, migrating...")
print("EMERGENCY: Found previous Application Support images, migrating...")
migratePreviousAppSupportImages(from: previousAppSupportImages)
return
}
@@ -696,21 +696,21 @@ class ImageManager {
if fileManager.fileExists(atPath: legacyImagesDirectory.path)
|| fileManager.fileExists(atPath: legacyImportImagesDirectory.path)
{
print("🆘 EMERGENCY: Attempting legacy migration as fallback...")
print("EMERGENCY: Attempting legacy migration as fallback...")
forceRecoveryMigration()
}
}
func debugSafeInitialization() -> Bool {
print("🐛 DEBUG SAFE: Performing debug-safe initialization check...")
print("DEBUG SAFE: Performing debug-safe initialization check...")
// Check if we're in a debug environment
#if DEBUG
print("🐛 DEBUG SAFE: Debug environment detected")
print("DEBUG SAFE: Debug environment detected")
// Check for interrupted migration more aggressively
if fileManager.fileExists(atPath: migrationLockURL.path) {
print("🐛 DEBUG SAFE: Found migration lock - likely debug interruption")
print("DEBUG SAFE: Found migration lock - likely debug interruption")
// Give extra time for file system to stabilize
Thread.sleep(forTimeInterval: 1.0)
@@ -732,14 +732,14 @@ class ImageManager {
((try? fileManager.contentsOfDirectory(atPath: backupDirectory.path)) ?? []).count > 0
if primaryEmpty && backupHasFiles {
print("🐛 DEBUG SAFE: Primary empty but backup exists - restoring")
print("DEBUG SAFE: Primary empty but backup exists - restoring")
emergencyImageRestore()
return true
}
// Check if primary storage is empty but previous Application Support images exist
if primaryEmpty, let previousAppSupportImages = findPreviousAppSupportImages() {
print("🐛 DEBUG SAFE: Primary empty but found previous Application Support images")
print("DEBUG SAFE: Primary empty but found previous Application Support images")
migratePreviousAppSupportImages(from: previousAppSupportImages)
return true
}
@@ -755,13 +755,15 @@ class ImageManager {
// Check if we have more backups than primary files (sign of corruption)
if backupFiles.count > primaryFiles.count + 5 {
print("⚠️ INTEGRITY: Backup count significantly exceeds primary - potential corruption")
print(
"WARNING INTEGRITY: Backup count significantly exceeds primary - potential corruption"
)
return false
}
// Check if primary is completely empty but we have data elsewhere
if primaryFiles.isEmpty && !backupFiles.isEmpty {
print("⚠️ INTEGRITY: Primary storage empty but backups exist")
print("WARNING INTEGRITY: Primary storage empty but backups exist")
return false
}
@@ -775,7 +777,7 @@ class ImageManager {
for: .applicationSupportDirectory, in: .userDomainMask
).first
else {
print(" Could not access Application Support directory")
print("ERROR: Could not access Application Support directory")
return nil
}
@@ -808,13 +810,13 @@ class ImageManager {
}
}
} catch {
print(" Error scanning for previous Application Support directories: \(error)")
print("ERROR: Error scanning for previous Application Support directories: \(error)")
}
return nil
}
private func migratePreviousAppSupportImages(from sourceDirectory: URL) {
print("🔄 Migrating images from previous Application Support directory")
print("Migrating images from previous Application Support directory")
do {
let imageFiles = try fileManager.contentsOfDirectory(atPath: sourceDirectory.path)
@@ -837,17 +839,17 @@ class ImageManager {
// Create backup
try? fileManager.copyItem(at: sourcePath, to: backupPath)
print("Migrated: \(fileName)")
print("Migrated: \(fileName)")
} catch {
print(" Failed to migrate \(fileName): \(error)")
print("ERROR: Failed to migrate \(fileName): \(error)")
}
}
}
print("Completed migration from previous Application Support directory")
print("Completed migration from previous Application Support directory")
} catch {
print(" Failed to migrate from previous Application Support: \(error)")
print("ERROR: Failed to migrate from previous Application Support: \(error)")
}
}
}

View File

@@ -4,54 +4,36 @@
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.
/// Utility for creating consistent image filenames across platforms
class ImageNamingUtils {
private static let imageExtension = ".jpg"
private static let hashLength = 12 // First 12 chars of SHA-256
private static let hashLength = 12
/// 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
/// Generates a deterministic filename for a problem image
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
/// Generates a deterministic filename using current timestamp
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
/// Extracts problem ID from an image 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: "_")
@@ -59,14 +41,10 @@ class ImageNamingUtils {
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
/// Validates if a filename follows our naming convention
static func isValidImageFilename(_ filename: String) -> Bool {
guard filename.hasPrefix("problem_") && filename.hasSuffix(imageExtension) else {
return false
@@ -79,32 +57,19 @@ class ImageNamingUtils {
&& 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
/// Migrates an existing filename to our naming 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
/// Creates a deterministic hash from input string
private static func createHash(from input: String) -> String {
let inputData = Data(input.utf8)
let hashed = SHA256.hash(data: inputData)
@@ -112,13 +77,7 @@ class ImageNamingUtils {
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
/// Batch renames images for a problem to use our naming convention
static func batchRenameForProblem(problemId: String, existingFilenames: [String]) -> [String:
String]
{
@@ -135,10 +94,7 @@ class ImageNamingUtils {
return renameMap
}
/// Validates that a collection of filenames follow our naming convention.
///
/// - Parameter filenames: Array of filenames to validate
/// - Returns: Dictionary with validation results
/// Validates that a collection of filenames follow our naming convention
static func validateFilenames(_ filenames: [String]) -> ImageValidationResult {
var validImages: [String] = []
var invalidImages: [String] = []
@@ -159,7 +115,7 @@ class ImageNamingUtils {
}
}
/// Result of image filename validation
// Result of image filename validation
struct ImageValidationResult {
let totalImages: Int
let validImages: [String]

View File

@@ -554,20 +554,20 @@ class ClimbingDataManager: ObservableObject {
// Collect referenced image paths
let referencedImagePaths = collectReferencedImagePaths()
print("🎯 Starting export with \(referencedImagePaths.count) images")
print("Starting export with \(referencedImagePaths.count) images")
let zipData = try ZipUtils.createExportZip(
exportData: exportData,
referencedImagePaths: referencedImagePaths
)
print("Export completed successfully")
print("Export completed successfully")
successMessage = "Export completed with \(referencedImagePaths.count) images"
clearMessageAfterDelay()
return zipData
} catch {
let errorMessage = "Export failed: \(error.localizedDescription)"
print(" \(errorMessage)")
print("ERROR: \(errorMessage)")
setError(errorMessage)
return nil
}
@@ -662,13 +662,13 @@ class ClimbingDataManager: ObservableObject {
extension ClimbingDataManager {
private func collectReferencedImagePaths() -> Set<String> {
var imagePaths = Set<String>()
print("🖼️ Starting image path collection...")
print("📊 Total problems: \(problems.count)")
print("Starting image path collection...")
print("Total problems: \(problems.count)")
for problem in problems {
if !problem.imagePaths.isEmpty {
print(
"📸 Problem '\(problem.name ?? "Unnamed")' has \(problem.imagePaths.count) images"
"Problem '\(problem.name ?? "Unnamed")' has \(problem.imagePaths.count) images"
)
for imagePath in problem.imagePaths {
print(" - Relative path: \(imagePath)")
@@ -677,10 +677,10 @@ extension ClimbingDataManager {
// Check if file exists
if FileManager.default.fileExists(atPath: fullPath) {
print(" File exists")
print(" File exists")
imagePaths.insert(fullPath)
} else {
print(" File does NOT exist")
print(" File does NOT exist")
// Still add it to let ZipUtils handle the error logging
imagePaths.insert(fullPath)
}
@@ -688,7 +688,7 @@ extension ClimbingDataManager {
}
}
print("🖼️ Collected \(imagePaths.count) total image paths for export")
print("Collected \(imagePaths.count) total image paths for export")
return imagePaths
}
@@ -748,7 +748,7 @@ extension ClimbingDataManager {
// Log storage information for debugging
let info = await ImageManager.shared.getStorageInfo()
print(
"📊 Image Storage: \(info.primaryCount) primary, \(info.backupCount) backup, \(info.totalSize / 1024)KB total"
"Image Storage: \(info.primaryCount) primary, \(info.backupCount) backup, \(info.totalSize / 1024)KB total"
)
}.value
}
@@ -786,7 +786,7 @@ extension ClimbingDataManager {
}
if !orphanedFiles.isEmpty {
print("🗑️ Cleaned up \(orphanedFiles.count) orphaned image files")
print("Cleaned up \(orphanedFiles.count) orphaned image files")
}
}
}
@@ -803,7 +803,7 @@ extension ClimbingDataManager {
}
func forceImageRecovery() {
print("🚨 User initiated force image recovery")
print("User initiated force image recovery")
ImageManager.shared.forceRecoveryMigration()
// Refresh the UI after recovery
@@ -811,7 +811,7 @@ extension ClimbingDataManager {
}
func emergencyImageRestore() {
print("🆘 User initiated emergency image restore")
print("User initiated emergency image restore")
ImageManager.shared.emergencyImageRestore()
// Refresh the UI after restore
@@ -827,7 +827,7 @@ extension ClimbingDataManager {
let info = ImageManager.shared.getStorageInfo()
return """
Image Storage Health: \(isValid ? "Good" : "Needs Recovery")
Image Storage Health: \(isValid ? "Good" : "Needs Recovery")
Primary Files: \(info.primaryCount)
Backup Files: \(info.backupCount)
Total Size: \(formatBytes(info.totalSize))
@@ -845,7 +845,7 @@ extension ClimbingDataManager {
// Test with dummy data if we have a gym
guard let testGym = gyms.first else {
print(" No gyms available for testing")
print("ERROR: No gyms available for testing")
return
}
@@ -877,14 +877,14 @@ extension ClimbingDataManager {
// Only restart if session is actually active
guard activeSession.status == .active else {
print(
"⚠️ Session exists but is not active (status: \(activeSession.status)), ending Live Activity"
"WARNING: Session exists but is not active (status: \(activeSession.status)), ending Live Activity"
)
await LiveActivityManager.shared.endLiveActivity()
return
}
if let gym = gym(withId: activeSession.gymId) {
print("🔍 Checking Live Activity for active session at \(gym.name)")
print("Checking Live Activity for active session at \(gym.name)")
// First cleanup any dismissed activities
await LiveActivityManager.shared.cleanupDismissedActivities()
@@ -894,15 +894,12 @@ extension ClimbingDataManager {
activeSession: activeSession,
gymName: gym.name
)
// Update with current session data
await updateLiveActivityData()
}
}
/// Call this when app becomes active to check for Live Activity restart
func onAppBecomeActive() {
print("📱 App became active - checking Live Activity status")
print("App became active - checking Live Activity status")
Task {
await checkAndRestartLiveActivity()
}
@@ -910,7 +907,7 @@ extension ClimbingDataManager {
/// Call this when app enters background to update Live Activity
func onAppEnterBackground() {
print("📱 App entering background - updating Live Activity if needed")
print("App entering background - updating Live Activity if needed")
Task {
await updateLiveActivityData()
}
@@ -939,7 +936,7 @@ extension ClimbingDataManager {
return
}
print("🔄 Attempting to restart dismissed Live Activity for \(gym.name)")
print("Attempting to restart dismissed Live Activity for \(gym.name)")
// Wait a bit before restarting to avoid frequency limits
try? await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds
@@ -979,7 +976,7 @@ extension ClimbingDataManager {
activeSession.status == .active,
let gym = gym(withId: activeSession.gymId)
else {
print("⚠️ Live Activity update skipped - no active session or gym")
print("WARNING: Live Activity update skipped - no active session or gym")
if let session = activeSession {
print(" Session ID: \(session.id)")
print(" Session Status: \(session.status)")
@@ -1003,7 +1000,7 @@ extension ClimbingDataManager {
elapsedInterval = 0
}
print("🔄 Live Activity Update Debug:")
print("Live Activity Update Debug:")
print(" Session ID: \(activeSession.id)")
print(" Gym: \(gym.name)")
print(" Total attempts in session: \(totalAttempts)")

View File

@@ -34,11 +34,11 @@ final class LiveActivityManager {
let isStillActive = activities.contains { $0.id == currentActivity.id }
if isStillActive {
print(" Live Activity still running: \(currentActivity.id)")
print("Live Activity still running: \(currentActivity.id)")
return
} else {
print(
"⚠️ Tracked Live Activity \(currentActivity.id) was dismissed, clearing reference"
"WARNING: Tracked Live Activity \(currentActivity.id) was dismissed, clearing reference"
)
self.currentActivity = nil
}
@@ -47,18 +47,18 @@ final class LiveActivityManager {
// Check if there are ANY active Live Activities for this session
let existingActivities = Activity<SessionActivityAttributes>.activities
if let existingActivity = existingActivities.first {
print(" Found existing Live Activity: \(existingActivity.id), using it")
print("Found existing Live Activity: \(existingActivity.id), using it")
self.currentActivity = existingActivity
return
}
print("🔄 No Live Activity found, restarting for existing session")
print("No Live Activity found, restarting for existing session")
await startLiveActivity(for: activeSession, gymName: gymName)
}
/// Call this when a ClimbSession starts to begin a Live Activity
func startLiveActivity(for session: ClimbSession, gymName: String) async {
print("🔴 Starting Live Activity for gym: \(gymName)")
print("Starting Live Activity for gym: \(gymName)")
await endLiveActivity()
@@ -84,9 +84,9 @@ final class LiveActivityManager {
pushType: nil
)
self.currentActivity = activity
print("Live Activity started successfully: \(activity.id)")
print("Live Activity started successfully: \(activity.id)")
} catch {
print(" Failed to start live activity: \(error)")
print("ERROR: Failed to start live activity: \(error)")
print("Error details: \(error.localizedDescription)")
// Check specific error types
@@ -104,7 +104,7 @@ final class LiveActivityManager {
func updateLiveActivity(elapsed: TimeInterval, totalAttempts: Int, completedProblems: Int) async
{
guard let currentActivity = currentActivity else {
print("⚠️ No current activity to update")
print("WARNING: No current activity to update")
return
}
@@ -114,14 +114,14 @@ final class LiveActivityManager {
if !isStillActive {
print(
"⚠️ Tracked Live Activity \(currentActivity.id) is no longer active, clearing reference"
"WARNING: Tracked Live Activity \(currentActivity.id) is no longer active, clearing reference"
)
self.currentActivity = nil
return
}
print(
"🔄 Updating Live Activity - Attempts: \(totalAttempts), Completed: \(completedProblems)"
"Updating Live Activity - Attempts: \(totalAttempts), Completed: \(completedProblems)"
)
let updatedContentState = SessionActivityAttributes.ContentState(
@@ -131,7 +131,7 @@ final class LiveActivityManager {
)
await currentActivity.update(.init(state: updatedContentState, staleDate: nil))
print("Live Activity updated successfully")
print("Live Activity updated successfully")
}
/// Call this when a ClimbSession ends to end the Live Activity
@@ -141,25 +141,25 @@ final class LiveActivityManager {
// First end the tracked activity if it exists
if let currentActivity {
print("🔴 Ending tracked Live Activity: \(currentActivity.id)")
print("Ending tracked Live Activity: \(currentActivity.id)")
await currentActivity.end(nil, dismissalPolicy: .immediate)
self.currentActivity = nil
print("Tracked Live Activity ended successfully")
print("Tracked Live Activity ended successfully")
}
// Force end ALL active activities of our type to ensure cleanup
print("🔍 Checking for any remaining active activities...")
print("Checking for any remaining active activities...")
let activities = Activity<SessionActivityAttributes>.activities
if activities.isEmpty {
print(" No additional activities found")
print("No additional activities found")
} else {
print("🔴 Found \(activities.count) additional active activities, ending them...")
print("Found \(activities.count) additional active activities, ending them...")
for activity in activities {
print("🔴 Force ending activity: \(activity.id)")
print("Force ending activity: \(activity.id)")
await activity.end(nil, dismissalPolicy: .immediate)
}
print("All Live Activities ended successfully")
print("All Live Activities ended successfully")
}
}
@@ -188,7 +188,7 @@ final class LiveActivityManager {
if let currentActivity = currentActivity {
let isStillActive = activities.contains { $0.id == currentActivity.id }
if !isStillActive {
print("🧹 Cleaning up dismissed Live Activity: \(currentActivity.id)")
print("Cleaning up dismissed Live Activity: \(currentActivity.id)")
self.currentActivity = nil
}
}
@@ -211,7 +211,7 @@ final class LiveActivityManager {
func stopHealthChecks() {
healthCheckTimer?.invalidate()
healthCheckTimer = nil
print("🛑 Stopped Live Activity health checks")
print("Stopped Live Activity health checks")
}
/// Perform a health check on the current Live Activity
@@ -231,7 +231,7 @@ final class LiveActivityManager {
let isStillActive = activities.contains { $0.id == currentActivity.id }
if !isStillActive {
print("💔 Health check failed - Live Activity was dismissed")
print("Health check failed - Live Activity was dismissed")
self.currentActivity = nil
// Notify that we need to restart
@@ -240,7 +240,7 @@ final class LiveActivityManager {
object: nil
)
} else {
print("Live Activity health check passed")
print("Live Activity health check passed")
}
}

View File

@@ -42,7 +42,7 @@ struct AddAttemptView: View {
}
var body: some View {
NavigationView {
NavigationStack {
Form {
if !showingCreateProblem {
ProblemSelectionSection()
@@ -597,7 +597,7 @@ struct ProblemExpandedView: View {
@State private var selectedImageIndex = 0
var body: some View {
NavigationView {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
// Images
@@ -735,7 +735,7 @@ struct EditAttemptView: View {
}
var body: some View {
NavigationView {
NavigationStack {
Form {
if !showingCreateProblem {
ProblemSelectionSection()

View File

@@ -1,4 +1,3 @@
import SwiftUI
struct AddEditGymView: View {
@@ -34,7 +33,7 @@ struct AddEditGymView: View {
}
var body: some View {
NavigationView {
NavigationStack {
Form {
BasicInfoSection()
ClimbTypesSection()

View File

@@ -55,7 +55,7 @@ struct AddEditProblemView: View {
}
var body: some View {
NavigationView {
NavigationStack {
Form {
GymSelectionSection()
BasicInfoSection()

View File

@@ -1,4 +1,3 @@
import SwiftUI
struct AddEditSessionView: View {
@@ -21,7 +20,7 @@ struct AddEditSessionView: View {
}
var body: some View {
NavigationView {
NavigationStack {
Form {
GymSelectionSection()
SessionDetailsSection()

View File

@@ -4,7 +4,7 @@ struct AnalyticsView: View {
@EnvironmentObject var dataManager: ClimbingDataManager
var body: some View {
NavigationView {
NavigationStack {
ScrollView {
LazyVStack(spacing: 20) {
OverallStatsSection()

View File

@@ -420,7 +420,7 @@ struct ImageViewerView: View {
}
var body: some View {
NavigationView {
NavigationStack {
TabView(selection: $currentIndex) {
ForEach(imagePaths.indices, id: \.self) { index in
ProblemDetailImageFullView(imagePath: imagePaths[index])

View File

@@ -9,24 +9,11 @@ struct SessionDetailView: View {
@State private var showingAddAttempt = false
@State private var editingAttempt: Attempt?
@State private var attemptToDelete: Attempt?
@State private var currentTime = Date()
private var session: ClimbSession? {
dataManager.session(withId: sessionId)
}
private func startTimer() {
// Update every 5 seconds instead of 1 second for better performance
timer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { _ in
currentTime = Date()
}
}
private func stopTimer() {
timer?.invalidate()
timer = nil
}
private var gym: Gym? {
guard let session = session else { return nil }
return dataManager.gym(withId: session.gymId)
@@ -47,14 +34,12 @@ struct SessionDetailView: View {
calculateSessionStats()
}
@State private var timer: Timer?
var body: some View {
ScrollView {
LazyVStack(spacing: 20) {
if let session = session, let gym = gym {
SessionHeaderCard(
session: session, gym: gym, stats: sessionStats, currentTime: currentTime)
session: session, gym: gym, stats: sessionStats)
SessionStatsCard(stats: sessionStats)
@@ -69,12 +54,7 @@ struct SessionDetailView: View {
}
.padding()
}
.onAppear {
startTimer()
}
.onDisappear {
stopTimer()
}
.navigationTitle("Session Details")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
@@ -182,7 +162,6 @@ struct SessionHeaderCard: View {
let session: ClimbSession
let gym: Gym
let stats: SessionStats
let currentTime: Date
var body: some View {
VStack(alignment: .leading, spacing: 16) {
@@ -197,9 +176,13 @@ struct SessionHeaderCard: View {
if session.status == .active {
if let startTime = session.startTime {
Text("Duration: \(formatDuration(from: startTime, to: currentTime))")
Text("Duration: ")
.font(.subheadline)
.foregroundColor(.secondary)
+ Text(timerInterval: startTime...Date.distantFuture, countsDown: false)
.font(.subheadline)
.foregroundColor(.secondary)
.monospacedDigit()
}
} else if let duration = session.duration {
Text("Duration: \(duration) minutes")
@@ -246,20 +229,6 @@ struct SessionHeaderCard: View {
return formatter.string(from: date)
}
private func formatDuration(from start: Date, to end: Date) -> String {
let interval = end.timeIntervalSince(start)
let hours = Int(interval) / 3600
let minutes = Int(interval) % 3600 / 60
let seconds = Int(interval) % 60
if hours > 0 {
return String(format: "%dh %dm %ds", hours, minutes, seconds)
} else if minutes > 0 {
return String(format: "%dm %ds", minutes, seconds)
} else {
return String(format: "%ds", seconds)
}
}
}
struct SessionStatsCard: View {

View File

@@ -5,7 +5,7 @@ struct GymsView: View {
@State private var showingAddGym = false
var body: some View {
NavigationView {
NavigationStack {
VStack {
if dataManager.gyms.isEmpty {
EmptyGymsView()

View File

@@ -9,7 +9,7 @@ struct LiveActivityDebugView: View {
@State private var isTestRunning = false
var body: some View {
NavigationView {
NavigationStack {
VStack(alignment: .leading, spacing: 20) {
// Header
@@ -87,7 +87,7 @@ struct LiveActivityDebugView: View {
.disabled(dataManager.activeSession == nil)
if dataManager.gyms.isEmpty {
Text("⚠️ Add at least one gym to test Live Activities")
Text("WARNING: Add at least one gym to test Live Activities")
.font(.caption)
.foregroundColor(.orange)
}
@@ -167,29 +167,31 @@ struct LiveActivityDebugView: View {
}
private func checkStatus() {
appendDebugOutput("🔍 Checking Live Activity status...")
appendDebugOutput("Checking Live Activity status...")
let status = LiveActivityManager.shared.checkLiveActivityAvailability()
appendDebugOutput("Status: \(status)")
// Check iOS version
if #available(iOS 16.1, *) {
appendDebugOutput("iOS version supports Live Activities")
appendDebugOutput("iOS version supports Live Activities")
} else {
appendDebugOutput("❌ iOS version does not support Live Activities (requires 16.1+)")
appendDebugOutput(
"ERROR: iOS version does not support Live Activities (requires 16.1+)")
}
// Check if we're on simulator
#if targetEnvironment(simulator)
appendDebugOutput("⚠️ Running on Simulator - Live Activities have limited functionality")
appendDebugOutput(
"WARNING: Running on Simulator - Live Activities have limited functionality")
#else
appendDebugOutput("Running on device - Live Activities should work fully")
appendDebugOutput("Running on device - Live Activities should work fully")
#endif
}
private func testLiveActivity() {
guard !dataManager.gyms.isEmpty else {
appendDebugOutput(" No gyms available for testing")
appendDebugOutput("ERROR: No gyms available for testing")
return
}
@@ -240,25 +242,25 @@ struct LiveActivityDebugView: View {
appendDebugOutput("Ending Live Activity...")
await LiveActivityManager.shared.endLiveActivity()
appendDebugOutput("🏁 Live Activity test completed!")
appendDebugOutput("Live Activity test completed!")
}
}
private func endCurrentSession() {
guard let activeSession = dataManager.activeSession else {
appendDebugOutput(" No active session to end")
appendDebugOutput("ERROR: No active session to end")
return
}
appendDebugOutput("🛑 Ending current session: \(activeSession.id)")
appendDebugOutput("Ending current session: \(activeSession.id)")
dataManager.endSession(activeSession.id)
appendDebugOutput("Session ended")
appendDebugOutput("Session ended")
}
private func forceLiveActivityUpdate() {
appendDebugOutput("🔄 Forcing Live Activity update...")
appendDebugOutput("Forcing Live Activity update...")
dataManager.forceLiveActivityUpdate()
appendDebugOutput("Live Activity update sent")
appendDebugOutput("Live Activity update sent")
}
}

View File

@@ -6,6 +6,8 @@ struct ProblemsView: View {
@State private var selectedClimbType: ClimbType?
@State private var selectedGym: Gym?
@State private var searchText = ""
@State private var showingSearch = false
@FocusState private var isSearchFocused: Bool
private var filteredProblems: [Problem] {
var filtered = dataManager.problems
@@ -38,29 +40,67 @@ struct ProblemsView: View {
}
var body: some View {
NavigationView {
VStack(spacing: 0) {
if !dataManager.problems.isEmpty {
FilterSection(
selectedClimbType: $selectedClimbType,
selectedGym: $selectedGym,
filteredProblems: filteredProblems
)
.padding()
.background(.regularMaterial)
}
NavigationStack {
Group {
VStack(spacing: 0) {
if showingSearch {
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.foregroundColor(.secondary)
.font(.system(size: 16, weight: .medium))
if filteredProblems.isEmpty {
EmptyProblemsView(
isEmpty: dataManager.problems.isEmpty,
isFiltered: !dataManager.problems.isEmpty
)
} else {
ProblemsList(problems: filteredProblems)
TextField("Search problems...", text: $searchText)
.textFieldStyle(.plain)
.font(.system(size: 16))
.focused($isSearchFocused)
.submitLabel(.search)
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background {
if #available(iOS 18.0, *) {
RoundedRectangle(cornerRadius: 12)
.fill(.regularMaterial)
.overlay {
RoundedRectangle(cornerRadius: 12)
.stroke(.quaternary, lineWidth: 0.5)
}
} else {
RoundedRectangle(cornerRadius: 10)
.fill(Color(.systemGray6))
.overlay {
RoundedRectangle(cornerRadius: 10)
.stroke(Color(.systemGray4), lineWidth: 0.5)
}
}
}
.padding(.horizontal)
.padding(.top, 8)
.animation(.easeInOut(duration: 0.3), value: showingSearch)
}
if !dataManager.problems.isEmpty && !showingSearch {
FilterSection(
selectedClimbType: $selectedClimbType,
selectedGym: $selectedGym,
filteredProblems: filteredProblems
)
.padding()
.background(.regularMaterial)
}
if filteredProblems.isEmpty {
EmptyProblemsView(
isEmpty: dataManager.problems.isEmpty,
isFiltered: !dataManager.problems.isEmpty
)
} else {
ProblemsList(problems: filteredProblems)
}
}
}
.navigationTitle("Problems")
.searchable(text: $searchText, prompt: "Search problems...")
.navigationBarTitleDisplayMode(.automatic)
.toolbar {
ToolbarItemGroup(placement: .navigationBarTrailing) {
if dataManager.isSyncing {
@@ -81,6 +121,22 @@ struct ProblemsView: View {
)
}
Button(action: {
withAnimation(.easeInOut(duration: 0.3)) {
showingSearch.toggle()
if showingSearch {
isSearchFocused = true
} else {
searchText = ""
isSearchFocused = false
}
}
}) {
Image(systemName: showingSearch ? "xmark.circle.fill" : "magnifyingglass")
.font(.system(size: 16, weight: .medium))
.foregroundColor(showingSearch ? .secondary : .blue)
}
if !dataManager.gyms.isEmpty {
Button("Add") {
showingAddProblem = true

View File

@@ -6,7 +6,7 @@ struct SessionsView: View {
@State private var showingAddSession = false
var body: some View {
NavigationView {
NavigationStack {
Group {
if dataManager.sessions.isEmpty && dataManager.activeSession == nil {
EmptySessionsView()
@@ -53,7 +53,6 @@ struct SessionsView: View {
AddEditSessionView()
}
}
.navigationViewStyle(.stack)
}
}
@@ -129,11 +128,8 @@ struct ActiveSessionBanner: View {
let session: ClimbSession
let gym: Gym
@EnvironmentObject var dataManager: ClimbingDataManager
@State private var currentTime = Date()
@State private var navigateToDetail = false
@State private var timer: Timer?
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 4) {
@@ -151,9 +147,10 @@ struct ActiveSessionBanner: View {
.foregroundColor(.secondary)
if let startTime = session.startTime {
Text(formatDuration(from: startTime, to: currentTime))
Text(timerInterval: startTime...Date.distantFuture, countsDown: false)
.font(.caption)
.foregroundColor(.secondary)
.monospacedDigit()
}
}
.frame(maxWidth: .infinity, alignment: .leading)
@@ -180,42 +177,12 @@ struct ActiveSessionBanner: View {
.fill(.green.opacity(0.1))
.stroke(.green.opacity(0.3), lineWidth: 1)
)
.onAppear {
startTimer()
}
.onDisappear {
stopTimer()
}
.navigationDestination(isPresented: $navigateToDetail) {
SessionDetailView(sessionId: session.id)
}
}
private func formatDuration(from start: Date, to end: Date) -> String {
let interval = end.timeIntervalSince(start)
let hours = Int(interval) / 3600
let minutes = Int(interval) % 3600 / 60
let seconds = Int(interval) % 60
if hours > 0 {
return String(format: "%dh %dm %ds", hours, minutes, seconds)
} else if minutes > 0 {
return String(format: "%dm %ds", minutes, seconds)
} else {
return String(format: "%ds", seconds)
}
}
private func startTimer() {
timer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { _ in
currentTime = Date()
}
}
private func stopTimer() {
timer?.invalidate()
timer = nil
}
}
struct SessionRow: View {

View File

@@ -11,49 +11,52 @@ struct SettingsView: View {
@State private var activeSheet: SheetType?
var body: some View {
List {
SyncSection()
.environmentObject(dataManager.syncService)
NavigationStack {
List {
SyncSection()
.environmentObject(dataManager.syncService)
DataManagementSection(
activeSheet: $activeSheet
)
DataManagementSection(
activeSheet: $activeSheet
)
AppInfoSection()
}
.navigationTitle("Settings")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
if dataManager.isSyncing {
HStack(spacing: 2) {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .blue))
.scaleEffect(0.6)
AppInfoSection()
}
.navigationTitle("Settings")
.navigationBarTitleDisplayMode(.automatic)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
if dataManager.isSyncing {
HStack(spacing: 2) {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .blue))
.scaleEffect(0.6)
}
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(
Circle()
.fill(.regularMaterial)
)
.transition(.scale.combined(with: .opacity))
.animation(
.easeInOut(duration: 0.2), value: dataManager.isSyncing
)
}
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(
Circle()
.fill(.regularMaterial)
)
.transition(.scale.combined(with: .opacity))
.animation(
.easeInOut(duration: 0.2), value: dataManager.isSyncing
)
}
}
}
.sheet(
item: Binding<SheetType?>(
get: { activeSheet },
set: { activeSheet = $0 }
)
) { sheetType in
switch sheetType {
case .export(let data):
ExportDataView(data: data)
case .importData:
ImportDataView()
.sheet(
item: Binding<SheetType?>(
get: { activeSheet },
set: { activeSheet = $0 }
)
) { sheetType in
switch sheetType {
case .export(let data):
ExportDataView(data: data)
case .importData:
ImportDataView()
}
}
}
}
@@ -191,7 +194,7 @@ struct ExportDataView: View {
@State private var isCreatingFile = true
var body: some View {
NavigationView {
NavigationStack {
VStack(spacing: 30) {
if isCreatingFile {
// Loading state - more prominent
@@ -498,7 +501,7 @@ struct SyncSettingsView: View {
@State private var testResultMessage = ""
var body: some View {
NavigationView {
NavigationStack {
Form {
Section {
TextField("Server URL", text: $serverURL)
@@ -691,7 +694,7 @@ struct ImportDataView: View {
@State private var showingDocumentPicker = false
var body: some View {
NavigationView {
NavigationStack {
VStack(spacing: 20) {
Image(systemName: "square.and.arrow.down")
.font(.system(size: 60))
@@ -705,7 +708,7 @@ struct ImportDataView: View {
Text("Import climbing data from a previously exported ZIP file.")
.multilineTextAlignment(.center)
Text("⚠️ Warning: This will replace all current data!")
Text("WARNING: This will replace all current data!")
.font(.subheadline)
.foregroundColor(.red)
.multilineTextAlignment(.center)