diff --git a/README.md b/README.md
index 71be771..f52bac5 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. [
](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"