1.0.0 for iOS is ready to ship
11
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.
|
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
|
## Download
|
||||||
|
|
||||||
You have two options:
|
For Android do one of the following:
|
||||||
|
|
||||||
1. Download the latest APK from the Releases page
|
1. Download the latest APK from the Releases page
|
||||||
2. [<img src="https://github.com/ImranR98/Obtainium/blob/main/assets/graphics/badge_obtainium.png?raw=true" alt="Obtainium" height="41">](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)
|
2. [<img src="https://github.com/ImranR98/Obtainium/blob/main/assets/graphics/badge_obtainium.png?raw=true" alt="Obtainium" height="41">](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
|
## Requirements
|
||||||
|
|
||||||
- Android 12+ or iOS 17+
|
- Android 12+ or iOS 17+
|
||||||
|
|||||||
@@ -285,7 +285,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.5.0;
|
MARKETING_VERSION = 1.0.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb;
|
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
@@ -323,7 +323,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.5.0;
|
MARKETING_VERSION = 1.0.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb;
|
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
"value": "dark"
|
"value": "dark"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"filename": "app_icon_1024.png",
|
"filename": "app_icon_1024_dark.png",
|
||||||
"idiom": "universal",
|
"idiom": "universal",
|
||||||
"platform": "ios",
|
"platform": "ios",
|
||||||
"size": "1024x1024"
|
"size": "1024x1024"
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
"value": "tinted"
|
"value": "tinted"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"filename": "app_icon_1024.png",
|
"filename": "app_icon_1024_tinted.png",
|
||||||
"idiom": "universal",
|
"idiom": "universal",
|
||||||
"platform": "ios",
|
"platform": "ios",
|
||||||
"size": "1024x1024"
|
"size": "1024x1024"
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="1024" height="1024" fill="#1A1A1A" rx="180" ry="180"/>
|
||||||
|
|
||||||
|
<g transform="translate(512, 512) scale(2.5)">
|
||||||
|
<polygon points="-70,80 -20,-60 30,80"
|
||||||
|
fill="#FFC107"
|
||||||
|
stroke="#1C1C1C"
|
||||||
|
stroke-width="4"
|
||||||
|
stroke-linejoin="round"/>
|
||||||
|
|
||||||
|
<polygon points="0,80 50,-80 100,80"
|
||||||
|
fill="#F44336"
|
||||||
|
stroke="#1C1C1C"
|
||||||
|
stroke-width="4"
|
||||||
|
stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 604 B |
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="1024" height="1024" fill="#FFFFFF" rx="180" ry="180"/>
|
||||||
|
|
||||||
|
<g transform="translate(512, 512) scale(2.5)">
|
||||||
|
<polygon points="-70,80 -20,-60 30,80"
|
||||||
|
fill="#FFC107"
|
||||||
|
stroke="#1C1C1C"
|
||||||
|
stroke-width="4"
|
||||||
|
stroke-linejoin="round"/>
|
||||||
|
|
||||||
|
<polygon points="0,80 50,-80 100,80"
|
||||||
|
fill="#F44336"
|
||||||
|
stroke="#1C1C1C"
|
||||||
|
stroke-width="4"
|
||||||
|
stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 604 B |
@@ -0,0 +1,20 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="1024" height="1024" fill="transparent" rx="180" ry="180"/>
|
||||||
|
|
||||||
|
<g transform="translate(512, 512) scale(2.5)">
|
||||||
|
<polygon points="-70,80 -20,-60 30,80"
|
||||||
|
fill="#000000"
|
||||||
|
stroke="#000000"
|
||||||
|
stroke-width="4"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
opacity="0.8"/>
|
||||||
|
|
||||||
|
<polygon points="0,80 50,-80 100,80"
|
||||||
|
fill="#000000"
|
||||||
|
stroke="#000000"
|
||||||
|
stroke-width="4"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
opacity="0.9"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 662 B |
@@ -1,23 +1,56 @@
|
|||||||
{
|
{
|
||||||
"images": [
|
"images" : [
|
||||||
{
|
{
|
||||||
"filename": "mountains_icon_256.png",
|
"filename" : "mountains_icon_256.png",
|
||||||
"idiom": "universal",
|
"idiom" : "universal",
|
||||||
"scale": "1x"
|
"scale" : "1x"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filename": "mountains_icon_256.png",
|
"appearances" : [
|
||||||
"idiom": "universal",
|
{
|
||||||
"scale": "2x"
|
"appearance" : "luminosity",
|
||||||
|
"value" : "dark"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"filename" : "mountains_icon_256_dark.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filename": "mountains_icon_256.png",
|
"filename" : "mountains_icon_256.png",
|
||||||
"idiom": "universal",
|
"idiom" : "universal",
|
||||||
"scale": "3x"
|
"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": {
|
"info" : {
|
||||||
"author": "xcode",
|
"author" : "xcode",
|
||||||
"version": 1
|
"version" : 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 4.0 KiB |
BIN
ios/OpenClimb/Assets.xcassets/MountainsIcon.imageset/mountains_icon_256_dark.png
vendored
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
@@ -1,9 +1,3 @@
|
|||||||
//
|
|
||||||
// ContentView.swift
|
|
||||||
// OpenClimb
|
|
||||||
//
|
|
||||||
// Created by OpenClimb on 2025-01-17.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,3 @@
|
|||||||
//
|
|
||||||
// DataModels.swift
|
|
||||||
// OpenClimb
|
|
||||||
//
|
|
||||||
// Created by OpenClimb on 2025-01-17.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|||||||
@@ -1,9 +1,3 @@
|
|||||||
//
|
|
||||||
// OpenClimbApp.swift
|
|
||||||
// OpenClimb
|
|
||||||
//
|
|
||||||
// Created by OpenClimb on 2025-01-17.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
|||||||
116
ios/OpenClimb/Utils/AppIconHelper.swift
Normal file
@@ -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
|
||||||
579
ios/OpenClimb/Utils/IconTestView.swift
Normal file
@@ -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
|
||||||
854
ios/OpenClimb/Utils/ImageManager.swift
Normal file
@@ -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<String>()
|
||||||
|
|
||||||
|
// 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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,3 @@
|
|||||||
//
|
|
||||||
// ZipUtils.swift
|
|
||||||
// OpenClimb
|
|
||||||
//
|
|
||||||
// Created by OpenClimb on 2025-01-17.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Compression
|
import Compression
|
||||||
import Foundation
|
import Foundation
|
||||||
@@ -169,21 +163,9 @@ struct ZipUtils {
|
|||||||
entry.filename.dropFirst("\(IMAGES_DIR_NAME)/".count))
|
entry.filename.dropFirst("\(IMAGES_DIR_NAME)/".count))
|
||||||
|
|
||||||
do {
|
do {
|
||||||
|
let filename = try ImageManager.shared.saveImportedImage(
|
||||||
let documentsURL = FileManager.default.urls(
|
entry.data, filename: originalFilename)
|
||||||
for: .documentDirectory, in: .userDomainMask
|
importedImagePaths[originalFilename] = filename
|
||||||
).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)"
|
|
||||||
)
|
|
||||||
} catch {
|
} catch {
|
||||||
print("Failed to import image \(originalFilename): \(error)")
|
print("Failed to import image \(originalFilename): \(error)")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,3 @@
|
|||||||
//
|
|
||||||
// ClimbingDataManager.swift
|
|
||||||
// OpenClimb
|
|
||||||
//
|
|
||||||
// Created by OpenClimb on 2025-01-17.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Combine
|
import Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
@@ -35,7 +28,14 @@ class ClimbingDataManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
|
_ = ImageManager.shared
|
||||||
loadAllData()
|
loadAllData()
|
||||||
|
migrateImagePaths()
|
||||||
|
|
||||||
|
Task {
|
||||||
|
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
||||||
|
await performImageMaintenance()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadAllData() {
|
private func loadAllData() {
|
||||||
@@ -181,11 +181,12 @@ class ClimbingDataManager: ObservableObject {
|
|||||||
attempts.removeAll { $0.problemId == problem.id }
|
attempts.removeAll { $0.problemId == problem.id }
|
||||||
saveAttempts()
|
saveAttempts()
|
||||||
|
|
||||||
|
// Delete associated images
|
||||||
|
ImageManager.shared.deleteImages(atPaths: problem.imagePaths)
|
||||||
|
|
||||||
// Delete the problem
|
// Delete the problem
|
||||||
problems.removeAll { $0.id == problem.id }
|
problems.removeAll { $0.id == problem.id }
|
||||||
saveProblems()
|
saveProblems()
|
||||||
successMessage = "Problem deleted successfully"
|
|
||||||
clearMessageAfterDelay()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func problem(withId id: UUID) -> Problem? {
|
func problem(withId id: UUID) -> Problem? {
|
||||||
@@ -770,7 +771,6 @@ struct AndroidAttempt: Codable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Helper Functions
|
|
||||||
extension ClimbingDataManager {
|
extension ClimbingDataManager {
|
||||||
private func collectReferencedImagePaths() -> Set<String> {
|
private func collectReferencedImagePaths() -> Set<String> {
|
||||||
var imagePaths = Set<String>()
|
var imagePaths = Set<String>()
|
||||||
@@ -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 {
|
private func validateImportData(_ importData: ClimbDataExport) throws {
|
||||||
if importData.gyms.isEmpty {
|
if importData.gyms.isEmpty {
|
||||||
throw NSError(
|
throw NSError(
|
||||||
@@ -802,7 +933,6 @@ extension ClimbingDataManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Preview Helper
|
|
||||||
extension ClimbingDataManager {
|
extension ClimbingDataManager {
|
||||||
static var preview: ClimbingDataManager {
|
static var preview: ClimbingDataManager {
|
||||||
let manager = ClimbingDataManager()
|
let manager = ClimbingDataManager()
|
||||||
|
|||||||
@@ -1,9 +1,3 @@
|
|||||||
//
|
|
||||||
// AddAttemptView.swift
|
|
||||||
// OpenClimb
|
|
||||||
//
|
|
||||||
// Created by OpenClimb on 2025-01-17.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
@@ -99,14 +93,20 @@ struct AddAttemptView: View {
|
|||||||
}
|
}
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
} else {
|
} else {
|
||||||
ForEach(activeProblems, id: \.id) { problem in
|
LazyVGrid(
|
||||||
ProblemSelectionRow(
|
columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 2),
|
||||||
problem: problem,
|
spacing: 8
|
||||||
isSelected: selectedProblem?.id == problem.id
|
) {
|
||||||
) {
|
ForEach(activeProblems, id: \.id) { problem in
|
||||||
selectedProblem = problem
|
ProblemSelectionCard(
|
||||||
|
problem: problem,
|
||||||
|
isSelected: selectedProblem?.id == problem.id
|
||||||
|
) {
|
||||||
|
selectedProblem = problem
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
|
||||||
Button("Create New Problem") {
|
Button("Create New Problem") {
|
||||||
showingCreateProblem = true
|
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 {
|
struct EditAttemptView: View {
|
||||||
let attempt: Attempt
|
let attempt: Attempt
|
||||||
@EnvironmentObject var dataManager: ClimbingDataManager
|
@EnvironmentObject var dataManager: ClimbingDataManager
|
||||||
@@ -556,3 +747,131 @@ struct EditAttemptView: View {
|
|||||||
)
|
)
|
||||||
.environmentObject(ClimbingDataManager.preview)
|
.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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,3 @@
|
|||||||
//
|
|
||||||
// AddEditGymView.swift
|
|
||||||
// OpenClimb
|
|
||||||
//
|
|
||||||
// Created by OpenClimb on 2025-01-17.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,3 @@
|
|||||||
//
|
|
||||||
// AddEditProblemView.swift
|
|
||||||
// OpenClimb
|
|
||||||
//
|
|
||||||
// Created by OpenClimb on 2025-01-17.
|
|
||||||
//
|
|
||||||
|
|
||||||
import PhotosUI
|
import PhotosUI
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
@@ -459,19 +453,10 @@ struct AddEditProblemView: View {
|
|||||||
private func loadSelectedPhotos() async {
|
private func loadSelectedPhotos() async {
|
||||||
for item in selectedPhotos {
|
for item in selectedPhotos {
|
||||||
if let data = try? await item.loadTransferable(type: Data.self) {
|
if let data = try? await item.loadTransferable(type: Data.self) {
|
||||||
// Save to app's documents directory
|
// Use ImageManager to save image
|
||||||
let documentsPath = FileManager.default.urls(
|
if let relativePath = ImageManager.shared.saveImageData(data) {
|
||||||
for: .documentDirectory, in: .userDomainMask
|
imagePaths.append(relativePath)
|
||||||
).first!
|
|
||||||
let imageName = "photo_\(UUID().uuidString).jpg"
|
|
||||||
let imagePath = documentsPath.appendingPathComponent(imageName)
|
|
||||||
|
|
||||||
do {
|
|
||||||
try data.write(to: imagePath)
|
|
||||||
imagePaths.append(imagePath.path)
|
|
||||||
imageData.append(data)
|
imageData.append(data)
|
||||||
} catch {
|
|
||||||
print("Failed to save image: \(error)")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,3 @@
|
|||||||
//
|
|
||||||
// AddEditSessionView.swift
|
|
||||||
// OpenClimb
|
|
||||||
//
|
|
||||||
// Created by OpenClimb on 2025-01-17.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,3 @@
|
|||||||
//
|
|
||||||
// AnalyticsView.swift
|
|
||||||
// OpenClimb
|
|
||||||
//
|
|
||||||
// Created by OpenClimb on 2025-01-17.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct AnalyticsView: View {
|
struct AnalyticsView: View {
|
||||||
@@ -538,8 +531,6 @@ struct ProgressDataPoint {
|
|||||||
let difficultySystem: DifficultySystem
|
let difficultySystem: DifficultySystem
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Helper Functions
|
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
AnalyticsView()
|
AnalyticsView()
|
||||||
.environmentObject(ClimbingDataManager.preview)
|
.environmentObject(ClimbingDataManager.preview)
|
||||||
|
|||||||
@@ -1,9 +1,3 @@
|
|||||||
//
|
|
||||||
// GymDetailView.swift
|
|
||||||
// OpenClimb
|
|
||||||
//
|
|
||||||
// Created by OpenClimb on 2025-01-17.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,3 @@
|
|||||||
//
|
|
||||||
// ProblemDetailView.swift
|
|
||||||
// OpenClimb
|
|
||||||
//
|
|
||||||
// Created by OpenClimb on 2025-01-17.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
@@ -296,21 +290,11 @@ struct PhotosSection: View {
|
|||||||
ScrollView(.horizontal, showsIndicators: false) {
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
ForEach(imagePaths.indices, id: \.self) { index in
|
ForEach(imagePaths.indices, id: \.self) { index in
|
||||||
AsyncImage(url: URL(fileURLWithPath: imagePaths[index])) { image in
|
ProblemDetailImageView(imagePath: imagePaths[index])
|
||||||
image
|
.onTapGesture {
|
||||||
.resizable()
|
selectedImageIndex = index
|
||||||
.aspectRatio(contentMode: .fill)
|
showingImageViewer = true
|
||||||
} placeholder: {
|
}
|
||||||
RoundedRectangle(cornerRadius: 12)
|
|
||||||
.fill(.gray.opacity(0.3))
|
|
||||||
}
|
|
||||||
.frame(width: 120, height: 120)
|
|
||||||
.clipped()
|
|
||||||
.cornerRadius(12)
|
|
||||||
.onTapGesture {
|
|
||||||
selectedImageIndex = index
|
|
||||||
showingImageViewer = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 1)
|
.padding(.horizontal, 1)
|
||||||
@@ -444,14 +428,8 @@ struct ImageViewerView: View {
|
|||||||
NavigationView {
|
NavigationView {
|
||||||
TabView(selection: $currentIndex) {
|
TabView(selection: $currentIndex) {
|
||||||
ForEach(imagePaths.indices, id: \.self) { index in
|
ForEach(imagePaths.indices, id: \.self) { index in
|
||||||
AsyncImage(url: URL(fileURLWithPath: imagePaths[index])) { image in
|
ProblemDetailImageFullView(imagePath: imagePaths[index])
|
||||||
image
|
.tag(index)
|
||||||
.resizable()
|
|
||||||
.aspectRatio(contentMode: .fit)
|
|
||||||
} placeholder: {
|
|
||||||
ProgressView()
|
|
||||||
}
|
|
||||||
.tag(index)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.tabViewStyle(.page(indexDisplayMode: .always))
|
.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 {
|
#Preview {
|
||||||
NavigationView {
|
NavigationView {
|
||||||
ProblemDetailView(problemId: UUID())
|
ProblemDetailView(problemId: UUID())
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
//
|
|
||||||
// SessionDetailView.swift
|
|
||||||
// OpenClimb
|
|
||||||
//
|
|
||||||
// Created by OpenClimb on 2025-01-17.
|
|
||||||
//
|
|
||||||
|
|
||||||
|
import Combine
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct SessionDetailView: View {
|
struct SessionDetailView: View {
|
||||||
@@ -14,6 +9,8 @@ struct SessionDetailView: View {
|
|||||||
@State private var showingDeleteAlert = false
|
@State private var showingDeleteAlert = false
|
||||||
@State private var showingAddAttempt = false
|
@State private var showingAddAttempt = false
|
||||||
@State private var editingAttempt: Attempt?
|
@State private var editingAttempt: Attempt?
|
||||||
|
@State private var attemptToDelete: Attempt?
|
||||||
|
@State private var currentTime = Date()
|
||||||
|
|
||||||
private var session: ClimbSession? {
|
private var session: ClimbSession? {
|
||||||
dataManager.session(withId: sessionId)
|
dataManager.session(withId: sessionId)
|
||||||
@@ -39,15 +36,20 @@ struct SessionDetailView: View {
|
|||||||
calculateSessionStats()
|
calculateSessionStats()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVStack(spacing: 20) {
|
LazyVStack(spacing: 20) {
|
||||||
if let session = session, let gym = gym {
|
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)
|
SessionStatsCard(stats: sessionStats)
|
||||||
|
|
||||||
AttemptsSection(attemptsWithProblems: attemptsWithProblems)
|
AttemptsSection(
|
||||||
|
attemptsWithProblems: attemptsWithProblems,
|
||||||
|
attemptToDelete: $attemptToDelete)
|
||||||
} else {
|
} else {
|
||||||
Text("Session not found")
|
Text("Session not found")
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
@@ -55,6 +57,9 @@ struct SessionDetailView: View {
|
|||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
|
.onReceive(timer) { _ in
|
||||||
|
currentTime = Date()
|
||||||
|
}
|
||||||
.navigationTitle("Session Details")
|
.navigationTitle("Session Details")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
@@ -80,6 +85,33 @@ struct SessionDetailView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.alert(
|
||||||
|
"Delete Attempt",
|
||||||
|
isPresented: Binding<Bool>(
|
||||||
|
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) {
|
.overlay(alignment: .bottomTrailing) {
|
||||||
if session?.status == .active {
|
if session?.status == .active {
|
||||||
Button(action: { showingAddAttempt = true }) {
|
Button(action: { showingAddAttempt = true }) {
|
||||||
@@ -140,12 +172,26 @@ struct SessionDetailView: View {
|
|||||||
|
|
||||||
private func gradeRange(for problems: [Problem]) -> String? {
|
private func gradeRange(for problems: [Problem]) -> String? {
|
||||||
guard !problems.isEmpty else { return nil }
|
guard !problems.isEmpty else { return nil }
|
||||||
let grades = problems.map { $0.difficulty }.sorted()
|
let difficulties = problems.map { $0.difficulty }
|
||||||
if grades.count == 1 {
|
|
||||||
return grades.first?.grade
|
// Group by difficulty system first
|
||||||
} else {
|
let groupedBySystem = Dictionary(grouping: difficulties) { $0.system }
|
||||||
return "\(grades.first?.grade ?? "") - \(grades.last?.grade ?? "")"
|
|
||||||
|
// 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 session: ClimbSession
|
||||||
let gym: Gym
|
let gym: Gym
|
||||||
let stats: SessionStats
|
let stats: SessionStats
|
||||||
|
let currentTime: Date
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
@@ -165,7 +212,13 @@ struct SessionHeaderCard: View {
|
|||||||
.font(.title2)
|
.font(.title2)
|
||||||
.foregroundColor(.blue)
|
.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")
|
Text("Duration: \(duration) minutes")
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
@@ -209,6 +262,21 @@ struct SessionHeaderCard: View {
|
|||||||
formatter.dateStyle = .full
|
formatter.dateStyle = .full
|
||||||
return formatter.string(from: date)
|
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 {
|
struct SessionStatsCard: View {
|
||||||
@@ -276,6 +344,7 @@ struct StatItem: View {
|
|||||||
|
|
||||||
struct AttemptsSection: View {
|
struct AttemptsSection: View {
|
||||||
let attemptsWithProblems: [(Attempt, Problem)]
|
let attemptsWithProblems: [(Attempt, Problem)]
|
||||||
|
@Binding var attemptToDelete: Attempt?
|
||||||
@EnvironmentObject var dataManager: ClimbingDataManager
|
@EnvironmentObject var dataManager: ClimbingDataManager
|
||||||
@State private var editingAttempt: Attempt?
|
@State private var editingAttempt: Attempt?
|
||||||
|
|
||||||
@@ -311,6 +380,30 @@ struct AttemptsSection: View {
|
|||||||
ForEach(attemptsWithProblems.indices, id: \.self) { index in
|
ForEach(attemptsWithProblems.indices, id: \.self) { index in
|
||||||
let (attempt, problem) = attemptsWithProblems[index]
|
let (attempt, problem) = attemptsWithProblems[index]
|
||||||
AttemptCard(attempt: attempt, problem: problem)
|
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 {
|
.onTapGesture {
|
||||||
editingAttempt = attempt
|
editingAttempt = attempt
|
||||||
}
|
}
|
||||||
@@ -327,8 +420,6 @@ struct AttemptsSection: View {
|
|||||||
struct AttemptCard: View {
|
struct AttemptCard: View {
|
||||||
let attempt: Attempt
|
let attempt: Attempt
|
||||||
let problem: Problem
|
let problem: Problem
|
||||||
@EnvironmentObject var dataManager: ClimbingDataManager
|
|
||||||
@State private var showingDeleteAlert = false
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
@@ -353,15 +444,6 @@ struct AttemptCard: View {
|
|||||||
|
|
||||||
VStack(alignment: .trailing, spacing: 8) {
|
VStack(alignment: .trailing, spacing: 8) {
|
||||||
AttemptResultBadge(result: attempt.result)
|
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()
|
.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?")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,3 @@
|
|||||||
//
|
|
||||||
// GymsView.swift
|
|
||||||
// OpenClimb
|
|
||||||
//
|
|
||||||
// Created by OpenClimb on 2025-01-17.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
@@ -37,14 +31,47 @@ struct GymsView: View {
|
|||||||
|
|
||||||
struct GymsList: View {
|
struct GymsList: View {
|
||||||
@EnvironmentObject var dataManager: ClimbingDataManager
|
@EnvironmentObject var dataManager: ClimbingDataManager
|
||||||
|
@State private var gymToDelete: Gym?
|
||||||
|
@State private var gymToEdit: Gym?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
List(dataManager.gyms, id: \.id) { gym in
|
List(dataManager.gyms, id: \.id) { gym in
|
||||||
NavigationLink(destination: GymDetailView(gymId: gym.id)) {
|
NavigationLink(destination: GymDetailView(gymId: gym.id)) {
|
||||||
GymRow(gym: gym)
|
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)
|
.lineLimit(2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.vertical, 4)
|
.padding(.vertical, 8)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,3 @@
|
|||||||
//
|
|
||||||
// ProblemsView.swift
|
|
||||||
// OpenClimb
|
|
||||||
//
|
|
||||||
// Created by OpenClimb on 2025-01-17.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
@@ -45,9 +39,13 @@ struct ProblemsView: View {
|
|||||||
NavigationView {
|
NavigationView {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
if !dataManager.problems.isEmpty {
|
if !dataManager.problems.isEmpty {
|
||||||
FilterSection()
|
FilterSection(
|
||||||
.padding()
|
selectedClimbType: $selectedClimbType,
|
||||||
.background(.regularMaterial)
|
selectedGym: $selectedGym,
|
||||||
|
filteredProblems: filteredProblems
|
||||||
|
)
|
||||||
|
.padding()
|
||||||
|
.background(.regularMaterial)
|
||||||
}
|
}
|
||||||
|
|
||||||
if filteredProblems.isEmpty {
|
if filteredProblems.isEmpty {
|
||||||
@@ -79,8 +77,9 @@ struct ProblemsView: View {
|
|||||||
|
|
||||||
struct FilterSection: View {
|
struct FilterSection: View {
|
||||||
@EnvironmentObject var dataManager: ClimbingDataManager
|
@EnvironmentObject var dataManager: ClimbingDataManager
|
||||||
@State private var selectedClimbType: ClimbType?
|
@Binding var selectedClimbType: ClimbType?
|
||||||
@State private var selectedGym: Gym?
|
@Binding var selectedGym: Gym?
|
||||||
|
let filteredProblems: [Problem]
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 12) {
|
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 {
|
struct FilterChip: View {
|
||||||
@@ -195,14 +181,47 @@ struct FilterChip: View {
|
|||||||
struct ProblemsList: View {
|
struct ProblemsList: View {
|
||||||
let problems: [Problem]
|
let problems: [Problem]
|
||||||
@EnvironmentObject var dataManager: ClimbingDataManager
|
@EnvironmentObject var dataManager: ClimbingDataManager
|
||||||
|
@State private var problemToDelete: Problem?
|
||||||
|
@State private var problemToEdit: Problem?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
List(problems) { problem in
|
List(problems) { problem in
|
||||||
NavigationLink(destination: ProblemDetailView(problemId: problem.id)) {
|
NavigationLink(destination: ProblemDetailView(problemId: problem.id)) {
|
||||||
ProblemRow(problem: problem)
|
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) {
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
ForEach(problem.imagePaths.prefix(3), id: \.self) { imagePath in
|
ForEach(problem.imagePaths.prefix(3), id: \.self) { imagePath in
|
||||||
AsyncImage(url: URL(fileURLWithPath: imagePath)) { image in
|
ProblemImageView(imagePath: imagePath)
|
||||||
image
|
|
||||||
.resizable()
|
|
||||||
.aspectRatio(contentMode: .fill)
|
|
||||||
} placeholder: {
|
|
||||||
RoundedRectangle(cornerRadius: 8)
|
|
||||||
.fill(.gray.opacity(0.3))
|
|
||||||
}
|
|
||||||
.frame(width: 60, height: 60)
|
|
||||||
.clipped()
|
|
||||||
.cornerRadius(8)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.padding(.horizontal, 4)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,7 +302,7 @@ struct ProblemRow: View {
|
|||||||
.fontWeight(.medium)
|
.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 {
|
#Preview {
|
||||||
ProblemsView()
|
ProblemsView()
|
||||||
.environmentObject(ClimbingDataManager.preview)
|
.environmentObject(ClimbingDataManager.preview)
|
||||||
|
|||||||
@@ -1,9 +1,3 @@
|
|||||||
//
|
|
||||||
// SessionsView.swift
|
|
||||||
// OpenClimb
|
|
||||||
//
|
|
||||||
// Created by OpenClimb on 2025-01-17.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Combine
|
import Combine
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
@@ -127,6 +121,7 @@ struct ActiveSessionBanner: View {
|
|||||||
|
|
||||||
struct SessionsList: View {
|
struct SessionsList: View {
|
||||||
@EnvironmentObject var dataManager: ClimbingDataManager
|
@EnvironmentObject var dataManager: ClimbingDataManager
|
||||||
|
@State private var sessionToDelete: ClimbSession?
|
||||||
|
|
||||||
private var completedSessions: [ClimbSession] {
|
private var completedSessions: [ClimbSession] {
|
||||||
dataManager.sessions
|
dataManager.sessions
|
||||||
@@ -139,8 +134,29 @@ struct SessionsList: View {
|
|||||||
NavigationLink(destination: SessionDetailView(sessionId: session.id)) {
|
NavigationLink(destination: SessionDetailView(sessionId: session.id)) {
|
||||||
SessionRow(session: session)
|
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)
|
.lineLimit(2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.vertical, 4)
|
.padding(.vertical, 8)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func formatDate(_ date: Date) -> String {
|
private func formatDate(_ date: Date) -> String {
|
||||||
|
|||||||
@@ -1,9 +1,3 @@
|
|||||||
//
|
|
||||||
// SettingsView.swift
|
|
||||||
// OpenClimb
|
|
||||||
//
|
|
||||||
// Created by OpenClimb on 2025-01-17.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
@@ -23,6 +17,8 @@ struct SettingsView: View {
|
|||||||
activeSheet: $activeSheet
|
activeSheet: $activeSheet
|
||||||
)
|
)
|
||||||
|
|
||||||
|
ImageStorageSection()
|
||||||
|
|
||||||
AppInfoSection()
|
AppInfoSection()
|
||||||
}
|
}
|
||||||
.navigationTitle("Settings")
|
.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 {
|
struct AppInfoSection: View {
|
||||||
private var appVersion: String {
|
private var appVersion: String {
|
||||||
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown"
|
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown"
|
||||||
|
|||||||