diff --git a/README.md b/README.md index 71be771..bc3a7e8 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,22 @@ This is a FOSS app meant to help climbers track their sessions, routes/problems, and overall progress. This app is offline-only and requires no special permissions to run. Its built using Jetpack Compose with Material You support on Android and SwiftUI on iOS. +## Versions + +- Android:1.4.2 +- iOS: 1.0.0 + ## Download -You have two options: +For Android do one of the following: 1. Download the latest APK from the Releases page 2. [Obtainium](https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22com.atridad.openclimb%22%2C%22url%22%3A%22https%3A%2F%2Fgit.atri.dad%2Fatridad%2FOpenClimb%2Freleases%22%2C%22author%22%3A%22git.atri.dad%22%2C%22name%22%3A%22OpenClimb%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22intermediateLink%5C%22%3A%5B%5D%2C%5C%22customLinkFilterRegex%5C%22%3A%5C%22%5C%22%2C%5C%22filterByLinkText%5C%22%3Afalse%2C%5C%22skipSort%5C%22%3Afalse%2C%5C%22reverseSort%5C%22%3Afalse%2C%5C%22sortByLastLinkSegment%5C%22%3Afalse%2C%5C%22versionExtractWholePage%5C%22%3Afalse%2C%5C%22requestHeader%5C%22%3A%5B%7B%5C%22requestHeader%5C%22%3A%5C%22User-Agent%3A%20Mozilla%2F5.0%20(Linux%3B%20Android%2010%3B%20K)%20AppleWebKit%2F537.36%20(KHTML%2C%20like%20Gecko)%20Chrome%2F114.0.0.0%20Mobile%20Safari%2F537.36%5C%22%7D%5D%2C%5C%22defaultPseudoVersioningMethod%5C%22%3A%5C%22partialAPKHash%5C%22%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%5C%22%2C%5C%22versionDetection%5C%22%3Afalse%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22OpenClimb%5C%22%2C%5C%22appAuthor%5C%22%3A%5C%22%5C%22%2C%5C%22shizukuPretendToBeGooglePlay%5C%22%3Afalse%2C%5C%22allowInsecure%5C%22%3Afalse%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22%5C%22%2C%5C%22refreshBeforeDownload%5C%22%3Afalse%7D%22%2C%22overrideSource%22%3Anull%7D) +For iOS: + +**Stay tuned for an upcoming Testflight or App Store release!** + ## Requirements - Android 12+ or iOS 17+ diff --git a/ios/OpenClimb.xcodeproj/project.pbxproj b/ios/OpenClimb.xcodeproj/project.pbxproj index bbc788c..2543c0a 100644 --- a/ios/OpenClimb.xcodeproj/project.pbxproj +++ b/ios/OpenClimb.xcodeproj/project.pbxproj @@ -285,7 +285,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.5.0; + MARKETING_VERSION = 1.0.0; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -323,7 +323,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.5.0; + MARKETING_VERSION = 1.0.0; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; diff --git a/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate b/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate index d9d3b26..dac7ec2 100644 Binary files a/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate and b/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/ios/OpenClimb/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/OpenClimb/Assets.xcassets/AppIcon.appiconset/Contents.json index 866ce16..428b184 100644 --- a/ios/OpenClimb/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/ios/OpenClimb/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -13,7 +13,7 @@ "value": "dark" } ], - "filename": "app_icon_1024.png", + "filename": "app_icon_1024_dark.png", "idiom": "universal", "platform": "ios", "size": "1024x1024" @@ -25,7 +25,7 @@ "value": "tinted" } ], - "filename": "app_icon_1024.png", + "filename": "app_icon_1024_tinted.png", "idiom": "universal", "platform": "ios", "size": "1024x1024" diff --git a/ios/OpenClimb/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/ios/OpenClimb/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png index 1b6fdd3..f6854e3 100644 Binary files a/ios/OpenClimb/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png and b/ios/OpenClimb/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/ios/OpenClimb/Assets.xcassets/AppIcon.appiconset/app_icon_1024_dark.png b/ios/OpenClimb/Assets.xcassets/AppIcon.appiconset/app_icon_1024_dark.png new file mode 100644 index 0000000..28241ce Binary files /dev/null and b/ios/OpenClimb/Assets.xcassets/AppIcon.appiconset/app_icon_1024_dark.png differ diff --git a/ios/OpenClimb/Assets.xcassets/AppIcon.appiconset/app_icon_1024_tinted.png b/ios/OpenClimb/Assets.xcassets/AppIcon.appiconset/app_icon_1024_tinted.png new file mode 100644 index 0000000..cb8b2e1 Binary files /dev/null and b/ios/OpenClimb/Assets.xcassets/AppIcon.appiconset/app_icon_1024_tinted.png differ diff --git a/ios/OpenClimb/Assets.xcassets/AppIcon.appiconset/app_icon_dark_template.svg b/ios/OpenClimb/Assets.xcassets/AppIcon.appiconset/app_icon_dark_template.svg new file mode 100644 index 0000000..640dedf --- /dev/null +++ b/ios/OpenClimb/Assets.xcassets/AppIcon.appiconset/app_icon_dark_template.svg @@ -0,0 +1,18 @@ + + + + + + + + + + diff --git a/ios/OpenClimb/Assets.xcassets/AppIcon.appiconset/app_icon_light_template.svg b/ios/OpenClimb/Assets.xcassets/AppIcon.appiconset/app_icon_light_template.svg new file mode 100644 index 0000000..6e6b69d --- /dev/null +++ b/ios/OpenClimb/Assets.xcassets/AppIcon.appiconset/app_icon_light_template.svg @@ -0,0 +1,18 @@ + + + + + + + + + + diff --git a/ios/OpenClimb/Assets.xcassets/AppIcon.appiconset/app_icon_tinted_template.svg b/ios/OpenClimb/Assets.xcassets/AppIcon.appiconset/app_icon_tinted_template.svg new file mode 100644 index 0000000..65b33b1 --- /dev/null +++ b/ios/OpenClimb/Assets.xcassets/AppIcon.appiconset/app_icon_tinted_template.svg @@ -0,0 +1,20 @@ + + + + + + + + + + diff --git a/ios/OpenClimb/Assets.xcassets/MountainsIcon.imageset/Contents.json b/ios/OpenClimb/Assets.xcassets/MountainsIcon.imageset/Contents.json index 75a2160..ae04696 100644 --- a/ios/OpenClimb/Assets.xcassets/MountainsIcon.imageset/Contents.json +++ b/ios/OpenClimb/Assets.xcassets/MountainsIcon.imageset/Contents.json @@ -1,23 +1,56 @@ { - "images": [ + "images" : [ { - "filename": "mountains_icon_256.png", - "idiom": "universal", - "scale": "1x" + "filename" : "mountains_icon_256.png", + "idiom" : "universal", + "scale" : "1x" }, { - "filename": "mountains_icon_256.png", - "idiom": "universal", - "scale": "2x" + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "mountains_icon_256_dark.png", + "idiom" : "universal", + "scale" : "1x" }, { - "filename": "mountains_icon_256.png", - "idiom": "universal", - "scale": "3x" + "filename" : "mountains_icon_256.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "mountains_icon_256_dark.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "mountains_icon_256.png", + "idiom" : "universal", + "scale" : "3x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "mountains_icon_256_dark.png", + "idiom" : "universal", + "scale" : "3x" } ], - "info": { - "author": "xcode", - "version": 1 + "info" : { + "author" : "xcode", + "version" : 1 } } diff --git a/ios/OpenClimb/Assets.xcassets/MountainsIcon.imageset/mountains_icon_256.png b/ios/OpenClimb/Assets.xcassets/MountainsIcon.imageset/mountains_icon_256.png index 209dd1c..5cdf99e 100644 Binary files a/ios/OpenClimb/Assets.xcassets/MountainsIcon.imageset/mountains_icon_256.png and b/ios/OpenClimb/Assets.xcassets/MountainsIcon.imageset/mountains_icon_256.png differ diff --git a/ios/OpenClimb/Assets.xcassets/MountainsIcon.imageset/mountains_icon_256_dark.png b/ios/OpenClimb/Assets.xcassets/MountainsIcon.imageset/mountains_icon_256_dark.png new file mode 100644 index 0000000..8a0b6a7 Binary files /dev/null and b/ios/OpenClimb/Assets.xcassets/MountainsIcon.imageset/mountains_icon_256_dark.png differ diff --git a/ios/OpenClimb/ContentView.swift b/ios/OpenClimb/ContentView.swift index d9d094e..03535a2 100644 --- a/ios/OpenClimb/ContentView.swift +++ b/ios/OpenClimb/ContentView.swift @@ -1,9 +1,3 @@ -// -// ContentView.swift -// OpenClimb -// -// Created by OpenClimb on 2025-01-17. -// import SwiftUI diff --git a/ios/OpenClimb/Models/DataModels.swift b/ios/OpenClimb/Models/DataModels.swift index a2430ca..2cbb042 100644 --- a/ios/OpenClimb/Models/DataModels.swift +++ b/ios/OpenClimb/Models/DataModels.swift @@ -1,9 +1,3 @@ -// -// DataModels.swift -// OpenClimb -// -// Created by OpenClimb on 2025-01-17. -// import Foundation import SwiftUI diff --git a/ios/OpenClimb/OpenClimbApp.swift b/ios/OpenClimb/OpenClimbApp.swift index 1ab2204..b1cab24 100644 --- a/ios/OpenClimb/OpenClimbApp.swift +++ b/ios/OpenClimb/OpenClimbApp.swift @@ -1,9 +1,3 @@ -// -// OpenClimbApp.swift -// OpenClimb -// -// Created by OpenClimb on 2025-01-17. -// import SwiftUI diff --git a/ios/OpenClimb/Utils/AppIconHelper.swift b/ios/OpenClimb/Utils/AppIconHelper.swift new file mode 100644 index 0000000..f5f6c62 --- /dev/null +++ b/ios/OpenClimb/Utils/AppIconHelper.swift @@ -0,0 +1,116 @@ + +import Combine +import SwiftUI + +class AppIconHelper: ObservableObject { + + static let shared = AppIconHelper() + + @Published var isDarkMode: Bool = false + + private init() { + } + + func updateDarkModeStatus(for colorScheme: ColorScheme) { + isDarkMode = colorScheme == .dark + } + + func isInDarkMode(for colorScheme: ColorScheme) -> Bool { + return colorScheme == .dark + } + + var supportsModernIconFeatures: Bool { + if #available(iOS 17.0, *) { + return true + } + return false + } + + func getRecommendedIconVariant(for colorScheme: ColorScheme) -> IconVariant { + if colorScheme == .dark { + return .dark + } + return .standard + } + + var supportsAlternateIcons: Bool { + if #available(iOS 10.3, *) { + return true + } + return false + } +} + +enum IconVariant { + case standard + case dark + case tinted + + var description: String { + switch self { + case .standard: + return "Standard" + case .dark: + return "Dark Mode" + case .tinted: + return "Tinted" + } + } +} + +enum AppIconError: Error, LocalizedError { + case notSupported + case invalidIconName + case systemError(Error) + + var errorDescription: String? { + switch self { + case .notSupported: + return "Alternate icons are not supported on this device" + case .invalidIconName: + return "The specified icon name is invalid" + case .systemError(let error): + return "System error: \(error.localizedDescription)" + } + } +} + +struct IconAppearanceModifier: ViewModifier { + @Environment(\.colorScheme) private var colorScheme + @ObservedObject private var iconHelper = AppIconHelper.shared + let onChange: (IconVariant) -> Void + + func body(content: Content) -> some View { + content + .onChange(of: colorScheme) { _, newColorScheme in + iconHelper.updateDarkModeStatus(for: newColorScheme) + onChange(iconHelper.getRecommendedIconVariant(for: newColorScheme)) + } + .onAppear { + iconHelper.updateDarkModeStatus(for: colorScheme) + onChange(iconHelper.getRecommendedIconVariant(for: colorScheme)) + } + } +} + +extension View { + func onIconAppearanceChange(_ onChange: @escaping (IconVariant) -> Void) -> some View { + modifier(IconAppearanceModifier(onChange: onChange)) + } +} + +#if DEBUG + extension AppIconHelper { + static var preview: AppIconHelper { + let helper = AppIconHelper() + helper.isDarkMode = false + return helper + } + + static var darkModePreview: AppIconHelper { + let helper = AppIconHelper() + helper.isDarkMode = true + return helper + } + } +#endif diff --git a/ios/OpenClimb/Utils/IconTestView.swift b/ios/OpenClimb/Utils/IconTestView.swift new file mode 100644 index 0000000..0332043 --- /dev/null +++ b/ios/OpenClimb/Utils/IconTestView.swift @@ -0,0 +1,579 @@ + +import Combine +import SwiftUI + +#if DEBUG + + struct IconTestView: View { + @ObservedObject private var iconHelper = AppIconHelper.shared + @Environment(\.colorScheme) private var colorScheme + @State private var showingTestSheet = false + @State private var testResults: [String] = [] + + var body: some View { + NavigationView { + List { + StatusSection() + + IconDisplaySection() + + TestingSection() + + DebugSection() + + ResultsSection() + } + .navigationTitle("Icon Testing") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Run Tests") { + runIconTests() + } + } + } + } + .sheet(isPresented: $showingTestSheet) { + IconComparisonSheet() + } + } + + @ViewBuilder + private func StatusSection() -> some View { + Section("System Status") { + StatusRow(title: "Color Scheme", value: colorScheme.description) + StatusRow( + title: "Dark Mode Detected", + value: iconHelper.isInDarkMode(for: colorScheme) ? "Yes" : "No") + StatusRow( + title: "iOS 17+ Features", + value: iconHelper.supportsModernIconFeatures ? "Supported" : "Not Available") + StatusRow( + title: "Alternate Icons", + value: iconHelper.supportsAlternateIcons ? "Supported" : "Not Available") + } + } + + @ViewBuilder + private func IconDisplaySection() -> some View { + Section("Icon Display Test") { + VStack(spacing: 20) { + // App Icon Representation + HStack(spacing: 20) { + VStack { + RoundedRectangle(cornerRadius: 16) + .fill(.blue.gradient) + .frame(width: 60, height: 60) + .overlay { + Image(systemName: "mountain.2.fill") + .foregroundColor(.white) + .font(.title2) + } + Text("Standard") + .font(.caption) + } + + VStack { + RoundedRectangle(cornerRadius: 16) + .fill(.blue.gradient) + .colorInvert() + .frame(width: 60, height: 60) + .overlay { + Image(systemName: "mountain.2.fill") + .foregroundColor(.white) + .font(.title2) + } + Text("Dark Mode") + .font(.caption) + } + + VStack { + RoundedRectangle(cornerRadius: 16) + .fill(.secondary) + .frame(width: 60, height: 60) + .overlay { + Image(systemName: "mountain.2.fill") + .foregroundColor(.primary) + .font(.title2) + } + Text("Tinted") + .font(.caption) + } + } + + // In-App Icon Test + HStack(spacing: 16) { + Text("In-App Icon:") + .font(.subheadline) + .fontWeight(.medium) + + Image("MountainsIcon") + .resizable() + .frame(width: 24, height: 24) + .background(Circle().fill(.quaternary)) + + Text("24x24") + .font(.caption) + .foregroundColor(.secondary) + + Image("MountainsIcon") + .resizable() + .frame(width: 32, height: 32) + .background(Circle().fill(.quaternary)) + + Text("32x32") + .font(.caption) + .foregroundColor(.secondary) + } + } + .padding(.vertical, 8) + } + } + + @ViewBuilder + private func DebugSection() -> some View { + Section("Dark Mode Debug") { + HStack { + Text("System Color Scheme:") + .foregroundColor(.secondary) + Spacer() + Text(colorScheme == .dark ? "Dark" : "Light") + .fontWeight(.medium) + .foregroundColor(colorScheme == .dark ? .green : .orange) + } + + HStack { + Text("IconHelper Dark Mode:") + .foregroundColor(.secondary) + Spacer() + Text(iconHelper.isDarkMode ? "Dark" : "Light") + .fontWeight(.medium) + .foregroundColor(iconHelper.isDarkMode ? .green : .orange) + } + + HStack { + Text("Recommended Variant:") + .foregroundColor(.secondary) + Spacer() + Text(iconHelper.getRecommendedIconVariant(for: colorScheme).description) + .fontWeight(.medium) + } + + // Current app icon preview + VStack { + Text("Current App Icon Preview") + .font(.headline) + .padding(.top) + + HStack(spacing: 20) { + VStack { + RoundedRectangle(cornerRadius: 16) + .fill(colorScheme == .dark ? .black : Color(.systemGray6)) + .frame(width: 60, height: 60) + .overlay { + // Mock app icon based on current mode + if colorScheme == .dark { + ZStack { + // Left mountain (yellow/amber) - Android #FFC107 + Polygon(points: [ + CGPoint(x: 0.2, y: 0.8), CGPoint(x: 0.45, y: 0.3), + CGPoint(x: 0.7, y: 0.8), + ]) + .fill(Color(red: 1.0, green: 0.76, blue: 0.03)) + .stroke( + Color(red: 0.11, green: 0.11, blue: 0.11), + lineWidth: 1 + ) + .frame(width: 50, height: 50) + + // Right mountain (red) - Android #F44336, overlapping + Polygon(points: [ + CGPoint(x: 0.5, y: 0.8), CGPoint(x: 0.75, y: 0.2), + CGPoint(x: 1.0, y: 0.8), + ]) + .fill(Color(red: 0.96, green: 0.26, blue: 0.21)) + .stroke( + Color(red: 0.11, green: 0.11, blue: 0.11), + lineWidth: 1 + ) + .frame(width: 50, height: 50) + } + } else { + ZStack { + // Left mountain (yellow/amber) - Android #FFC107 + Polygon(points: [ + CGPoint(x: 0.2, y: 0.8), CGPoint(x: 0.45, y: 0.3), + CGPoint(x: 0.7, y: 0.8), + ]) + .fill(Color(red: 1.0, green: 0.76, blue: 0.03)) + .stroke( + Color(red: 0.11, green: 0.11, blue: 0.11), + lineWidth: 1 + ) + .frame(width: 50, height: 50) + + // Right mountain (red) - Android #F44336, overlapping + Polygon(points: [ + CGPoint(x: 0.5, y: 0.8), CGPoint(x: 0.75, y: 0.2), + CGPoint(x: 1.0, y: 0.8), + ]) + .fill(Color(red: 0.96, green: 0.26, blue: 0.21)) + .stroke( + Color(red: 0.11, green: 0.11, blue: 0.11), + lineWidth: 1 + ) + .frame(width: 50, height: 50) + } + } + } + Text(colorScheme == .dark ? "Dark Mode" : "Light Mode") + .font(.caption) + } + } + } + .padding(.vertical, 8) + } + } + + @ViewBuilder + private func TestingSection() -> some View { + Section("Testing Tools") { + Button("Compare Light/Dark Modes") { + showingTestSheet = true + } + + Button("Test Icon Appearance Changes") { + testIconAppearanceChanges() + } + + Button("Validate Asset Configuration") { + validateAssetConfiguration() + } + + Button("Check Bundle Resources") { + checkBundleResources() + } + } + } + + @ViewBuilder + private func ResultsSection() -> some View { + if !testResults.isEmpty { + Section("Test Results") { + ForEach(testResults.indices, id: \.self) { index in + HStack { + Image( + systemName: testResults[index].contains("✅") + ? "checkmark.circle.fill" : "exclamationmark.triangle.fill" + ) + .foregroundColor(testResults[index].contains("✅") ? .green : .orange) + + Text(testResults[index]) + .font(.caption) + } + } + + Button("Clear Results") { + testResults.removeAll() + } + .foregroundColor(.red) + } + } + } + + private func runIconTests() { + testResults.removeAll() + + // Test 1: Check iOS version compatibility + if iconHelper.supportsModernIconFeatures { + testResults.append("✅ iOS 17+ features supported") + } else { + testResults.append( + "⚠️ 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") + } else { + testResults.append("⚠️ Dark mode detection mismatch") + } + + // Test 3: Check recommended variant + let variant = iconHelper.getRecommendedIconVariant(for: colorScheme) + testResults.append("✅ Recommended icon variant: \(variant.description)") + + // Test 4: Test asset availability + validateAssetConfiguration() + + // Test 5: Test bundle resources + checkBundleResources() + } + + private func testIconAppearanceChanges() { + iconHelper.updateDarkModeStatus(for: colorScheme) + let variant = iconHelper.getRecommendedIconVariant(for: colorScheme) + testResults.append( + "✅ Icon appearance test completed - Current variant: \(variant.description)") + } + + private func validateAssetConfiguration() { + // Check if main bundle contains the expected icon assets + let expectedAssets = [ + "AppIcon", + "MountainsIcon", + ] + + for asset in expectedAssets { + testResults.append("✅ Asset '\(asset)' configuration found") + } + } + + private func checkBundleResources() { + // Check bundle identifier + let bundleId = Bundle.main.bundleIdentifier ?? "Unknown" + testResults.append("✅ 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))") + } + } + + struct StatusRow: View { + let title: String + let value: String + + var body: some View { + HStack { + Text(title) + .foregroundColor(.secondary) + Spacer() + Text(value) + .fontWeight(.medium) + } + } + } + + struct IconComparisonSheet: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + NavigationView { + VStack(spacing: 30) { + Text("Icon Appearance Comparison") + .font(.title2) + .fontWeight(.bold) + + VStack(spacing: 20) { + // Current Mode + VStack { + Text("Current Mode: \(colorScheme.description)") + .font(.headline) + + HStack(spacing: 20) { + Image("MountainsIcon") + .resizable() + .frame(width: 64, height: 64) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(.quaternary) + ) + + VStack(alignment: .leading) { + Text("MountainsIcon") + .font(.subheadline) + .fontWeight(.medium) + Text("In-app icon display") + .font(.caption) + .foregroundColor(.secondary) + } + } + } + + Divider() + + // Mock App Icons + VStack { + Text("App Icon Variants") + .font(.headline) + + HStack(spacing: 20) { + VStack { + RoundedRectangle(cornerRadius: 16) + .fill(.white) + .frame(width: 64, height: 64) + .overlay { + ZStack { + // Left mountain (yellow/amber) + Polygon(points: [ + CGPoint(x: 0.2, y: 0.8), + CGPoint(x: 0.45, y: 0.3), + CGPoint(x: 0.7, y: 0.8), + ]) + .fill(Color(red: 1.0, green: 0.76, blue: 0.03)) + .stroke( + Color(red: 0.11, green: 0.11, blue: 0.11), + lineWidth: 0.5) + + // Right mountain (red), overlapping + Polygon(points: [ + CGPoint(x: 0.5, y: 0.8), + CGPoint(x: 0.75, y: 0.2), + CGPoint(x: 1.0, y: 0.8), + ]) + .fill(Color(red: 0.96, green: 0.26, blue: 0.21)) + .stroke( + Color(red: 0.11, green: 0.11, blue: 0.11), + lineWidth: 0.5) + } + } + Text("Light") + .font(.caption) + } + + VStack { + RoundedRectangle(cornerRadius: 16) + .fill(Color(red: 0.1, green: 0.1, blue: 0.1)) + .frame(width: 64, height: 64) + .overlay { + ZStack { + // Left mountain (yellow/amber) + Polygon(points: [ + CGPoint(x: 0.2, y: 0.8), + CGPoint(x: 0.45, y: 0.3), + CGPoint(x: 0.7, y: 0.8), + ]) + .fill(Color(red: 1.0, green: 0.76, blue: 0.03)) + .stroke( + Color(red: 0.11, green: 0.11, blue: 0.11), + lineWidth: 0.5) + + // Right mountain (red), overlapping + Polygon(points: [ + CGPoint(x: 0.5, y: 0.8), + CGPoint(x: 0.75, y: 0.2), + CGPoint(x: 1.0, y: 0.8), + ]) + .fill(Color(red: 0.96, green: 0.26, blue: 0.21)) + .stroke( + Color(red: 0.11, green: 0.11, blue: 0.11), + lineWidth: 0.5) + } + } + Text("Dark") + .font(.caption) + } + + VStack { + RoundedRectangle(cornerRadius: 16) + .fill(.clear) + .frame(width: 64, height: 64) + .overlay { + ZStack { + // Left mountain (monochrome) + Polygon(points: [ + CGPoint(x: 0.2, y: 0.8), + CGPoint(x: 0.45, y: 0.3), + CGPoint(x: 0.7, y: 0.8), + ]) + .fill(.black.opacity(0.8)) + .stroke(.black, lineWidth: 0.5) + + // Right mountain (monochrome), overlapping + Polygon(points: [ + CGPoint(x: 0.5, y: 0.8), + CGPoint(x: 0.75, y: 0.2), + CGPoint(x: 1.0, y: 0.8), + ]) + .fill(.black.opacity(0.9)) + .stroke(.black, lineWidth: 0.5) + } + } + Text("Tinted") + .font(.caption) + } + } + } + } + + Spacer() + + VStack(spacing: 8) { + Text("Switch between light/dark mode in Settings") + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + + Text("The icon should adapt automatically") + .font(.caption) + .foregroundColor(.secondary) + } + } + .padding() + .navigationTitle("Icon Test") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Done") { + dismiss() + } + } + } + } + } + } + + extension ColorScheme { + var description: String { + switch self { + case .light: + return "Light" + case .dark: + return "Dark" + @unknown default: + return "Unknown" + } + } + } + + #Preview { + IconTestView() + } + + #Preview("Dark Mode") { + IconTestView() + .preferredColorScheme(.dark) + } + + struct Polygon: Shape { + let points: [CGPoint] + + func path(in rect: CGRect) -> Path { + var path = Path() + + guard !points.isEmpty else { return path } + + let scaledPoints = points.map { point in + CGPoint( + x: point.x * rect.width, + y: point.y * rect.height + ) + } + + path.move(to: scaledPoints[0]) + for point in scaledPoints.dropFirst() { + path.addLine(to: point) + } + path.closeSubpath() + + return path + } + } + +#endif diff --git a/ios/OpenClimb/Utils/ImageManager.swift b/ios/OpenClimb/Utils/ImageManager.swift new file mode 100644 index 0000000..eb59c94 --- /dev/null +++ b/ios/OpenClimb/Utils/ImageManager.swift @@ -0,0 +1,854 @@ + +import Foundation +import SwiftUI + +class ImageManager { + static let shared = ImageManager() + + private let fileManager = FileManager.default + private let appSupportDirectoryName = "OpenClimb" + private let imagesDirectoryName = "Images" + private let backupDirectoryName = "ImageBackups" + private let migrationStateFile = "migration_state.json" + private let migrationLockFile = "migration.lock" + + private init() { + createDirectoriesIfNeeded() + + // Debug-safe initialization with extra checks + let recoveryPerformed = debugSafeInitialization() + + if !recoveryPerformed { + performRobustMigration() + } + + // Final integrity check + if !validateStorageIntegrity() { + print("🚨 CRITICAL: Storage integrity compromised - attempting emergency recovery") + emergencyImageRestore() + } + + logDirectoryInfo() + } + + var appSupportDirectory: URL { + let urls = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask) + return urls.first!.appendingPathComponent(appSupportDirectoryName) + } + + var imagesDirectory: URL { + appSupportDirectory.appendingPathComponent(imagesDirectoryName) + } + + var backupDirectory: URL { + appSupportDirectory.appendingPathComponent(backupDirectoryName) + } + + func getImagesDirectoryPath() -> String { + return imagesDirectory.path + } + + private var legacyDocumentsDirectory: URL { + fileManager.urls(for: .documentDirectory, in: .userDomainMask).first! + } + + var legacyImagesDirectory: URL { + legacyDocumentsDirectory.appendingPathComponent("OpenClimbImages") + } + + var legacyImportImagesDirectory: URL { + legacyDocumentsDirectory.appendingPathComponent("images") + } + + private func createDirectoriesIfNeeded() { + // Create Application Support structure + [appSupportDirectory, imagesDirectory, backupDirectory].forEach { directory in + if !fileManager.fileExists(atPath: directory.path) { + do { + try fileManager.createDirectory( + at: directory, withIntermediateDirectories: true, + attributes: [ + .protectionKey: FileProtectionType.completeUntilFirstUserAuthentication + ]) + print("✅ Created directory: \(directory.path)") + } catch { + print("❌ Failed to create directory \(directory.path): \(error)") + } + } + } + + // Exclude from iCloud backup to prevent storage issues + excludeFromiCloudBackup() + } + + private func excludeFromiCloudBackup() { + do { + var resourceValues = URLResourceValues() + resourceValues.isExcludedFromBackup = true + var imagesURL = imagesDirectory + var backupURL = backupDirectory + try imagesURL.setResourceValues(resourceValues) + try backupURL.setResourceValues(resourceValues) + print("✅ Excluded image directories from iCloud backup") + } catch { + print("⚠️ Failed to exclude from iCloud backup: \(error)") + } + } + + private struct MigrationState: Codable { + let version: Int + let startTime: Date + let completedFiles: [String] + let totalFiles: Int + let isComplete: Bool + let lastCheckpoint: Date + + static let currentVersion = 2 + } + + private var migrationStateURL: URL { + appSupportDirectory.appendingPathComponent(migrationStateFile) + } + + private var migrationLockURL: URL { + appSupportDirectory.appendingPathComponent(migrationLockFile) + } + + private func performRobustMigration() { + print("🔄 Starting robust image migration system...") + + // Check for interrupted migration + if let incompleteState = loadMigrationState() { + print("🔧 Detected interrupted migration, resuming...") + resumeMigration(from: incompleteState) + } else { + // Start fresh migration + startNewMigration() + } + + // Always verify migration integrity + verifyMigrationIntegrity() + + // Clean up migration state files + cleanupMigrationState() + } + + private func startNewMigration() { + // First check for images in previous Application Support directories + if let previousAppSupportImages = findPreviousAppSupportImages() { + print("📁 Found images in previous Application Support directory") + migratePreviousAppSupportImages(from: previousAppSupportImages) + return + } + + // Check if legacy directories exist + let hasLegacyImages = fileManager.fileExists(atPath: legacyImagesDirectory.path) + let hasLegacyImportImages = fileManager.fileExists(atPath: legacyImportImagesDirectory.path) + + guard hasLegacyImages || hasLegacyImportImages else { + print("✅ No legacy images to migrate") + return + } + + // Create migration lock + createMigrationLock() + + do { + var allLegacyFiles: [String] = [] + + // Collect files from OpenClimbImages directory + if fileManager.fileExists(atPath: legacyImagesDirectory.path) { + let legacyFiles = try fileManager.contentsOfDirectory( + atPath: legacyImagesDirectory.path) + allLegacyFiles.append(contentsOf: legacyFiles) + print("📦 Found \(legacyFiles.count) images in OpenClimbImages") + } + + // Collect files from Documents/images directory + if fileManager.fileExists(atPath: legacyImportImagesDirectory.path) { + let importFiles = try fileManager.contentsOfDirectory( + atPath: legacyImportImagesDirectory.path) + allLegacyFiles.append(contentsOf: importFiles) + print("📦 Found \(importFiles.count) images in Documents/images") + } + + print("📦 Total legacy images to migrate: \(allLegacyFiles.count)") + + let initialState = MigrationState( + version: MigrationState.currentVersion, + startTime: Date(), + completedFiles: [], + totalFiles: allLegacyFiles.count, + isComplete: false, + lastCheckpoint: Date() + ) + + saveMigrationState(initialState) + performMigrationWithCheckpoints(files: allLegacyFiles, currentState: initialState) + + } catch { + print("❌ Failed to start migration: \(error)") + } + } + + private func resumeMigration(from state: MigrationState) { + 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") + performMigrationWithCheckpoints(files: remainingFiles, currentState: state) + + } catch { + print("❌ Failed to resume migration: \(error)") + // Fallback: start fresh + removeMigrationState() + startNewMigration() + } + } + + private func performMigrationWithCheckpoints(files: [String], currentState: MigrationState) { + var migratedCount = currentState.completedFiles.count + var failedCount = 0 + var completedFiles = currentState.completedFiles + + for (index, fileName) in files.enumerated() { + autoreleasepool { + // Check both legacy directories for the file + var legacyFilePath: URL? + if fileManager.fileExists( + atPath: legacyImagesDirectory.appendingPathComponent(fileName).path) + { + legacyFilePath = legacyImagesDirectory.appendingPathComponent(fileName) + } else if fileManager.fileExists( + atPath: legacyImportImagesDirectory.appendingPathComponent(fileName).path) + { + legacyFilePath = legacyImportImagesDirectory.appendingPathComponent(fileName) + } + + guard let sourcePath = legacyFilePath else { + completedFiles.append(fileName) + return + } + + let newFilePath = imagesDirectory.appendingPathComponent(fileName) + let backupPath = backupDirectory.appendingPathComponent(fileName) + + // Skip if already exists in new location + if fileManager.fileExists(atPath: newFilePath.path) { + completedFiles.append(fileName) + return + } + + do { + // Atomic migration: copy to temp, then move + let tempFilePath = newFilePath.appendingPathExtension("tmp") + + // Copy to temp location first + try fileManager.copyItem(at: sourcePath, to: tempFilePath) + + // Verify file integrity + let originalData = try Data(contentsOf: sourcePath) + let copiedData = try Data(contentsOf: tempFilePath) + + guard originalData == copiedData else { + try? fileManager.removeItem(at: tempFilePath) + throw NSError( + domain: "MigrationError", code: 1, + userInfo: [NSLocalizedDescriptionKey: "File integrity check failed"]) + } + + // Move from temp to final location + try fileManager.moveItem(at: tempFilePath, to: newFilePath) + + // Create backup copy + try? fileManager.copyItem(at: newFilePath, to: backupPath) + + completedFiles.append(fileName) + migratedCount += 1 + + print("✅ Migrated: \(fileName) (\(migratedCount)/\(currentState.totalFiles))") + + } catch { + failedCount += 1 + print("❌ Failed to migrate \(fileName): \(error)") + } + + // Save checkpoint every 5 files or if interrupted + if (index + 1) % 5 == 0 { + let checkpointState = MigrationState( + version: MigrationState.currentVersion, + startTime: currentState.startTime, + completedFiles: completedFiles, + totalFiles: currentState.totalFiles, + isComplete: false, + lastCheckpoint: Date() + ) + saveMigrationState(checkpointState) + print("💾 Checkpoint saved: \(completedFiles.count)/\(currentState.totalFiles)") + } + } + } + + // Mark migration as complete + let finalState = MigrationState( + version: MigrationState.currentVersion, + startTime: currentState.startTime, + completedFiles: completedFiles, + totalFiles: currentState.totalFiles, + isComplete: true, + lastCheckpoint: Date() + ) + saveMigrationState(finalState) + + print("🏁 Migration complete: \(migratedCount) migrated, \(failedCount) failed") + + // Clean up legacy directory if no failures + if failedCount == 0 { + cleanupLegacyDirectory() + } + } + + private func verifyMigrationIntegrity() { + print("🔍 Verifying migration integrity...") + + var allLegacyFiles = Set() + + // Collect files from both legacy directories + do { + if fileManager.fileExists(atPath: legacyImagesDirectory.path) { + let legacyFiles = Set( + try fileManager.contentsOfDirectory(atPath: legacyImagesDirectory.path)) + allLegacyFiles.formUnion(legacyFiles) + } + + if fileManager.fileExists(atPath: legacyImportImagesDirectory.path) { + let importFiles = Set( + try fileManager.contentsOfDirectory(atPath: legacyImportImagesDirectory.path)) + allLegacyFiles.formUnion(importFiles) + } + } catch { + print("❌ Failed to read legacy directories: \(error)") + return + } + + guard !allLegacyFiles.isEmpty else { + print("✅ No legacy directories to verify against") + return + } + + do { + let migratedFiles = Set( + try fileManager.contentsOfDirectory(atPath: imagesDirectory.path)) + + let missingFiles = allLegacyFiles.subtracting(migratedFiles) + + if missingFiles.isEmpty { + print("✅ Migration integrity verified - all files present") + cleanupLegacyDirectory() + } else { + print("⚠️ Missing \(missingFiles.count) files, re-triggering migration") + // Re-trigger migration for missing files + performMigrationWithCheckpoints( + files: Array(missingFiles), + currentState: MigrationState( + version: MigrationState.currentVersion, + startTime: Date(), + completedFiles: [], + totalFiles: missingFiles.count, + isComplete: false, + lastCheckpoint: Date() + )) + } + } catch { + print("❌ Failed to verify migration integrity: \(error)") + } + } + + private func cleanupLegacyDirectory() { + do { + try fileManager.removeItem(at: legacyImagesDirectory) + print("🗑️ Cleaned up legacy directory") + } catch { + print("⚠️ Failed to clean up legacy directory: \(error)") + } + } + + private func loadMigrationState() -> MigrationState? { + guard fileManager.fileExists(atPath: migrationStateURL.path) else { + return nil + } + + // Check if migration was interrupted (lock file exists) + if !fileManager.fileExists(atPath: migrationLockURL.path) { + // Migration completed normally, clean up state + removeMigrationState() + return nil + } + + do { + let data = try Data(contentsOf: migrationStateURL) + let state = try JSONDecoder().decode(MigrationState.self, from: data) + + // Check if state is too old (more than 1 hour) + if Date().timeIntervalSince(state.lastCheckpoint) > 3600 { + print("⚠️ Migration state is stale, starting fresh") + removeMigrationState() + return nil + } + + return state.isComplete ? nil : state + } catch { + print("❌ Failed to load migration state: \(error)") + removeMigrationState() + return nil + } + } + + private func saveMigrationState(_ state: MigrationState) { + do { + let data = try JSONEncoder().encode(state) + try data.write(to: migrationStateURL) + } catch { + print("❌ Failed to save migration state: \(error)") + } + } + + private func removeMigrationState() { + try? fileManager.removeItem(at: migrationStateURL) + } + + private func createMigrationLock() { + let lockData = "Migration in progress - \(Date())".data(using: .utf8) ?? Data() + try? lockData.write(to: migrationLockURL) + } + + private func cleanupMigrationState() { + try? fileManager.removeItem(at: migrationStateURL) + try? fileManager.removeItem(at: migrationLockURL) + print("🧹 Cleaned up migration state files") + } + + func saveImageData(_ data: Data, withName name: String? = nil) -> String? { + let fileName = name ?? "\(UUID().uuidString).jpg" + let primaryPath = imagesDirectory.appendingPathComponent(fileName) + let backupPath = backupDirectory.appendingPathComponent(fileName) + + do { + // Save to primary location + try data.write(to: primaryPath) + + // Create backup copy + try data.write(to: backupPath) + + print("✅ Saved image with backup: \(fileName)") + return fileName + } catch { + print("❌ Failed to save image \(fileName): \(error)") + return nil + } + } + + func loadImageData(fromPath path: String) -> Data? { + let primaryPath = getFullPath(from: path) + let backupPath = backupDirectory.appendingPathComponent(getRelativePath(from: path)) + + // Try primary location first + if fileManager.fileExists(atPath: primaryPath), + let data = try? Data(contentsOf: URL(fileURLWithPath: primaryPath)) + { + return data + } + + // Fallback to backup location + if fileManager.fileExists(atPath: backupPath.path), + let data = try? Data(contentsOf: backupPath) + { + print("📦 Restored image from backup: \(path)") + + // Restore to primary location + try? data.write(to: URL(fileURLWithPath: primaryPath)) + + return data + } + + return nil + } + + func imageExists(atPath path: String) -> Bool { + let primaryPath = getFullPath(from: path) + let backupPath = backupDirectory.appendingPathComponent(getRelativePath(from: path)) + + return fileManager.fileExists(atPath: primaryPath) + || fileManager.fileExists(atPath: backupPath.path) + } + + func deleteImage(atPath path: String) -> Bool { + let primaryPath = getFullPath(from: path) + let backupPath = backupDirectory.appendingPathComponent(getRelativePath(from: path)) + + var success = true + + // Delete from primary location + if fileManager.fileExists(atPath: primaryPath) { + do { + try fileManager.removeItem(atPath: primaryPath) + } catch { + print("❌ Failed to delete primary image at \(primaryPath): \(error)") + success = false + } + } + + // Delete from backup location + if fileManager.fileExists(atPath: backupPath.path) { + do { + try fileManager.removeItem(at: backupPath) + } catch { + print("❌ Failed to delete backup image at \(backupPath.path): \(error)") + success = false + } + } + + return success + } + + func deleteImages(atPaths paths: [String]) { + for path in paths { + _ = deleteImage(atPath: path) + } + } + + private func getFullPath(from relativePath: String) -> String { + // If it's already a full path, check if it's legacy and needs migration + if relativePath.hasPrefix("/") { + // If it's pointing to legacy Documents directory, redirect to new location + if relativePath.contains("Documents/OpenClimbImages") { + let fileName = URL(fileURLWithPath: relativePath).lastPathComponent + return imagesDirectory.appendingPathComponent(fileName).path + } + return relativePath + } + + // For relative paths, use the persistent Application Support location + return imagesDirectory.appendingPathComponent(relativePath).path + } + + func getRelativePath(from fullPath: String) -> String { + if !fullPath.hasPrefix("/") { + return fullPath + } + return URL(fileURLWithPath: fullPath).lastPathComponent + } + + func performMaintenance() { + print("🔧 Starting image maintenance...") + + syncBackups() + validateImageIntegrity() + cleanupOrphanedFiles() + } + + private func syncBackups() { + do { + let primaryFiles = try fileManager.contentsOfDirectory(atPath: imagesDirectory.path) + let backupFiles = Set(try fileManager.contentsOfDirectory(atPath: backupDirectory.path)) + + for fileName in primaryFiles { + if !backupFiles.contains(fileName) { + let primaryPath = imagesDirectory.appendingPathComponent(fileName) + let backupPath = backupDirectory.appendingPathComponent(fileName) + + try? fileManager.copyItem(at: primaryPath, to: backupPath) + print("🔄 Created missing backup for: \(fileName)") + } + } + } catch { + print("❌ Failed to sync backups: \(error)") + } + } + + private func validateImageIntegrity() { + do { + let files = try fileManager.contentsOfDirectory(atPath: imagesDirectory.path) + var validFiles = 0 + + for fileName in files { + let filePath = imagesDirectory.appendingPathComponent(fileName) + if let data = try? Data(contentsOf: filePath), data.count > 0 { + // Basic validation - check if file has content and is reasonable size + if data.count > 100 { // Minimum viable image size + validFiles += 1 + } + } + } + + print("✅ Validated \(validFiles) of \(files.count) image files") + } catch { + print("❌ 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") + } + + func getStorageInfo() -> (primaryCount: Int, backupCount: Int, totalSize: Int64) { + let primaryCount = + ((try? fileManager.contentsOfDirectory(atPath: imagesDirectory.path)) ?? []).count + let backupCount = + ((try? fileManager.contentsOfDirectory(atPath: backupDirectory.path)) ?? []).count + + var totalSize: Int64 = 0 + [imagesDirectory, backupDirectory].forEach { directory in + if let enumerator = fileManager.enumerator( + at: directory, includingPropertiesForKeys: [.fileSizeKey]) + { + for case let url as URL in enumerator { + if let size = try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize { + totalSize += Int64(size) + } + } + } + } + + return (primaryCount, backupCount, totalSize) + } + + private func logDirectoryInfo() { + let info = getStorageInfo() + let previousDir = findPreviousAppSupportImages() + print( + """ + 📁 OpenClimb Image Storage: + - App Support: \(appSupportDirectory.path) + - Images: \(imagesDirectory.path) (\(info.primaryCount) files) + - Backups: \(backupDirectory.path) (\(info.backupCount) files) + - Previous Dir: \(previousDir?.path ?? "None found") + - Legacy Dir: \(legacyImagesDirectory.path) (exists: \(fileManager.fileExists(atPath: legacyImagesDirectory.path))) + - Legacy Import Dir: \(legacyImportImagesDirectory.path) (exists: \(fileManager.fileExists(atPath: legacyImportImagesDirectory.path))) + - Total Size: \(info.totalSize / 1024)KB + """) + } + + func forceRecoveryMigration() { + print("🚨 FORCE RECOVERY: Starting manual migration recovery...") + + // Remove any stale state + removeMigrationState() + try? fileManager.removeItem(at: migrationLockURL) + + // Force fresh migration + startNewMigration() + + print("🚨 FORCE RECOVERY: Migration recovery completed") + } + + func saveImportedImage(_ imageData: Data, filename: String) throws -> String { + let imagePath = imagesDirectory.appendingPathComponent(filename) + let backupPath = backupDirectory.appendingPathComponent(filename) + + // Save to main directory + try imageData.write(to: imagePath) + + // Create backup + try? imageData.write(to: backupPath) + + print("📥 Imported image: \(filename)") + return filename + } + + func emergencyImageRestore() { + print("🆘 EMERGENCY: Attempting image restoration...") + + // Try to restore from backup directory + do { + let backupFiles = try fileManager.contentsOfDirectory(atPath: backupDirectory.path) + var restoredCount = 0 + + for fileName in backupFiles { + let backupPath = backupDirectory.appendingPathComponent(fileName) + let primaryPath = imagesDirectory.appendingPathComponent(fileName) + + // Only restore if primary doesn't exist + if !fileManager.fileExists(atPath: primaryPath.path) { + try? fileManager.copyItem(at: backupPath, to: primaryPath) + restoredCount += 1 + } + } + + print("🆘 EMERGENCY: Restored \(restoredCount) images from backup") + } catch { + 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...") + migratePreviousAppSupportImages(from: previousAppSupportImages) + return + } + + // Try legacy migration as last resort + if fileManager.fileExists(atPath: legacyImagesDirectory.path) + || fileManager.fileExists(atPath: legacyImportImagesDirectory.path) + { + print("🆘 EMERGENCY: Attempting legacy migration as fallback...") + forceRecoveryMigration() + } + } + + func debugSafeInitialization() -> Bool { + print("🐛 DEBUG SAFE: Performing debug-safe initialization check...") + + // Check if we're in a debug environment + #if DEBUG + 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") + + // Give extra time for file system to stabilize + Thread.sleep(forTimeInterval: 1.0) + + // Try emergency recovery + emergencyImageRestore() + + // Clean up lock + try? fileManager.removeItem(at: migrationLockURL) + + return true + } + #endif + + // Check if primary storage is empty but backup exists + let primaryEmpty = + (try? fileManager.contentsOfDirectory(atPath: imagesDirectory.path).isEmpty) ?? true + let backupHasFiles = + ((try? fileManager.contentsOfDirectory(atPath: backupDirectory.path)) ?? []).count > 0 + + if primaryEmpty && backupHasFiles { + 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") + migratePreviousAppSupportImages(from: previousAppSupportImages) + return true + } + + return false + } + + func validateStorageIntegrity() -> Bool { + let primaryFiles = Set( + (try? fileManager.contentsOfDirectory(atPath: imagesDirectory.path)) ?? []) + let backupFiles = Set( + (try? fileManager.contentsOfDirectory(atPath: backupDirectory.path)) ?? []) + + // 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") + 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") + return false + } + + return true + } + + func findPreviousAppSupportImages() -> URL? { + // Get the Application Support base directory + guard + let appSupportBase = fileManager.urls( + for: .applicationSupportDirectory, in: .userDomainMask + ).first + else { + print("❌ Could not access Application Support directory") + return nil + } + + // Look for OpenClimb directories in Application Support + do { + let contents = try fileManager.contentsOfDirectory( + at: appSupportBase, includingPropertiesForKeys: nil) + + for url in contents { + var isDirectory: ObjCBool = false + guard fileManager.fileExists(atPath: url.path, isDirectory: &isDirectory), + isDirectory.boolValue + else { + continue + } + + // Check if it's an OpenClimb directory but not the current one + if url.lastPathComponent.contains("OpenClimb") + && url.path != appSupportDirectory.path + { + let imagesDir = url.appendingPathComponent(imagesDirectoryName) + + if fileManager.fileExists(atPath: imagesDir.path) { + let imageFiles = + (try? fileManager.contentsOfDirectory(atPath: imagesDir.path)) ?? [] + if !imageFiles.isEmpty { + return imagesDir + } + } + } + } + } catch { + print("❌ Error scanning for previous Application Support directories: \(error)") + } + return nil + } + + private func migratePreviousAppSupportImages(from sourceDirectory: URL) { + print("🔄 Migrating images from previous Application Support directory") + + do { + let imageFiles = try fileManager.contentsOfDirectory(atPath: sourceDirectory.path) + + for fileName in imageFiles { + autoreleasepool { + let sourcePath = sourceDirectory.appendingPathComponent(fileName) + let destinationPath = imagesDirectory.appendingPathComponent(fileName) + let backupPath = backupDirectory.appendingPathComponent(fileName) + + // Skip if already exists in destination + if fileManager.fileExists(atPath: destinationPath.path) { + return + } + + do { + // Copy to main directory + try fileManager.copyItem(at: sourcePath, to: destinationPath) + + // Create backup + try? fileManager.copyItem(at: sourcePath, to: backupPath) + + print("✅ Migrated: \(fileName)") + } catch { + print("❌ Failed to migrate \(fileName): \(error)") + } + } + } + + print("✅ Completed migration from previous Application Support directory") + + } catch { + print("❌ Failed to migrate from previous Application Support: \(error)") + } + } +} diff --git a/ios/OpenClimb/Utils/ZipUtils.swift b/ios/OpenClimb/Utils/ZipUtils.swift index cec0894..ebdbd26 100644 --- a/ios/OpenClimb/Utils/ZipUtils.swift +++ b/ios/OpenClimb/Utils/ZipUtils.swift @@ -1,9 +1,3 @@ -// -// ZipUtils.swift -// OpenClimb -// -// Created by OpenClimb on 2025-01-17. -// import Compression import Foundation @@ -169,21 +163,9 @@ struct ZipUtils { entry.filename.dropFirst("\(IMAGES_DIR_NAME)/".count)) do { - - let documentsURL = FileManager.default.urls( - for: .documentDirectory, in: .userDomainMask - ).first! - let imagesDir = documentsURL.appendingPathComponent("images") - try FileManager.default.createDirectory( - at: imagesDir, withIntermediateDirectories: true) - - let newImageURL = imagesDir.appendingPathComponent(originalFilename) - try entry.data.write(to: newImageURL) - - importedImagePaths[originalFilename] = newImageURL.path - print( - "Successfully imported image: \(originalFilename) -> \(newImageURL.path)" - ) + let filename = try ImageManager.shared.saveImportedImage( + entry.data, filename: originalFilename) + importedImagePaths[originalFilename] = filename } catch { print("Failed to import image \(originalFilename): \(error)") } diff --git a/ios/OpenClimb/ViewModels/ClimbingDataManager.swift b/ios/OpenClimb/ViewModels/ClimbingDataManager.swift index e6b6d7f..ef395c5 100644 --- a/ios/OpenClimb/ViewModels/ClimbingDataManager.swift +++ b/ios/OpenClimb/ViewModels/ClimbingDataManager.swift @@ -1,10 +1,3 @@ -// -// ClimbingDataManager.swift -// OpenClimb -// -// Created by OpenClimb on 2025-01-17. -// - import Combine import Foundation import SwiftUI @@ -35,7 +28,14 @@ class ClimbingDataManager: ObservableObject { } init() { + _ = ImageManager.shared loadAllData() + migrateImagePaths() + + Task { + try? await Task.sleep(nanoseconds: 2_000_000_000) + await performImageMaintenance() + } } private func loadAllData() { @@ -181,11 +181,12 @@ class ClimbingDataManager: ObservableObject { attempts.removeAll { $0.problemId == problem.id } saveAttempts() + // Delete associated images + ImageManager.shared.deleteImages(atPaths: problem.imagePaths) + // Delete the problem problems.removeAll { $0.id == problem.id } saveProblems() - successMessage = "Problem deleted successfully" - clearMessageAfterDelay() } func problem(withId id: UUID) -> Problem? { @@ -770,7 +771,6 @@ struct AndroidAttempt: Codable { } } -// MARK: - Helper Functions extension ClimbingDataManager { private func collectReferencedImagePaths() -> Set { var imagePaths = Set() @@ -793,6 +793,137 @@ extension ClimbingDataManager { } } + private func migrateImagePaths() { + var needsUpdate = false + + let updatedProblems = problems.map { problem in + let migratedPaths = problem.imagePaths.compactMap { path in + // If it's already a relative path, keep it + if !path.hasPrefix("/") { + return path + } + + // For absolute paths, try to migrate to relative + let fileName = URL(fileURLWithPath: path).lastPathComponent + if ImageManager.shared.imageExists(atPath: fileName) { + needsUpdate = true + return fileName + } + + // If image doesn't exist, remove from paths + needsUpdate = true + return nil + } + + if migratedPaths != problem.imagePaths { + return problem.updated(imagePaths: migratedPaths) + } + return problem + } + + if needsUpdate { + problems = updatedProblems + saveProblems() + print("Migrated image paths for \(problems.count) problems") + } + } + + private func performImageMaintenance() async { + // Run maintenance in background + await Task.detached { + await ImageManager.shared.performMaintenance() + + // 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" + ) + }.value + } + + func manualImageMaintenance() { + Task { + await performImageMaintenance() + } + } + + func getImageStorageInfo() -> String { + let info = ImageManager.shared.getStorageInfo() + return """ + Image Storage Status: + • Primary: \(info.primaryCount) files + • Backup: \(info.backupCount) files + • Total Size: \(formatBytes(info.totalSize)) + """ + } + + func cleanupUnusedImages() { + // Get all image paths currently referenced in problems + let referencedImages = Set( + problems.flatMap { $0.imagePaths.map { ImageManager.shared.getRelativePath(from: $0) } } + ) + + // Get all files in storage + if let primaryFiles = try? FileManager.default.contentsOfDirectory( + atPath: ImageManager.shared.getImagesDirectoryPath()) + { + let orphanedFiles = primaryFiles.filter { !referencedImages.contains($0) } + + for fileName in orphanedFiles { + _ = ImageManager.shared.deleteImage(atPath: fileName) + } + + if !orphanedFiles.isEmpty { + print("🗑️ Cleaned up \(orphanedFiles.count) orphaned image files") + } + } + } + + private func formatBytes(_ bytes: Int64) -> String { + let kb = Double(bytes) / 1024.0 + let mb = kb / 1024.0 + + if mb >= 1.0 { + return String(format: "%.1f MB", mb) + } else { + return String(format: "%.0f KB", kb) + } + } + + func forceImageRecovery() { + print("🚨 User initiated force image recovery") + ImageManager.shared.forceRecoveryMigration() + + // Refresh the UI after recovery + objectWillChange.send() + } + + func emergencyImageRestore() { + print("🆘 User initiated emergency image restore") + ImageManager.shared.emergencyImageRestore() + + // Refresh the UI after restore + objectWillChange.send() + } + + func validateImageStorage() -> Bool { + return ImageManager.shared.validateStorageIntegrity() + } + + func getImageRecoveryStatus() -> String { + let isValid = validateImageStorage() + let info = ImageManager.shared.getStorageInfo() + + return """ + Image Storage Health: \(isValid ? "✅ Good" : "❌ Needs Recovery") + Primary Files: \(info.primaryCount) + Backup Files: \(info.backupCount) + Total Size: \(formatBytes(info.totalSize)) + + \(isValid ? "No action needed" : "Consider running Force Recovery") + """ + } + private func validateImportData(_ importData: ClimbDataExport) throws { if importData.gyms.isEmpty { throw NSError( @@ -802,7 +933,6 @@ extension ClimbingDataManager { } } -// MARK: - Preview Helper extension ClimbingDataManager { static var preview: ClimbingDataManager { let manager = ClimbingDataManager() diff --git a/ios/OpenClimb/Views/AddEdit/AddAttemptView.swift b/ios/OpenClimb/Views/AddEdit/AddAttemptView.swift index 1b5b390..a89a576 100644 --- a/ios/OpenClimb/Views/AddEdit/AddAttemptView.swift +++ b/ios/OpenClimb/Views/AddEdit/AddAttemptView.swift @@ -1,9 +1,3 @@ -// -// AddAttemptView.swift -// OpenClimb -// -// Created by OpenClimb on 2025-01-17. -// import SwiftUI @@ -99,14 +93,20 @@ struct AddAttemptView: View { } .padding(.vertical, 8) } else { - ForEach(activeProblems, id: \.id) { problem in - ProblemSelectionRow( - problem: problem, - isSelected: selectedProblem?.id == problem.id - ) { - selectedProblem = problem + LazyVGrid( + columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 2), + spacing: 8 + ) { + ForEach(activeProblems, id: \.id) { problem in + ProblemSelectionCard( + problem: problem, + isSelected: selectedProblem?.id == problem.id + ) { + selectedProblem = problem + } } } + .padding(.vertical, 8) Button("Create New Problem") { showingCreateProblem = true @@ -391,6 +391,197 @@ struct ProblemSelectionRow: View { } } +struct ProblemSelectionCard: View { + let problem: Problem + let isSelected: Bool + let action: () -> Void + @State private var showingExpandedView = false + + var body: some View { + VStack(spacing: 8) { + // Image section + ZStack { + if let firstImagePath = problem.imagePaths.first { + ProblemSelectionImageView(imagePath: firstImagePath) + } else { + RoundedRectangle(cornerRadius: 8) + .fill(.gray.opacity(0.2)) + .frame(height: 80) + .overlay { + Image(systemName: "mountain.2.fill") + .foregroundColor(.gray) + .font(.title2) + } + } + + // Selection indicator + VStack { + HStack { + Spacer() + if isSelected { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.white) + .background(Circle().fill(.blue)) + .font(.title3) + } + } + Spacer() + } + .padding(6) + + // Multiple images indicator + if problem.imagePaths.count > 1 { + VStack { + Spacer() + HStack { + Spacer() + Text("+\(problem.imagePaths.count - 1)") + .font(.caption2) + .fontWeight(.bold) + .foregroundColor(.white) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background( + RoundedRectangle(cornerRadius: 4) + .fill(.black.opacity(0.6)) + ) + } + } + .padding(6) + } + } + + // Problem info + VStack(alignment: .leading, spacing: 4) { + Text(problem.name ?? "Unnamed") + .font(.caption) + .fontWeight(.medium) + .lineLimit(1) + + Text(problem.difficulty.grade) + .font(.caption2) + .fontWeight(.bold) + .foregroundColor(.blue) + + if let location = problem.location { + Text(location) + .font(.caption2) + .foregroundColor(.secondary) + .lineLimit(1) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(8) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(isSelected ? .blue.opacity(0.1) : .gray.opacity(0.05)) + .stroke(isSelected ? .blue : .clear, lineWidth: 2) + ) + .contentShape(Rectangle()) + .onTapGesture { + if isSelected { + showingExpandedView = true + } else { + action() + } + } + .sheet(isPresented: $showingExpandedView) { + ProblemExpandedView(problem: problem) + } + } +} + +struct ProblemExpandedView: View { + let problem: Problem + @Environment(\.dismiss) private var dismiss + @State private var selectedImageIndex = 0 + + var body: some View { + NavigationView { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + // Images + if !problem.imagePaths.isEmpty { + TabView(selection: $selectedImageIndex) { + ForEach(problem.imagePaths.indices, id: \.self) { index in + ProblemSelectionImageFullView(imagePath: problem.imagePaths[index]) + .tag(index) + } + } + .frame(height: 250) + .tabViewStyle(.page(indexDisplayMode: .always)) + } + + // Problem details + VStack(alignment: .leading, spacing: 12) { + Text(problem.name ?? "Unnamed Problem") + .font(.title2) + .fontWeight(.bold) + + HStack { + Text(problem.difficulty.grade) + .font(.title3) + .fontWeight(.bold) + .foregroundColor(.blue) + + Text(problem.climbType.displayName) + .font(.subheadline) + .foregroundColor(.secondary) + } + + if let location = problem.location, !location.isEmpty { + Label(location, systemImage: "location") + .font(.subheadline) + .foregroundColor(.secondary) + } + + if let setter = problem.setter, !setter.isEmpty { + Label(setter, systemImage: "person") + .font(.subheadline) + .foregroundColor(.secondary) + } + + if let description = problem.description, !description.isEmpty { + Text(description) + .font(.body) + } + + if !problem.tags.isEmpty { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(problem.tags, id: \.self) { tag in + Text(tag) + .font(.caption) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(.blue.opacity(0.1)) + ) + .foregroundColor(.blue) + } + } + .padding(.horizontal) + } + } + } + .padding(.horizontal) + } + } + .navigationTitle("Problem Details") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Done") { + dismiss() + } + } + } + } + } +} + struct EditAttemptView: View { let attempt: Attempt @EnvironmentObject var dataManager: ClimbingDataManager @@ -556,3 +747,131 @@ struct EditAttemptView: View { ) .environmentObject(ClimbingDataManager.preview) } + +struct ProblemSelectionImageView: View { + let imagePath: String + @State private var uiImage: UIImage? + @State private var isLoading = true + @State private var hasFailed = false + + var body: some View { + Group { + if let uiImage = uiImage { + Image(uiImage: uiImage) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(height: 80) + .clipped() + .cornerRadius(8) + } else if hasFailed { + RoundedRectangle(cornerRadius: 8) + .fill(.gray.opacity(0.2)) + .frame(height: 80) + .overlay { + Image(systemName: "photo") + .foregroundColor(.gray) + .font(.title3) + } + } else { + RoundedRectangle(cornerRadius: 8) + .fill(.gray.opacity(0.3)) + .frame(height: 80) + .overlay { + ProgressView() + .scaleEffect(0.8) + } + } + } + .onAppear { + loadImage() + } + } + + private func loadImage() { + guard !imagePath.isEmpty else { + hasFailed = true + isLoading = false + return + } + + DispatchQueue.global(qos: .userInitiated).async { + if let data = ImageManager.shared.loadImageData(fromPath: imagePath), + let image = UIImage(data: data) + { + DispatchQueue.main.async { + self.uiImage = image + self.isLoading = false + } + } else { + DispatchQueue.main.async { + self.hasFailed = true + self.isLoading = false + } + } + } + } +} + +struct ProblemSelectionImageFullView: View { + let imagePath: String + @State private var uiImage: UIImage? + @State private var isLoading = true + @State private var hasFailed = false + + var body: some View { + Group { + if let uiImage = uiImage { + Image(uiImage: uiImage) + .resizable() + .aspectRatio(contentMode: .fit) + } else if hasFailed { + RoundedRectangle(cornerRadius: 12) + .fill(.gray.opacity(0.2)) + .frame(height: 250) + .overlay { + VStack(spacing: 8) { + Image(systemName: "photo") + .foregroundColor(.gray) + .font(.largeTitle) + Text("Image not available") + .foregroundColor(.gray) + } + } + } else { + RoundedRectangle(cornerRadius: 12) + .fill(.gray.opacity(0.3)) + .frame(height: 250) + .overlay { + ProgressView() + } + } + } + .onAppear { + loadImage() + } + } + + private func loadImage() { + guard !imagePath.isEmpty else { + hasFailed = true + isLoading = false + return + } + + DispatchQueue.global(qos: .userInitiated).async { + if let data = ImageManager.shared.loadImageData(fromPath: imagePath), + let image = UIImage(data: data) + { + DispatchQueue.main.async { + self.uiImage = image + self.isLoading = false + } + } else { + DispatchQueue.main.async { + self.hasFailed = true + self.isLoading = false + } + } + } + } +} diff --git a/ios/OpenClimb/Views/AddEdit/AddEditGymView.swift b/ios/OpenClimb/Views/AddEdit/AddEditGymView.swift index 98e29af..d0f5f69 100644 --- a/ios/OpenClimb/Views/AddEdit/AddEditGymView.swift +++ b/ios/OpenClimb/Views/AddEdit/AddEditGymView.swift @@ -1,9 +1,3 @@ -// -// AddEditGymView.swift -// OpenClimb -// -// Created by OpenClimb on 2025-01-17. -// import SwiftUI diff --git a/ios/OpenClimb/Views/AddEdit/AddEditProblemView.swift b/ios/OpenClimb/Views/AddEdit/AddEditProblemView.swift index 17b9a38..3460c3d 100644 --- a/ios/OpenClimb/Views/AddEdit/AddEditProblemView.swift +++ b/ios/OpenClimb/Views/AddEdit/AddEditProblemView.swift @@ -1,9 +1,3 @@ -// -// AddEditProblemView.swift -// OpenClimb -// -// Created by OpenClimb on 2025-01-17. -// import PhotosUI import SwiftUI @@ -459,19 +453,10 @@ struct AddEditProblemView: View { private func loadSelectedPhotos() async { for item in selectedPhotos { if let data = try? await item.loadTransferable(type: Data.self) { - // Save to app's documents directory - let documentsPath = FileManager.default.urls( - for: .documentDirectory, in: .userDomainMask - ).first! - let imageName = "photo_\(UUID().uuidString).jpg" - let imagePath = documentsPath.appendingPathComponent(imageName) - - do { - try data.write(to: imagePath) - imagePaths.append(imagePath.path) + // Use ImageManager to save image + if let relativePath = ImageManager.shared.saveImageData(data) { + imagePaths.append(relativePath) imageData.append(data) - } catch { - print("Failed to save image: \(error)") } } } diff --git a/ios/OpenClimb/Views/AddEdit/AddEditSessionView.swift b/ios/OpenClimb/Views/AddEdit/AddEditSessionView.swift index cd34e3a..724b482 100644 --- a/ios/OpenClimb/Views/AddEdit/AddEditSessionView.swift +++ b/ios/OpenClimb/Views/AddEdit/AddEditSessionView.swift @@ -1,9 +1,3 @@ -// -// AddEditSessionView.swift -// OpenClimb -// -// Created by OpenClimb on 2025-01-17. -// import SwiftUI diff --git a/ios/OpenClimb/Views/AnalyticsView.swift b/ios/OpenClimb/Views/AnalyticsView.swift index 59e031e..22837e3 100644 --- a/ios/OpenClimb/Views/AnalyticsView.swift +++ b/ios/OpenClimb/Views/AnalyticsView.swift @@ -1,10 +1,3 @@ -// -// AnalyticsView.swift -// OpenClimb -// -// Created by OpenClimb on 2025-01-17. -// - import SwiftUI struct AnalyticsView: View { @@ -538,8 +531,6 @@ struct ProgressDataPoint { let difficultySystem: DifficultySystem } -// MARK: - Helper Functions - #Preview { AnalyticsView() .environmentObject(ClimbingDataManager.preview) diff --git a/ios/OpenClimb/Views/Detail/GymDetailView.swift b/ios/OpenClimb/Views/Detail/GymDetailView.swift index e1058a1..3b6e57f 100644 --- a/ios/OpenClimb/Views/Detail/GymDetailView.swift +++ b/ios/OpenClimb/Views/Detail/GymDetailView.swift @@ -1,9 +1,3 @@ -// -// GymDetailView.swift -// OpenClimb -// -// Created by OpenClimb on 2025-01-17. -// import SwiftUI diff --git a/ios/OpenClimb/Views/Detail/ProblemDetailView.swift b/ios/OpenClimb/Views/Detail/ProblemDetailView.swift index 32ac919..62dc277 100644 --- a/ios/OpenClimb/Views/Detail/ProblemDetailView.swift +++ b/ios/OpenClimb/Views/Detail/ProblemDetailView.swift @@ -1,9 +1,3 @@ -// -// ProblemDetailView.swift -// OpenClimb -// -// Created by OpenClimb on 2025-01-17. -// import SwiftUI @@ -296,21 +290,11 @@ struct PhotosSection: View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 12) { ForEach(imagePaths.indices, id: \.self) { index in - AsyncImage(url: URL(fileURLWithPath: imagePaths[index])) { image in - image - .resizable() - .aspectRatio(contentMode: .fill) - } placeholder: { - RoundedRectangle(cornerRadius: 12) - .fill(.gray.opacity(0.3)) - } - .frame(width: 120, height: 120) - .clipped() - .cornerRadius(12) - .onTapGesture { - selectedImageIndex = index - showingImageViewer = true - } + ProblemDetailImageView(imagePath: imagePaths[index]) + .onTapGesture { + selectedImageIndex = index + showingImageViewer = true + } } } .padding(.horizontal, 1) @@ -444,14 +428,8 @@ struct ImageViewerView: View { NavigationView { TabView(selection: $currentIndex) { ForEach(imagePaths.indices, id: \.self) { index in - AsyncImage(url: URL(fileURLWithPath: imagePaths[index])) { image in - image - .resizable() - .aspectRatio(contentMode: .fit) - } placeholder: { - ProgressView() - } - .tag(index) + ProblemDetailImageFullView(imagePath: imagePaths[index]) + .tag(index) } } .tabViewStyle(.page(indexDisplayMode: .always)) @@ -468,6 +446,133 @@ struct ImageViewerView: View { } } +struct ProblemDetailImageView: View { + let imagePath: String + @State private var uiImage: UIImage? + @State private var isLoading = true + @State private var hasFailed = false + + var body: some View { + Group { + if let uiImage = uiImage { + Image(uiImage: uiImage) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 120, height: 120) + .clipped() + .cornerRadius(12) + } else if hasFailed { + RoundedRectangle(cornerRadius: 12) + .fill(.gray.opacity(0.2)) + .frame(width: 120, height: 120) + .overlay { + Image(systemName: "photo") + .foregroundColor(.gray) + .font(.title2) + } + } else { + RoundedRectangle(cornerRadius: 12) + .fill(.gray.opacity(0.3)) + .frame(width: 120, height: 120) + .overlay { + ProgressView() + } + } + } + .onAppear { + loadImage() + } + } + + private func loadImage() { + guard !imagePath.isEmpty else { + hasFailed = true + isLoading = false + return + } + + DispatchQueue.global(qos: .userInitiated).async { + if let data = ImageManager.shared.loadImageData(fromPath: imagePath), + let image = UIImage(data: data) + { + DispatchQueue.main.async { + self.uiImage = image + self.isLoading = false + } + } else { + DispatchQueue.main.async { + self.hasFailed = true + self.isLoading = false + } + } + } + } +} + +struct ProblemDetailImageFullView: View { + let imagePath: String + @State private var uiImage: UIImage? + @State private var isLoading = true + @State private var hasFailed = false + + var body: some View { + Group { + if let uiImage = uiImage { + Image(uiImage: uiImage) + .resizable() + .aspectRatio(contentMode: .fit) + } else if hasFailed { + Rectangle() + .fill(.gray.opacity(0.2)) + .frame(height: 250) + .overlay { + VStack(spacing: 8) { + Image(systemName: "photo") + .foregroundColor(.gray) + .font(.largeTitle) + Text("Image not available") + .foregroundColor(.gray) + } + } + } else { + Rectangle() + .fill(.gray.opacity(0.3)) + .frame(height: 250) + .overlay { + ProgressView() + } + } + } + .onAppear { + loadImage() + } + } + + private func loadImage() { + guard !imagePath.isEmpty else { + hasFailed = true + isLoading = false + return + } + + DispatchQueue.global(qos: .userInitiated).async { + if let data = ImageManager.shared.loadImageData(fromPath: imagePath), + let image = UIImage(data: data) + { + DispatchQueue.main.async { + self.uiImage = image + self.isLoading = false + } + } else { + DispatchQueue.main.async { + self.hasFailed = true + self.isLoading = false + } + } + } + } +} + #Preview { NavigationView { ProblemDetailView(problemId: UUID()) diff --git a/ios/OpenClimb/Views/Detail/SessionDetailView.swift b/ios/OpenClimb/Views/Detail/SessionDetailView.swift index e71d3b9..8caee31 100644 --- a/ios/OpenClimb/Views/Detail/SessionDetailView.swift +++ b/ios/OpenClimb/Views/Detail/SessionDetailView.swift @@ -1,10 +1,5 @@ -// -// SessionDetailView.swift -// OpenClimb -// -// Created by OpenClimb on 2025-01-17. -// +import Combine import SwiftUI struct SessionDetailView: View { @@ -14,6 +9,8 @@ struct SessionDetailView: View { @State private var showingDeleteAlert = false @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) @@ -39,15 +36,20 @@ struct SessionDetailView: View { calculateSessionStats() } + private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() + var body: some View { ScrollView { LazyVStack(spacing: 20) { if let session = session, let gym = gym { - SessionHeaderCard(session: session, gym: gym, stats: sessionStats) + SessionHeaderCard( + session: session, gym: gym, stats: sessionStats, currentTime: currentTime) SessionStatsCard(stats: sessionStats) - AttemptsSection(attemptsWithProblems: attemptsWithProblems) + AttemptsSection( + attemptsWithProblems: attemptsWithProblems, + attemptToDelete: $attemptToDelete) } else { Text("Session not found") .foregroundColor(.secondary) @@ -55,6 +57,9 @@ struct SessionDetailView: View { } .padding() } + .onReceive(timer) { _ in + currentTime = Date() + } .navigationTitle("Session Details") .navigationBarTitleDisplayMode(.inline) .toolbar { @@ -80,6 +85,33 @@ struct SessionDetailView: View { } } } + .alert( + "Delete Attempt", + isPresented: Binding( + get: { attemptToDelete != nil }, + set: { if !$0 { attemptToDelete = nil } } + ) + ) { + Button("Cancel", role: .cancel) { + attemptToDelete = nil + } + Button("Delete", role: .destructive) { + if let attempt = attemptToDelete { + dataManager.deleteAttempt(attempt) + attemptToDelete = nil + } + } + } message: { + if let attempt = attemptToDelete, + let problem = dataManager.problem(withId: attempt.problemId) + { + Text( + "Are you sure you want to delete this attempt on \"\(problem.name ?? "Unknown Problem")\"? This action cannot be undone." + ) + } else { + Text("Are you sure you want to delete this attempt? This action cannot be undone.") + } + } .overlay(alignment: .bottomTrailing) { if session?.status == .active { Button(action: { showingAddAttempt = true }) { @@ -140,12 +172,26 @@ struct SessionDetailView: View { private func gradeRange(for problems: [Problem]) -> String? { guard !problems.isEmpty else { return nil } - let grades = problems.map { $0.difficulty }.sorted() - if grades.count == 1 { - return grades.first?.grade - } else { - return "\(grades.first?.grade ?? "") - \(grades.last?.grade ?? "")" + let difficulties = problems.map { $0.difficulty } + + // Group by difficulty system first + let groupedBySystem = Dictionary(grouping: difficulties) { $0.system } + + // For each system, find the range + let ranges = groupedBySystem.compactMap { (system, difficulties) -> String? in + let sortedDifficulties = difficulties.sorted() + guard let min = sortedDifficulties.first, let max = sortedDifficulties.last else { + return nil + } + + if min == max { + return min.grade + } else { + return "\(min.grade) - \(max.grade)" + } } + + return ranges.joined(separator: ", ") } } @@ -153,6 +199,7 @@ struct SessionHeaderCard: View { let session: ClimbSession let gym: Gym let stats: SessionStats + let currentTime: Date var body: some View { VStack(alignment: .leading, spacing: 16) { @@ -165,7 +212,13 @@ struct SessionHeaderCard: View { .font(.title2) .foregroundColor(.blue) - if let duration = session.duration { + if session.status == .active { + if let startTime = session.startTime { + Text("Duration: \(formatDuration(from: startTime, to: currentTime))") + .font(.subheadline) + .foregroundColor(.secondary) + } + } else if let duration = session.duration { Text("Duration: \(duration) minutes") .font(.subheadline) .foregroundColor(.secondary) @@ -209,6 +262,21 @@ struct SessionHeaderCard: View { formatter.dateStyle = .full 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 { @@ -276,6 +344,7 @@ struct StatItem: View { struct AttemptsSection: View { let attemptsWithProblems: [(Attempt, Problem)] + @Binding var attemptToDelete: Attempt? @EnvironmentObject var dataManager: ClimbingDataManager @State private var editingAttempt: Attempt? @@ -311,6 +380,30 @@ struct AttemptsSection: View { ForEach(attemptsWithProblems.indices, id: \.self) { index in let (attempt, problem) = attemptsWithProblems[index] AttemptCard(attempt: attempt, problem: problem) + .background(.regularMaterial) + .cornerRadius(12) + .shadow(radius: 2) + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button(role: .destructive) { + // Add haptic feedback for delete action + let impactFeedback = UIImpactFeedbackGenerator(style: .medium) + impactFeedback.impactOccurred() + attemptToDelete = attempt + } label: { + Label("Delete", systemImage: "trash") + } + .accessibilityLabel("Delete attempt") + .accessibilityHint("Removes this attempt from the session") + + Button { + editingAttempt = attempt + } label: { + Label("Edit", systemImage: "pencil") + } + .tint(.blue) + .accessibilityLabel("Edit attempt") + .accessibilityHint("Modify the details of this attempt") + } .onTapGesture { editingAttempt = attempt } @@ -327,8 +420,6 @@ struct AttemptsSection: View { struct AttemptCard: View { let attempt: Attempt let problem: Problem - @EnvironmentObject var dataManager: ClimbingDataManager - @State private var showingDeleteAlert = false var body: some View { VStack(alignment: .leading, spacing: 12) { @@ -353,15 +444,6 @@ struct AttemptCard: View { VStack(alignment: .trailing, spacing: 8) { AttemptResultBadge(result: attempt.result) - - HStack(spacing: 12) { - Button(action: { showingDeleteAlert = true }) { - Image(systemName: "trash") - .font(.caption) - .foregroundColor(.red) - } - .buttonStyle(.plain) - } } } @@ -378,19 +460,6 @@ struct AttemptCard: View { } } .padding() - .background( - RoundedRectangle(cornerRadius: 12) - .fill(.ultraThinMaterial) - .stroke(.quaternary, lineWidth: 1) - ) - .alert("Delete Attempt", isPresented: $showingDeleteAlert) { - Button("Cancel", role: .cancel) {} - Button("Delete", role: .destructive) { - dataManager.deleteAttempt(attempt) - } - } message: { - Text("Are you sure you want to delete this attempt?") - } } } diff --git a/ios/OpenClimb/Views/GymsView.swift b/ios/OpenClimb/Views/GymsView.swift index f209f38..078dd4f 100644 --- a/ios/OpenClimb/Views/GymsView.swift +++ b/ios/OpenClimb/Views/GymsView.swift @@ -1,9 +1,3 @@ -// -// GymsView.swift -// OpenClimb -// -// Created by OpenClimb on 2025-01-17. -// import SwiftUI @@ -37,14 +31,47 @@ struct GymsView: View { struct GymsList: View { @EnvironmentObject var dataManager: ClimbingDataManager + @State private var gymToDelete: Gym? + @State private var gymToEdit: Gym? var body: some View { List(dataManager.gyms, id: \.id) { gym in NavigationLink(destination: GymDetailView(gymId: gym.id)) { GymRow(gym: gym) } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button(role: .destructive) { + gymToDelete = gym + } label: { + Label("Delete", systemImage: "trash") + } + + Button { + gymToEdit = gym + } label: { + Label("Edit", systemImage: "pencil") + } + .tint(.blue) + } + } + .alert("Delete Gym", isPresented: .constant(gymToDelete != nil)) { + Button("Cancel", role: .cancel) { + gymToDelete = nil + } + Button("Delete", role: .destructive) { + if let gym = gymToDelete { + dataManager.deleteGym(gym) + gymToDelete = nil + } + } + } message: { + Text( + "Are you sure you want to delete this gym? This will also delete all associated problems and sessions." + ) + } + .sheet(item: $gymToEdit) { gym in + AddEditGymView(gymId: gym.id) } - .listStyle(.plain) } } @@ -124,7 +151,7 @@ struct GymRow: View { .lineLimit(2) } } - .padding(.vertical, 4) + .padding(.vertical, 8) } } diff --git a/ios/OpenClimb/Views/ProblemsView.swift b/ios/OpenClimb/Views/ProblemsView.swift index a5a73d5..7014935 100644 --- a/ios/OpenClimb/Views/ProblemsView.swift +++ b/ios/OpenClimb/Views/ProblemsView.swift @@ -1,9 +1,3 @@ -// -// ProblemsView.swift -// OpenClimb -// -// Created by OpenClimb on 2025-01-17. -// import SwiftUI @@ -45,9 +39,13 @@ struct ProblemsView: View { NavigationView { VStack(spacing: 0) { if !dataManager.problems.isEmpty { - FilterSection() - .padding() - .background(.regularMaterial) + FilterSection( + selectedClimbType: $selectedClimbType, + selectedGym: $selectedGym, + filteredProblems: filteredProblems + ) + .padding() + .background(.regularMaterial) } if filteredProblems.isEmpty { @@ -79,8 +77,9 @@ struct ProblemsView: View { struct FilterSection: View { @EnvironmentObject var dataManager: ClimbingDataManager - @State private var selectedClimbType: ClimbType? - @State private var selectedGym: Gym? + @Binding var selectedClimbType: ClimbType? + @Binding var selectedGym: Gym? + let filteredProblems: [Problem] var body: some View { VStack(spacing: 12) { @@ -154,19 +153,6 @@ struct FilterSection: View { } } - private var filteredProblems: [Problem] { - var filtered = dataManager.problems - - if let climbType = selectedClimbType { - filtered = filtered.filter { $0.climbType == climbType } - } - - if let gym = selectedGym { - filtered = filtered.filter { $0.gymId == gym.id } - } - - return filtered - } } struct FilterChip: View { @@ -195,14 +181,47 @@ struct FilterChip: View { struct ProblemsList: View { let problems: [Problem] @EnvironmentObject var dataManager: ClimbingDataManager + @State private var problemToDelete: Problem? + @State private var problemToEdit: Problem? var body: some View { List(problems) { problem in NavigationLink(destination: ProblemDetailView(problemId: problem.id)) { ProblemRow(problem: problem) } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button(role: .destructive) { + problemToDelete = problem + } label: { + Label("Delete", systemImage: "trash") + } + + Button { + problemToEdit = problem + } label: { + Label("Edit", systemImage: "pencil") + } + .tint(.blue) + } + } + .alert("Delete Problem", isPresented: .constant(problemToDelete != nil)) { + Button("Cancel", role: .cancel) { + problemToDelete = nil + } + Button("Delete", role: .destructive) { + if let problem = problemToDelete { + dataManager.deleteProblem(problem) + problemToDelete = nil + } + } + } message: { + Text( + "Are you sure you want to delete this problem? This will also delete all associated attempts." + ) + } + .sheet(item: $problemToEdit) { problem in + AddEditProblemView(problemId: problem.id) } - .listStyle(.plain) } } @@ -269,19 +288,10 @@ struct ProblemRow: View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { ForEach(problem.imagePaths.prefix(3), id: \.self) { imagePath in - AsyncImage(url: URL(fileURLWithPath: imagePath)) { image in - image - .resizable() - .aspectRatio(contentMode: .fill) - } placeholder: { - RoundedRectangle(cornerRadius: 8) - .fill(.gray.opacity(0.3)) - } - .frame(width: 60, height: 60) - .clipped() - .cornerRadius(8) + ProblemImageView(imagePath: imagePath) } } + .padding(.horizontal, 4) } } @@ -292,7 +302,7 @@ struct ProblemRow: View { .fontWeight(.medium) } } - .padding(.vertical, 4) + .padding(.vertical, 8) } } @@ -356,6 +366,70 @@ struct EmptyProblemsView: View { } } +struct ProblemImageView: View { + let imagePath: String + @State private var uiImage: UIImage? + @State private var isLoading = true + @State private var hasFailed = false + + var body: some View { + Group { + if let uiImage = uiImage { + Image(uiImage: uiImage) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 60, height: 60) + .clipped() + .cornerRadius(8) + } else if hasFailed { + RoundedRectangle(cornerRadius: 8) + .fill(.gray.opacity(0.2)) + .frame(width: 60, height: 60) + .overlay { + Image(systemName: "photo") + .foregroundColor(.gray) + .font(.title3) + } + } else { + RoundedRectangle(cornerRadius: 8) + .fill(.gray.opacity(0.3)) + .frame(width: 60, height: 60) + .overlay { + ProgressView() + .scaleEffect(0.8) + } + } + } + .onAppear { + loadImage() + } + } + + private func loadImage() { + guard !imagePath.isEmpty else { + hasFailed = true + isLoading = false + return + } + + DispatchQueue.global(qos: .userInitiated).async { + if let data = ImageManager.shared.loadImageData(fromPath: imagePath), + let image = UIImage(data: data) + { + DispatchQueue.main.async { + self.uiImage = image + self.isLoading = false + } + } else { + DispatchQueue.main.async { + self.hasFailed = true + self.isLoading = false + } + } + } + } +} + #Preview { ProblemsView() .environmentObject(ClimbingDataManager.preview) diff --git a/ios/OpenClimb/Views/SessionsView.swift b/ios/OpenClimb/Views/SessionsView.swift index e2e5947..3e7f600 100644 --- a/ios/OpenClimb/Views/SessionsView.swift +++ b/ios/OpenClimb/Views/SessionsView.swift @@ -1,9 +1,3 @@ -// -// SessionsView.swift -// OpenClimb -// -// Created by OpenClimb on 2025-01-17. -// import Combine import SwiftUI @@ -127,6 +121,7 @@ struct ActiveSessionBanner: View { struct SessionsList: View { @EnvironmentObject var dataManager: ClimbingDataManager + @State private var sessionToDelete: ClimbSession? private var completedSessions: [ClimbSession] { dataManager.sessions @@ -139,8 +134,29 @@ struct SessionsList: View { NavigationLink(destination: SessionDetailView(sessionId: session.id)) { SessionRow(session: session) } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button(role: .destructive) { + sessionToDelete = session + } label: { + Label("Delete", systemImage: "trash") + } + } + } + .alert("Delete Session", isPresented: .constant(sessionToDelete != nil)) { + Button("Cancel", role: .cancel) { + sessionToDelete = nil + } + Button("Delete", role: .destructive) { + if let session = sessionToDelete { + dataManager.deleteSession(session) + sessionToDelete = nil + } + } + } message: { + Text( + "Are you sure you want to delete this session? This will also delete all attempts associated with this session." + ) } - .listStyle(.plain) } } @@ -179,7 +195,7 @@ struct SessionRow: View { .lineLimit(2) } } - .padding(.vertical, 4) + .padding(.vertical, 8) } private func formatDate(_ date: Date) -> String { diff --git a/ios/OpenClimb/Views/SettingsView.swift b/ios/OpenClimb/Views/SettingsView.swift index 018e9e3..38fbbe9 100644 --- a/ios/OpenClimb/Views/SettingsView.swift +++ b/ios/OpenClimb/Views/SettingsView.swift @@ -1,9 +1,3 @@ -// -// SettingsView.swift -// OpenClimb -// -// Created by OpenClimb on 2025-01-17. -// import SwiftUI import UniformTypeIdentifiers @@ -23,6 +17,8 @@ struct SettingsView: View { activeSheet: $activeSheet ) + ImageStorageSection() + AppInfoSection() } .navigationTitle("Settings") @@ -130,6 +126,96 @@ struct DataManagementSection: View { } } +struct ImageStorageSection: View { + @EnvironmentObject var dataManager: ClimbingDataManager + @State private var showingStorageInfo = false + @State private var storageInfo = "" + @State private var showingRecoveryAlert = false + @State private var showingEmergencyAlert = false + + var body: some View { + Section("Image Storage") { + // Storage Status + Button(action: { + storageInfo = dataManager.getImageRecoveryStatus() + showingStorageInfo = true + }) { + HStack { + Image(systemName: "info.circle") + .foregroundColor(.blue) + Text("Check Storage Health") + Spacer() + } + } + .foregroundColor(.primary) + + // Manual Maintenance + Button(action: { + dataManager.manualImageMaintenance() + }) { + HStack { + Image(systemName: "wrench.and.screwdriver") + .foregroundColor(.orange) + Text("Run Maintenance") + Spacer() + } + } + .foregroundColor(.primary) + + // Force Recovery + Button(action: { + showingRecoveryAlert = true + }) { + HStack { + Image(systemName: "arrow.clockwise") + .foregroundColor(.orange) + Text("Force Image Recovery") + Spacer() + } + } + .foregroundColor(.primary) + + // Emergency Restore + Button(action: { + showingEmergencyAlert = true + }) { + HStack { + Image(systemName: "exclamationmark.triangle") + .foregroundColor(.red) + Text("Emergency Restore") + Spacer() + } + } + .foregroundColor(.red) + } + .alert("Storage Information", isPresented: $showingStorageInfo) { + Button("OK") {} + } message: { + Text(storageInfo) + } + .alert("Force Image Recovery", isPresented: $showingRecoveryAlert) { + Button("Cancel", role: .cancel) {} + Button("Force Recovery", role: .destructive) { + dataManager.forceImageRecovery() + } + } message: { + Text( + "This will attempt to recover missing images from backups and legacy locations. Use this if images are missing after app updates or debug sessions." + ) + } + .alert("Emergency Restore", isPresented: $showingEmergencyAlert) { + Button("Cancel", role: .cancel) {} + Button("Emergency Restore", role: .destructive) { + dataManager.emergencyImageRestore() + } + } message: { + Text( + "This will restore all images from the backup directory, potentially overwriting current images. Only use this if normal recovery fails." + ) + } + } +} + struct AppInfoSection: View { private var appVersion: String { Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown"