1.5.0 Initial run as iOS in a monorepo

This commit is contained in:
2025-09-12 22:35:14 -06:00
parent ba6edcd854
commit ce220c7220
127 changed files with 7062 additions and 1039 deletions

View File

@@ -0,0 +1,362 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objects = {
/* Begin PBXFileReference section */
D24C19682E75002A0045894C /* OpenClimb.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = OpenClimb.app; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
D28C3C8B2E75111D00F7AEE9 /* Exceptions for "OpenClimb" folder in "OpenClimb" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = D24C19672E75002A0045894C /* OpenClimb */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
D24C196A2E75002A0045894C /* OpenClimb */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
D28C3C8B2E75111D00F7AEE9 /* Exceptions for "OpenClimb" folder in "OpenClimb" target */,
);
path = OpenClimb;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
D24C19652E75002A0045894C /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
D24C195F2E75002A0045894C = {
isa = PBXGroup;
children = (
D24C196A2E75002A0045894C /* OpenClimb */,
D24C19692E75002A0045894C /* Products */,
);
sourceTree = "<group>";
};
D24C19692E75002A0045894C /* Products */ = {
isa = PBXGroup;
children = (
D24C19682E75002A0045894C /* OpenClimb.app */,
);
name = Products;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
D24C19672E75002A0045894C /* OpenClimb */ = {
isa = PBXNativeTarget;
buildConfigurationList = D24C19732E75002A0045894C /* Build configuration list for PBXNativeTarget "OpenClimb" */;
buildPhases = (
D24C19642E75002A0045894C /* Sources */,
D24C19652E75002A0045894C /* Frameworks */,
D24C19662E75002A0045894C /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
D24C196A2E75002A0045894C /* OpenClimb */,
);
name = OpenClimb;
packageProductDependencies = (
);
productName = OpenClimb;
productReference = D24C19682E75002A0045894C /* OpenClimb.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
D24C19602E75002A0045894C /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 2600;
LastUpgradeCheck = 2600;
TargetAttributes = {
D24C19672E75002A0045894C = {
CreatedOnToolsVersion = 26.0;
};
};
};
buildConfigurationList = D24C19632E75002A0045894C /* Build configuration list for PBXProject "OpenClimb" */;
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = D24C195F2E75002A0045894C;
minimizedProjectReferenceProxies = 1;
preferredProjectObjectVersion = 77;
productRefGroup = D24C19692E75002A0045894C /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
D24C19672E75002A0045894C /* OpenClimb */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
D24C19662E75002A0045894C /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
D24C19642E75002A0045894C /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
D24C19712E75002A0045894C /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
D24C19722E75002A0045894C /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
VALIDATE_PRODUCT = YES;
};
name = Release;
};
D24C19742E75002A0045894C /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = OpenClimb/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = OpenClimb;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.sports";
INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = YES;
INFOPLIST_KEY_NSCameraUsageDescription = "OpenClimb needs camera access to take photos of climbing problems.";
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "OpenClimb needs access to your photo library to save and display climbing problem images.";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.5.0;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
D24C19752E75002A0045894C /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = OpenClimb/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = OpenClimb;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.sports";
INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = YES;
INFOPLIST_KEY_NSCameraUsageDescription = "OpenClimb needs camera access to take photos of climbing problems.";
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "OpenClimb needs access to your photo library to save and display climbing problem images.";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.5.0;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
D24C19632E75002A0045894C /* Build configuration list for PBXProject "OpenClimb" */ = {
isa = XCConfigurationList;
buildConfigurations = (
D24C19712E75002A0045894C /* Debug */,
D24C19722E75002A0045894C /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
D24C19732E75002A0045894C /* Build configuration list for PBXNativeTarget "OpenClimb" */ = {
isa = XCConfigurationList;
buildConfigurations = (
D24C19742E75002A0045894C /* Debug */,
D24C19752E75002A0045894C /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = D24C19602E75002A0045894C /* Project object */;
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<array/>
</plist>

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>SchemeUserState</key>
<dict>
<key>OpenClimb.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>
</dict>
</dict>
</dict>
</plist>

View File

@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,35 @@
{
"images" : [
{
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "tinted"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,114 @@
//
// ContentView.swift
// OpenClimb
//
// Created by OpenClimb on 2025-01-17.
//
import SwiftUI
struct ContentView: View {
@StateObject private var dataManager = ClimbingDataManager()
@State private var selectedTab = 0
var body: some View {
TabView(selection: $selectedTab) {
SessionsView()
.tabItem {
Image(systemName: "play.fill")
Text("Sessions")
}
.tag(0)
ProblemsView()
.tabItem {
Image(systemName: "star.fill")
Text("Problems")
}
.tag(1)
AnalyticsView()
.tabItem {
Image(systemName: "chart.bar.fill")
Text("Analytics")
}
.tag(2)
GymsView()
.tabItem {
Image(systemName: "location.fill")
Text("Gyms")
}
.tag(3)
SettingsView()
.tabItem {
Image(systemName: "gear")
Text("Settings")
}
.tag(4)
}
.environmentObject(dataManager)
.overlay(alignment: .top) {
if let message = dataManager.successMessage {
SuccessMessageView(message: message)
.transition(.move(edge: .top).combined(with: .opacity))
.animation(.easeInOut, value: dataManager.successMessage)
}
if let error = dataManager.errorMessage {
ErrorMessageView(message: error)
.transition(.move(edge: .top).combined(with: .opacity))
.animation(.easeInOut, value: dataManager.errorMessage)
}
}
}
}
struct SuccessMessageView: View {
let message: String
var body: some View {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
Text(message)
.font(.subheadline)
.foregroundColor(.primary)
}
.padding()
.background(
RoundedRectangle(cornerRadius: 12)
.fill(.regularMaterial)
.shadow(radius: 4)
)
.padding(.horizontal)
.padding(.top, 8)
}
}
struct ErrorMessageView: View {
let message: String
var body: some View {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.red)
Text(message)
.font(.subheadline)
.foregroundColor(.primary)
}
.padding()
.background(
RoundedRectangle(cornerRadius: 12)
.fill(.regularMaterial)
.shadow(radius: 4)
)
.padding(.horizontal)
.padding(.top, 8)
}
}
#Preview {
ContentView()
}

8
ios/OpenClimb/Info.plist Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>UIFileSharingEnabled</key>
<true/>
</dict>
</plist>

View File

@@ -0,0 +1,561 @@
//
// DataModels.swift
// OpenClimb
//
// Created by OpenClimb on 2025-01-17.
//
import Foundation
import SwiftUI
enum ClimbType: String, CaseIterable, Codable {
case rope = "ROPE"
case boulder = "BOULDER"
var displayName: String {
switch self {
case .rope:
return "Rope"
case .boulder:
return "Bouldering"
}
}
}
enum DifficultySystem: String, CaseIterable, Codable {
case vScale = "V_SCALE"
case font = "FONT"
case yds = "YDS"
case custom = "CUSTOM"
var displayName: String {
switch self {
case .vScale:
return "V Scale"
case .font:
return "Font Scale"
case .yds:
return "YDS (Yosemite)"
case .custom:
return "Custom"
}
}
var isBoulderingSystem: Bool {
switch self {
case .vScale, .font:
return true
case .yds:
return false
case .custom:
return true
}
}
var isRopeSystem: Bool {
switch self {
case .yds:
return true
case .vScale, .font:
return false
case .custom:
return true
}
}
var availableGrades: [String] {
switch self {
case .vScale:
return [
"VB", "V0", "V1", "V2", "V3", "V4", "V5", "V6", "V7", "V8", "V9", "V10", "V11",
"V12", "V13", "V14", "V15", "V16", "V17",
]
case .font:
return [
"3", "4A", "4B", "4C", "5A", "5B", "5C", "6A", "6A+", "6B", "6B+", "6C", "6C+",
"7A", "7A+", "7B", "7B+", "7C", "7C+", "8A", "8A+", "8B", "8B+", "8C", "8C+",
]
case .yds:
return [
"5.0", "5.1", "5.2", "5.3", "5.4", "5.5", "5.6", "5.7", "5.8", "5.9", "5.10a",
"5.10b", "5.10c", "5.10d", "5.11a", "5.11b", "5.11c", "5.11d", "5.12a", "5.12b",
"5.12c", "5.12d", "5.13a", "5.13b", "5.13c", "5.13d", "5.14a", "5.14b", "5.14c",
"5.14d", "5.15a", "5.15b", "5.15c", "5.15d",
]
case .custom:
return []
}
}
static func systemsForClimbType(_ climbType: ClimbType) -> [DifficultySystem] {
switch climbType {
case .boulder:
return allCases.filter { $0.isBoulderingSystem }
case .rope:
return allCases.filter { $0.isRopeSystem }
}
}
}
enum AttemptResult: String, CaseIterable, Codable {
case success = "SUCCESS"
case fall = "FALL"
case noProgress = "NO_PROGRESS"
case flash = "FLASH"
var displayName: String {
switch self {
case .success:
return "Success"
case .fall:
return "Fall"
case .noProgress:
return "No Progress"
case .flash:
return "Flash"
}
}
var isSuccessful: Bool {
return self == .success || self == .flash
}
}
enum SessionStatus: String, CaseIterable, Codable {
case active = "ACTIVE"
case completed = "COMPLETED"
case paused = "PAUSED"
var displayName: String {
switch self {
case .active:
return "Active"
case .completed:
return "Completed"
case .paused:
return "Paused"
}
}
}
struct DifficultyGrade: Codable, Hashable {
let system: DifficultySystem
let grade: String
let numericValue: Int
init(system: DifficultySystem, grade: String) {
self.system = system
self.grade = grade
self.numericValue = Self.calculateNumericValue(system: system, grade: grade)
}
private static func calculateNumericValue(system: DifficultySystem, grade: String) -> Int {
switch system {
case .vScale:
if grade == "VB" { return 0 }
return Int(grade.replacingOccurrences(of: "V", with: "")) ?? 0
case .font:
let fontMapping: [String: Int] = [
"3": 3, "4A": 4, "4B": 5, "4C": 6, "5A": 7, "5B": 8, "5C": 9,
"6A": 10, "6A+": 11, "6B": 12, "6B+": 13, "6C": 14, "6C+": 15,
"7A": 16, "7A+": 17, "7B": 18, "7B+": 19, "7C": 20, "7C+": 21,
"8A": 22, "8A+": 23, "8B": 24, "8B+": 25, "8C": 26, "8C+": 27,
]
return fontMapping[grade] ?? 0
case .yds:
let ydsMapping: [String: Int] = [
"5.0": 50, "5.1": 51, "5.2": 52, "5.3": 53, "5.4": 54, "5.5": 55,
"5.6": 56, "5.7": 57, "5.8": 58, "5.9": 59, "5.10a": 60, "5.10b": 61,
"5.10c": 62, "5.10d": 63, "5.11a": 64, "5.11b": 65, "5.11c": 66,
"5.11d": 67, "5.12a": 68, "5.12b": 69, "5.12c": 70, "5.12d": 71,
"5.13a": 72, "5.13b": 73, "5.13c": 74, "5.13d": 75, "5.14a": 76,
"5.14b": 77, "5.14c": 78, "5.14d": 79, "5.15a": 80, "5.15b": 81,
"5.15c": 82, "5.15d": 83,
]
return ydsMapping[grade] ?? 0
case .custom:
return Int(grade) ?? 0
}
}
}
struct Gym: Identifiable, Codable, Hashable {
let id: UUID
let name: String
let location: String?
let supportedClimbTypes: [ClimbType]
let difficultySystems: [DifficultySystem]
let customDifficultyGrades: [String]
let notes: String?
let createdAt: Date
let updatedAt: Date
init(
name: String, location: String? = nil, supportedClimbTypes: [ClimbType],
difficultySystems: [DifficultySystem], customDifficultyGrades: [String] = [],
notes: String? = nil
) {
self.id = UUID()
self.name = name
self.location = location
self.supportedClimbTypes = supportedClimbTypes
self.difficultySystems = difficultySystems
self.customDifficultyGrades = customDifficultyGrades
self.notes = notes
let now = Date()
self.createdAt = now
self.updatedAt = now
}
func updated(
name: String? = nil, location: String? = nil, supportedClimbTypes: [ClimbType]? = nil,
difficultySystems: [DifficultySystem]? = nil, customDifficultyGrades: [String]? = nil,
notes: String? = nil
) -> Gym {
return Gym(
id: self.id,
name: name ?? self.name,
location: location ?? self.location,
supportedClimbTypes: supportedClimbTypes ?? self.supportedClimbTypes,
difficultySystems: difficultySystems ?? self.difficultySystems,
customDifficultyGrades: customDifficultyGrades ?? self.customDifficultyGrades,
notes: notes ?? self.notes,
createdAt: self.createdAt,
updatedAt: Date()
)
}
private init(
id: UUID, name: String, location: String?, supportedClimbTypes: [ClimbType],
difficultySystems: [DifficultySystem], customDifficultyGrades: [String], notes: String?,
createdAt: Date, updatedAt: Date
) {
self.id = id
self.name = name
self.location = location
self.supportedClimbTypes = supportedClimbTypes
self.difficultySystems = difficultySystems
self.customDifficultyGrades = customDifficultyGrades
self.notes = notes
self.createdAt = createdAt
self.updatedAt = updatedAt
}
static func fromImport(
id: UUID, name: String, location: String?, supportedClimbTypes: [ClimbType],
difficultySystems: [DifficultySystem], customDifficultyGrades: [String], notes: String?,
createdAt: Date, updatedAt: Date
) -> Gym {
return Gym(
id: id,
name: name,
location: location,
supportedClimbTypes: supportedClimbTypes,
difficultySystems: difficultySystems,
customDifficultyGrades: customDifficultyGrades,
notes: notes,
createdAt: createdAt,
updatedAt: updatedAt
)
}
}
struct Problem: Identifiable, Codable, Hashable {
let id: UUID
let gymId: UUID
let name: String?
let description: String?
let climbType: ClimbType
let difficulty: DifficultyGrade
let setter: String?
let tags: [String]
let location: String?
let imagePaths: [String]
let isActive: Bool
let dateSet: Date?
let notes: String?
let createdAt: Date
let updatedAt: Date
init(
gymId: UUID, name: String? = nil, description: String? = nil, climbType: ClimbType,
difficulty: DifficultyGrade, setter: String? = nil, tags: [String] = [],
location: String? = nil, imagePaths: [String] = [], dateSet: Date? = nil,
notes: String? = nil
) {
self.id = UUID()
self.gymId = gymId
self.name = name
self.description = description
self.climbType = climbType
self.difficulty = difficulty
self.setter = setter
self.tags = tags
self.location = location
self.imagePaths = imagePaths
self.isActive = true
self.dateSet = dateSet
self.notes = notes
let now = Date()
self.createdAt = now
self.updatedAt = now
}
func updated(
name: String? = nil, description: String? = nil, climbType: ClimbType? = nil,
difficulty: DifficultyGrade? = nil, setter: String? = nil, tags: [String]? = nil,
location: String? = nil, imagePaths: [String]? = nil, isActive: Bool? = nil,
dateSet: Date? = nil, notes: String? = nil
) -> Problem {
return Problem(
id: self.id,
gymId: self.gymId,
name: name ?? self.name,
description: description ?? self.description,
climbType: climbType ?? self.climbType,
difficulty: difficulty ?? self.difficulty,
setter: setter ?? self.setter,
tags: tags ?? self.tags,
location: location ?? self.location,
imagePaths: imagePaths ?? self.imagePaths,
isActive: isActive ?? self.isActive,
dateSet: dateSet ?? self.dateSet,
notes: notes ?? self.notes,
createdAt: self.createdAt,
updatedAt: Date()
)
}
private init(
id: UUID, gymId: UUID, name: String?, description: String?, climbType: ClimbType,
difficulty: DifficultyGrade, setter: String?, tags: [String], location: String?,
imagePaths: [String], isActive: Bool, dateSet: Date?, notes: String?, createdAt: Date,
updatedAt: Date
) {
self.id = id
self.gymId = gymId
self.name = name
self.description = description
self.climbType = climbType
self.difficulty = difficulty
self.setter = setter
self.tags = tags
self.location = location
self.imagePaths = imagePaths
self.isActive = isActive
self.dateSet = dateSet
self.notes = notes
self.createdAt = createdAt
self.updatedAt = updatedAt
}
static func fromImport(
id: UUID, gymId: UUID, name: String?, description: String?, climbType: ClimbType,
difficulty: DifficultyGrade, setter: String?, tags: [String], location: String?,
imagePaths: [String], isActive: Bool, dateSet: Date?, notes: String?, createdAt: Date,
updatedAt: Date
) -> Problem {
return Problem(
id: id,
gymId: gymId,
name: name,
description: description,
climbType: climbType,
difficulty: difficulty,
setter: setter,
tags: tags,
location: location,
imagePaths: imagePaths,
isActive: isActive,
dateSet: dateSet,
notes: notes,
createdAt: createdAt,
updatedAt: updatedAt
)
}
}
struct ClimbSession: Identifiable, Codable, Hashable {
let id: UUID
let gymId: UUID
let date: Date
let startTime: Date?
let endTime: Date?
let duration: Int? // Duration in minutes
let status: SessionStatus
let notes: String?
let createdAt: Date
let updatedAt: Date
init(gymId: UUID, notes: String? = nil) {
self.id = UUID()
self.gymId = gymId
let now = Date()
self.date = now
self.startTime = now
self.endTime = nil
self.duration = nil
self.status = .active
self.notes = notes
self.createdAt = now
self.updatedAt = now
}
func completed() -> ClimbSession {
let endTime = Date()
let durationMinutes =
startTime != nil ? Int(endTime.timeIntervalSince(startTime!) / 60) : nil
return ClimbSession(
id: self.id,
gymId: self.gymId,
date: self.date,
startTime: self.startTime,
endTime: endTime,
duration: durationMinutes,
status: .completed,
notes: self.notes,
createdAt: self.createdAt,
updatedAt: Date()
)
}
func updated(notes: String? = nil, status: SessionStatus? = nil) -> ClimbSession {
return ClimbSession(
id: self.id,
gymId: self.gymId,
date: self.date,
startTime: self.startTime,
endTime: self.endTime,
duration: self.duration,
status: status ?? self.status,
notes: notes ?? self.notes,
createdAt: self.createdAt,
updatedAt: Date()
)
}
private init(
id: UUID, gymId: UUID, date: Date, startTime: Date?, endTime: Date?, duration: Int?,
status: SessionStatus, notes: String?, createdAt: Date, updatedAt: Date
) {
self.id = id
self.gymId = gymId
self.date = date
self.startTime = startTime
self.endTime = endTime
self.duration = duration
self.status = status
self.notes = notes
self.createdAt = createdAt
self.updatedAt = updatedAt
}
static func fromImport(
id: UUID, gymId: UUID, date: Date, startTime: Date?, endTime: Date?, duration: Int?,
status: SessionStatus, notes: String?, createdAt: Date, updatedAt: Date
) -> ClimbSession {
return ClimbSession(
id: id,
gymId: gymId,
date: date,
startTime: startTime,
endTime: endTime,
duration: duration,
status: status,
notes: notes,
createdAt: createdAt,
updatedAt: updatedAt
)
}
}
struct Attempt: Identifiable, Codable, Hashable {
let id: UUID
let sessionId: UUID
let problemId: UUID
let result: AttemptResult
let highestHold: String?
let notes: String?
let duration: Int?
let restTime: Int?
let timestamp: Date
let createdAt: Date
init(
sessionId: UUID, problemId: UUID, result: AttemptResult, highestHold: String? = nil,
notes: String? = nil, duration: Int? = nil, restTime: Int? = nil, timestamp: Date = Date()
) {
self.id = UUID()
self.sessionId = sessionId
self.problemId = problemId
self.result = result
self.highestHold = highestHold
self.notes = notes
self.duration = duration
self.restTime = restTime
self.timestamp = timestamp
self.createdAt = Date()
}
func updated(
result: AttemptResult? = nil, highestHold: String? = nil, notes: String? = nil,
duration: Int? = nil, restTime: Int? = nil
) -> Attempt {
return Attempt(
id: self.id,
sessionId: self.sessionId,
problemId: self.problemId,
result: result ?? self.result,
highestHold: highestHold ?? self.highestHold,
notes: notes ?? self.notes,
duration: duration ?? self.duration,
restTime: restTime ?? self.restTime,
timestamp: self.timestamp,
createdAt: self.createdAt
)
}
private init(
id: UUID, sessionId: UUID, problemId: UUID, result: AttemptResult, highestHold: String?,
notes: String?, duration: Int?, restTime: Int?, timestamp: Date, createdAt: Date
) {
self.id = id
self.sessionId = sessionId
self.problemId = problemId
self.result = result
self.highestHold = highestHold
self.notes = notes
self.duration = duration
self.restTime = restTime
self.timestamp = timestamp
self.createdAt = createdAt
}
static func fromImport(
id: UUID, sessionId: UUID, problemId: UUID, result: AttemptResult, highestHold: String?,
notes: String?, duration: Int?, restTime: Int?, timestamp: Date, createdAt: Date
) -> Attempt {
return Attempt(
id: id,
sessionId: sessionId,
problemId: problemId,
result: result,
highestHold: highestHold,
notes: notes,
duration: duration,
restTime: restTime,
timestamp: timestamp,
createdAt: createdAt
)
}
}
extension DifficultyGrade: Comparable {
static func < (lhs: DifficultyGrade, rhs: DifficultyGrade) -> Bool {
if lhs.system != rhs.system {
return false // Can't compare different systems
}
return lhs.numericValue < rhs.numericValue
}
}

View File

@@ -0,0 +1,17 @@
//
// OpenClimbApp.swift
// OpenClimb
//
// Created by OpenClimb on 2025-01-17.
//
import SwiftUI
@main
struct OpenClimbApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}

View File

@@ -0,0 +1,654 @@
//
// ZipUtils.swift
// OpenClimb
//
// Created by OpenClimb on 2025-01-17.
//
import Compression
import Foundation
import zlib
struct ZipUtils {
private static let DATA_JSON_FILENAME = "data.json"
private static let IMAGES_DIR_NAME = "images"
private static let METADATA_FILENAME = "metadata.txt"
static func createExportZip(
exportData: ClimbDataExport,
referencedImagePaths: Set<String>
) throws -> Data {
var zipData = Data()
var centralDirectory = Data()
var fileEntries: [(name: String, data: Data, offset: UInt32)] = []
var currentOffset: UInt32 = 0
let metadata = createMetadata(
exportData: exportData, referencedImagePaths: referencedImagePaths)
let metadataData = metadata.data(using: .utf8) ?? Data()
try addFileToZip(
filename: METADATA_FILENAME,
fileData: metadataData,
zipData: &zipData,
fileEntries: &fileEntries,
currentOffset: &currentOffset
)
let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted
encoder.dateEncodingStrategy = .custom { date, encoder in
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS"
var container = encoder.singleValueContainer()
try container.encode(formatter.string(from: date))
}
let jsonData = try encoder.encode(exportData)
try addFileToZip(
filename: DATA_JSON_FILENAME,
fileData: jsonData,
zipData: &zipData,
fileEntries: &fileEntries,
currentOffset: &currentOffset
)
print("Processing \(referencedImagePaths.count) referenced image paths")
var successfulImages = 0
for imagePath in referencedImagePaths {
print("Processing image path: \(imagePath)")
let imageURL = URL(fileURLWithPath: imagePath)
let imageName = imageURL.lastPathComponent
print("Image name: \(imageName)")
if FileManager.default.fileExists(atPath: imagePath) {
print("Image file exists at: \(imagePath)")
do {
let imageData = try Data(contentsOf: imageURL)
print("Image data size: \(imageData.count) bytes")
if imageData.count > 0 {
let imageEntryName = "\(IMAGES_DIR_NAME)/\(imageName)"
try addFileToZip(
filename: imageEntryName,
fileData: imageData,
zipData: &zipData,
fileEntries: &fileEntries,
currentOffset: &currentOffset
)
successfulImages += 1
print("Successfully added image to ZIP: \(imageEntryName)")
} else {
print("Image data is empty for: \(imagePath)")
}
} catch {
print("Failed to read image data for \(imagePath): \(error)")
}
} else {
print("Image file does not exist at: \(imagePath)")
}
}
print("Export completed: \(successfulImages)/\(referencedImagePaths.count) images included")
for entry in fileEntries {
let centralDirEntry = createCentralDirectoryEntry(
filename: entry.name,
fileData: entry.data,
localHeaderOffset: entry.offset
)
centralDirectory.append(centralDirEntry)
}
let centralDirOffset = UInt32(zipData.count)
zipData.append(centralDirectory)
let endOfCentralDir = createEndOfCentralDirectory(
numEntries: UInt16(fileEntries.count),
centralDirSize: UInt32(centralDirectory.count),
centralDirOffset: centralDirOffset
)
zipData.append(endOfCentralDir)
return zipData
}
static func extractImportZip(data: Data) throws -> ImportResult {
print("Starting ZIP extraction - data size: \(data.count) bytes")
return try extractUsingCustomParser(data: data)
}
private static func extractUsingCustomParser(data: Data) throws -> ImportResult {
var jsonContent = ""
var metadataContent = ""
var importedImagePaths: [String: String] = [:]
let zipEntries: [ZipEntry]
do {
zipEntries = try parseZipFile(data: data)
print("Successfully parsed ZIP file with \(zipEntries.count) entries")
} catch {
print("Failed to parse ZIP file: \(error)")
print(
"ZIP data header: \(data.prefix(20).map { String(format: "%02X", $0) }.joined(separator: " "))"
)
throw NSError(
domain: "ImportError", code: 1,
userInfo: [
NSLocalizedDescriptionKey:
"Failed to parse ZIP file: \(error.localizedDescription). This may be due to incompatibility with the ZIP format."
]
)
}
print("Found \(zipEntries.count) entries in ZIP file:")
for entry in zipEntries {
print(" - \(entry.filename) (size: \(entry.data.count) bytes)")
}
for entry in zipEntries {
switch entry.filename {
case METADATA_FILENAME:
metadataContent = String(data: entry.data, encoding: .utf8) ?? ""
print("Found metadata: \(metadataContent.prefix(100))...")
case DATA_JSON_FILENAME:
jsonContent = String(data: entry.data, encoding: .utf8) ?? ""
print("Found data.json with \(jsonContent.count) characters")
if jsonContent.isEmpty {
print("WARNING: data.json is empty!")
} else {
print("data.json preview: \(jsonContent.prefix(200))...")
}
default:
if entry.filename.hasPrefix("\(IMAGES_DIR_NAME)/") && !entry.filename.hasSuffix("/")
{
let originalFilename = String(
entry.filename.dropFirst("\(IMAGES_DIR_NAME)/".count))
do {
let documentsURL = FileManager.default.urls(
for: .documentDirectory, in: .userDomainMask
).first!
let imagesDir = documentsURL.appendingPathComponent("images")
try FileManager.default.createDirectory(
at: imagesDir, withIntermediateDirectories: true)
let newImageURL = imagesDir.appendingPathComponent(originalFilename)
try entry.data.write(to: newImageURL)
importedImagePaths[originalFilename] = newImageURL.path
print(
"Successfully imported image: \(originalFilename) -> \(newImageURL.path)"
)
} catch {
print("Failed to import image \(originalFilename): \(error)")
}
}
}
}
guard !jsonContent.isEmpty else {
print("ERROR: data.json not found or empty")
print("Available files in ZIP:")
for entry in zipEntries {
print(" - \(entry.filename)")
}
throw NSError(
domain: "ImportError", code: 1,
userInfo: [
NSLocalizedDescriptionKey:
"Invalid ZIP file: data.json not found or empty. Found files: \(zipEntries.map { $0.filename }.joined(separator: ", "))"
]
)
}
print("Import extraction completed: \(importedImagePaths.count) images processed")
return ImportResult(
jsonData: jsonContent.data(using: .utf8) ?? Data(), imagePathMapping: importedImagePaths
)
}
private static func createMetadata(
exportData: ClimbDataExport,
referencedImagePaths: Set<String>
) -> String {
return """
OpenClimb Export Metadata
=======================
Export Date: \(exportData.exportedAt)
Gyms: \(exportData.gyms.count)
Problems: \(exportData.problems.count)
Sessions: \(exportData.sessions.count)
Attempts: \(exportData.attempts.count)
Referenced Images: \(referencedImagePaths.count)
Format: ZIP with embedded JSON data and images
"""
}
private static func addFileToZip(
filename: String,
fileData: Data,
zipData: inout Data,
fileEntries: inout [(name: String, data: Data, offset: UInt32)],
currentOffset: inout UInt32
) throws {
let localHeader = createLocalFileHeader(filename: filename, fileData: fileData)
let headerOffset = currentOffset
zipData.append(localHeader)
zipData.append(fileData)
fileEntries.append((name: filename, data: fileData, offset: headerOffset))
currentOffset += UInt32(localHeader.count + fileData.count)
}
private static func createLocalFileHeader(filename: String, fileData: Data) -> Data {
var header = Data()
header.append(Data([0x50, 0x4b, 0x03, 0x04]))
header.append(Data([0x14, 0x00]))
header.append(Data([0x00, 0x00]))
header.append(Data([0x00, 0x00]))
// Last mod file time & date (use current time)
let dosTime = getDosDateTime()
header.append(dosTime)
let crc = calculateCRC32(data: fileData)
header.append(withUnsafeBytes(of: crc.littleEndian) { Data($0) })
// Compressed size (same as uncompressed since no compression)
let compressedSize = UInt32(fileData.count)
header.append(withUnsafeBytes(of: compressedSize.littleEndian) { Data($0) })
let uncompressedSize = UInt32(fileData.count)
header.append(withUnsafeBytes(of: uncompressedSize.littleEndian) { Data($0) })
let filenameData = filename.data(using: .utf8) ?? Data()
let filenameLength = UInt16(filenameData.count)
header.append(withUnsafeBytes(of: filenameLength.littleEndian) { Data($0) })
header.append(Data([0x00, 0x00]))
header.append(filenameData)
return header
}
private static func createCentralDirectoryEntry(
filename: String,
fileData: Data,
localHeaderOffset: UInt32
) -> Data {
var entry = Data()
entry.append(Data([0x50, 0x4b, 0x01, 0x02]))
entry.append(Data([0x14, 0x00]))
entry.append(Data([0x14, 0x00]))
entry.append(Data([0x00, 0x00]))
entry.append(Data([0x00, 0x00]))
// Last mod file time & date
let dosTime = getDosDateTime()
entry.append(dosTime)
let crc = calculateCRC32(data: fileData)
entry.append(withUnsafeBytes(of: crc.littleEndian) { Data($0) })
let compressedSize = UInt32(fileData.count)
entry.append(withUnsafeBytes(of: compressedSize.littleEndian) { Data($0) })
let uncompressedSize = UInt32(fileData.count)
entry.append(withUnsafeBytes(of: uncompressedSize.littleEndian) { Data($0) })
let filenameData = filename.data(using: .utf8) ?? Data()
let filenameLength = UInt16(filenameData.count)
entry.append(withUnsafeBytes(of: filenameLength.littleEndian) { Data($0) })
entry.append(Data([0x00, 0x00]))
// File comment length
entry.append(Data([0x00, 0x00]))
entry.append(Data([0x00, 0x00]))
entry.append(Data([0x00, 0x00]))
entry.append(Data([0x00, 0x00, 0x00, 0x00]))
// Relative offset of local header
entry.append(withUnsafeBytes(of: localHeaderOffset.littleEndian) { Data($0) })
entry.append(filenameData)
return entry
}
private static func createEndOfCentralDirectory(
numEntries: UInt16,
centralDirSize: UInt32,
centralDirOffset: UInt32
) -> Data {
var endRecord = Data()
endRecord.append(Data([0x50, 0x4b, 0x05, 0x06]))
endRecord.append(Data([0x00, 0x00]))
// Number of the disk with the start of the central directory
endRecord.append(Data([0x00, 0x00]))
// Total number of entries in the central directory on this disk
endRecord.append(withUnsafeBytes(of: numEntries.littleEndian) { Data($0) })
// Total number of entries in the central directory
endRecord.append(withUnsafeBytes(of: numEntries.littleEndian) { Data($0) })
endRecord.append(withUnsafeBytes(of: centralDirSize.littleEndian) { Data($0) })
// Offset of start of central directory
endRecord.append(withUnsafeBytes(of: centralDirOffset.littleEndian) { Data($0) })
// ZIP file comment length
endRecord.append(Data([0x00, 0x00]))
return endRecord
}
private static func getDosDateTime() -> Data {
let date = Date()
let calendar = Calendar.current
let components = calendar.dateComponents(
[.year, .month, .day, .hour, .minute, .second], from: date)
let year = UInt16(max(1980, components.year ?? 1980) - 1980)
let month = UInt16(components.month ?? 1)
let day = UInt16(components.day ?? 1)
let hour = UInt16(components.hour ?? 0)
let minute = UInt16(components.minute ?? 0)
let second = UInt16((components.second ?? 0) / 2)
let dosDate = (year << 9) | (month << 5) | day
let dosTime = (hour << 11) | (minute << 5) | second
var data = Data()
data.append(withUnsafeBytes(of: dosTime.littleEndian) { Data($0) })
data.append(withUnsafeBytes(of: dosDate.littleEndian) { Data($0) })
return data
}
private static func calculateCRC32(data: Data) -> UInt32 {
let polynomial: UInt32 = 0xEDB8_8320
var crc: UInt32 = 0xFFFF_FFFF
for byte in data {
crc ^= UInt32(byte)
for _ in 0..<8 {
if crc & 1 != 0 {
crc = (crc >> 1) ^ polynomial
} else {
crc >>= 1
}
}
}
return ~crc
}
private static func parseZipFile(data: Data) throws -> [ZipEntry] {
var endOfCentralDirOffset = -1
let signature = Data([0x50, 0x4b, 0x05, 0x06])
for i in stride(from: data.count - 22, through: 0, by: -1) {
if data.subdata(in: i..<i + 4) == signature {
endOfCentralDirOffset = i
break
}
}
guard endOfCentralDirOffset >= 0 else {
throw NSError(
domain: "ZipError", code: 1,
userInfo: [NSLocalizedDescriptionKey: "End of central directory not found"])
}
let endRecord = data.subdata(in: endOfCentralDirOffset..<endOfCentralDirOffset + 22)
let numEntries = endRecord.subdata(in: 8..<10).withUnsafeBytes { $0.load(as: UInt16.self) }
let centralDirSize = endRecord.subdata(in: 12..<16).withUnsafeBytes {
$0.load(as: UInt32.self)
}
let centralDirOffset = endRecord.subdata(in: 16..<20).withUnsafeBytes {
$0.load(as: UInt32.self)
}
var entries: [ZipEntry] = []
var offset = Int(centralDirOffset)
for _ in 0..<numEntries {
let entry = try parseCentralDirectoryEntry(data: data, offset: &offset)
entries.append(entry)
}
var zipEntries: [ZipEntry] = []
for entry in entries {
let fileData = try extractFileData(data: data, entry: entry)
let zipEntry = ZipEntry(filename: entry.filename, data: fileData)
zipEntries.append(zipEntry)
}
return zipEntries
}
private static func parseCentralDirectoryEntry(
data: Data, offset: inout Int
) throws -> ZipEntry {
guard offset + 46 <= data.count else {
throw NSError(
domain: "ZipError", code: 1,
userInfo: [NSLocalizedDescriptionKey: "Invalid central directory entry"])
}
let entryData = data.subdata(in: offset..<offset + 46)
let signature = entryData.subdata(in: 0..<4)
guard signature == Data([0x50, 0x4b, 0x01, 0x02]) else {
throw NSError(
domain: "ZipError", code: 1,
userInfo: [NSLocalizedDescriptionKey: "Invalid central directory signature"])
}
let compressionMethod = entryData.subdata(in: 10..<12).withUnsafeBytes {
$0.load(as: UInt16.self)
}
let compressedSize = entryData.subdata(in: 20..<24).withUnsafeBytes {
$0.load(as: UInt32.self)
}
let uncompressedSize = entryData.subdata(in: 24..<28).withUnsafeBytes {
$0.load(as: UInt32.self)
}
let filenameLength = entryData.subdata(in: 28..<30).withUnsafeBytes {
$0.load(as: UInt16.self)
}
let extraFieldLength = entryData.subdata(in: 30..<32).withUnsafeBytes {
$0.load(as: UInt16.self)
}
let commentLength = entryData.subdata(in: 32..<34).withUnsafeBytes {
$0.load(as: UInt16.self)
}
let localHeaderOffset = entryData.subdata(in: 42..<46).withUnsafeBytes {
$0.load(as: UInt32.self)
}
offset += 46
let filenameData = data.subdata(in: offset..<offset + Int(filenameLength))
let filename = String(data: filenameData, encoding: .utf8) ?? ""
offset += Int(filenameLength)
offset += Int(extraFieldLength) + Int(commentLength)
return ZipEntry(
filename: filename,
localHeaderOffset: localHeaderOffset,
compressedSize: compressedSize,
uncompressedSize: uncompressedSize,
compressionMethod: compressionMethod
)
}
private static func extractFileData(
data: Data, entry: ZipEntry
) throws -> Data {
let headerOffset = Int(entry.localHeaderOffset)
guard headerOffset + 30 <= data.count else {
throw NSError(
domain: "ZipError", code: 1,
userInfo: [NSLocalizedDescriptionKey: "Invalid local header offset"])
}
let headerData = data.subdata(in: headerOffset..<headerOffset + 30)
let signature = headerData.subdata(in: 0..<4)
guard signature == Data([0x50, 0x4b, 0x03, 0x04]) else {
throw NSError(
domain: "ZipError", code: 1,
userInfo: [NSLocalizedDescriptionKey: "Invalid local header signature"])
}
let localCompressionMethod = headerData.subdata(in: 8..<10).withUnsafeBytes {
$0.load(as: UInt16.self)
}
let filenameLength = headerData.subdata(in: 26..<28).withUnsafeBytes {
$0.load(as: UInt16.self)
}
let extraFieldLength = headerData.subdata(in: 28..<30).withUnsafeBytes {
$0.load(as: UInt16.self)
}
let dataOffset = headerOffset + 30 + Int(filenameLength) + Int(extraFieldLength)
let dataEndOffset = dataOffset + Int(entry.compressedSize)
guard dataEndOffset <= data.count else {
throw NSError(
domain: "ZipError", code: 1,
userInfo: [NSLocalizedDescriptionKey: "File data extends beyond ZIP file"])
}
let compressedData = data.subdata(in: dataOffset..<dataEndOffset)
switch localCompressionMethod {
case 0:
return compressedData
case 8:
return try decompressDeflate(compressedData)
default:
throw NSError(
domain: "ZipError", code: 1,
userInfo: [
NSLocalizedDescriptionKey:
"Unsupported compression method: \(localCompressionMethod)"
])
}
}
private static func decompressDeflate(_ data: Data) throws -> Data {
let buffer = UnsafeMutablePointer<UInt8>.allocate(capacity: 1024 * 1024)
defer { buffer.deallocate() }
var decompressedData = Data()
try data.withUnsafeBytes { bytes in
var stream = z_stream()
stream.next_in = UnsafeMutablePointer<UInt8>(
mutating: bytes.bindMemory(to: UInt8.self).baseAddress)
stream.avail_in = UInt32(data.count)
let initResult = inflateInit2_(
&stream, -15, ZLIB_VERSION, Int32(MemoryLayout<z_stream>.size))
guard initResult == Z_OK else {
throw NSError(
domain: "ZipError", code: 1,
userInfo: [
NSLocalizedDescriptionKey: "Failed to initialize deflate decompression"
])
}
defer { inflateEnd(&stream) }
var result: Int32
repeat {
stream.next_out = buffer
stream.avail_out = 1024 * 1024
result = inflate(&stream, Z_NO_FLUSH)
if result != Z_OK && result != Z_STREAM_END {
throw NSError(
domain: "ZipError", code: 1,
userInfo: [
NSLocalizedDescriptionKey: "Decompression failed with code: \(result)"
])
}
let bytesDecompressed = 1024 * 1024 - Int(stream.avail_out)
if bytesDecompressed > 0 {
decompressedData.append(buffer, count: bytesDecompressed)
}
} while result != Z_STREAM_END
}
return decompressedData
}
}
struct ZipEntry {
let filename: String
let data: Data
let localHeaderOffset: UInt32
let compressedSize: UInt32
let uncompressedSize: UInt32
let compressionMethod: UInt16
init(filename: String, data: Data) {
self.filename = filename
self.data = data
self.localHeaderOffset = 0
self.compressedSize = 0
self.uncompressedSize = 0
self.compressionMethod = 0
}
init(
filename: String, localHeaderOffset: UInt32, compressedSize: UInt32,
uncompressedSize: UInt32 = 0, compressionMethod: UInt16 = 0
) {
self.filename = filename
self.data = Data()
self.localHeaderOffset = localHeaderOffset
self.compressedSize = compressedSize
self.uncompressedSize = uncompressedSize
self.compressionMethod = compressionMethod
}
}
struct ImportResult {
let jsonData: Data
let imagePathMapping: [String: String]
}

View File

@@ -0,0 +1,834 @@
//
// ClimbingDataManager.swift
// OpenClimb
//
// Created by OpenClimb on 2025-01-17.
//
import Combine
import Foundation
import SwiftUI
import UniformTypeIdentifiers
@MainActor
class ClimbingDataManager: ObservableObject {
@Published var gyms: [Gym] = []
@Published var problems: [Problem] = []
@Published var sessions: [ClimbSession] = []
@Published var attempts: [Attempt] = []
@Published var activeSession: ClimbSession?
@Published var isLoading = false
@Published var errorMessage: String?
@Published var successMessage: String?
private let userDefaults = UserDefaults.standard
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
private enum Keys {
static let gyms = "openclimb_gyms"
static let problems = "openclimb_problems"
static let sessions = "openclimb_sessions"
static let attempts = "openclimb_attempts"
static let activeSession = "openclimb_active_session"
}
init() {
loadAllData()
}
private func loadAllData() {
loadGyms()
loadProblems()
loadSessions()
loadAttempts()
loadActiveSession()
}
private func loadGyms() {
if let data = userDefaults.data(forKey: Keys.gyms),
let loadedGyms = try? decoder.decode([Gym].self, from: data)
{
self.gyms = loadedGyms
}
}
private func loadProblems() {
if let data = userDefaults.data(forKey: Keys.problems),
let loadedProblems = try? decoder.decode([Problem].self, from: data)
{
self.problems = loadedProblems
}
}
private func loadSessions() {
if let data = userDefaults.data(forKey: Keys.sessions),
let loadedSessions = try? decoder.decode([ClimbSession].self, from: data)
{
self.sessions = loadedSessions
}
}
private func loadAttempts() {
if let data = userDefaults.data(forKey: Keys.attempts),
let loadedAttempts = try? decoder.decode([Attempt].self, from: data)
{
self.attempts = loadedAttempts
}
}
private func loadActiveSession() {
if let data = userDefaults.data(forKey: Keys.activeSession),
let loadedActiveSession = try? decoder.decode(ClimbSession.self, from: data)
{
self.activeSession = loadedActiveSession
}
}
private func saveGyms() {
if let data = try? encoder.encode(gyms) {
userDefaults.set(data, forKey: Keys.gyms)
}
}
private func saveProblems() {
if let data = try? encoder.encode(problems) {
userDefaults.set(data, forKey: Keys.problems)
}
}
private func saveSessions() {
if let data = try? encoder.encode(sessions) {
userDefaults.set(data, forKey: Keys.sessions)
}
}
private func saveAttempts() {
if let data = try? encoder.encode(attempts) {
userDefaults.set(data, forKey: Keys.attempts)
}
}
private func saveActiveSession() {
if let activeSession = activeSession,
let data = try? encoder.encode(activeSession)
{
userDefaults.set(data, forKey: Keys.activeSession)
} else {
userDefaults.removeObject(forKey: Keys.activeSession)
}
}
func addGym(_ gym: Gym) {
gyms.append(gym)
saveGyms()
successMessage = "Gym added successfully"
clearMessageAfterDelay()
}
func updateGym(_ gym: Gym) {
if let index = gyms.firstIndex(where: { $0.id == gym.id }) {
gyms[index] = gym
saveGyms()
successMessage = "Gym updated successfully"
clearMessageAfterDelay()
}
}
func deleteGym(_ gym: Gym) {
// Delete associated problems and their attempts first
let problemsToDelete = problems.filter { $0.gymId == gym.id }
for problem in problemsToDelete {
deleteProblem(problem)
}
// Delete associated sessions and their attempts
let sessionsToDelete = sessions.filter { $0.gymId == gym.id }
for session in sessionsToDelete {
deleteSession(session)
}
// Delete the gym
gyms.removeAll { $0.id == gym.id }
saveGyms()
successMessage = "Gym deleted successfully"
clearMessageAfterDelay()
}
func gym(withId id: UUID) -> Gym? {
return gyms.first { $0.id == id }
}
func addProblem(_ problem: Problem) {
problems.append(problem)
saveProblems()
successMessage = "Problem added successfully"
clearMessageAfterDelay()
}
func updateProblem(_ problem: Problem) {
if let index = problems.firstIndex(where: { $0.id == problem.id }) {
problems[index] = problem
saveProblems()
successMessage = "Problem updated successfully"
clearMessageAfterDelay()
}
}
func deleteProblem(_ problem: Problem) {
// Delete associated attempts first
attempts.removeAll { $0.problemId == problem.id }
saveAttempts()
// Delete the problem
problems.removeAll { $0.id == problem.id }
saveProblems()
successMessage = "Problem deleted successfully"
clearMessageAfterDelay()
}
func problem(withId id: UUID) -> Problem? {
return problems.first { $0.id == id }
}
func problems(forGym gymId: UUID) -> [Problem] {
return problems.filter { $0.gymId == gymId }
}
func activeProblems(forGym gymId: UUID) -> [Problem] {
return problems.filter { $0.gymId == gymId && $0.isActive }
}
func startSession(gymId: UUID, notes: String? = nil) {
if let currentActive = activeSession {
endSession(currentActive.id)
}
let newSession = ClimbSession(gymId: gymId, notes: notes)
activeSession = newSession
sessions.append(newSession)
saveActiveSession()
saveSessions()
successMessage = "Session started successfully"
clearMessageAfterDelay()
}
func endSession(_ sessionId: UUID) {
if let session = sessions.first(where: { $0.id == sessionId && $0.status == .active }),
let index = sessions.firstIndex(where: { $0.id == sessionId })
{
let completedSession = session.completed()
sessions[index] = completedSession
if activeSession?.id == sessionId {
activeSession = nil
}
saveActiveSession()
saveSessions()
successMessage = "Session completed successfully"
clearMessageAfterDelay()
}
}
func updateSession(_ session: ClimbSession) {
if let index = sessions.firstIndex(where: { $0.id == session.id }) {
sessions[index] = session
if activeSession?.id == session.id {
activeSession = session
saveActiveSession()
}
saveSessions()
successMessage = "Session updated successfully"
clearMessageAfterDelay()
}
}
func deleteSession(_ session: ClimbSession) {
// Delete associated attempts first
attempts.removeAll { $0.sessionId == session.id }
saveAttempts()
// Remove from active session if it's the current one
if activeSession?.id == session.id {
activeSession = nil
saveActiveSession()
}
// Delete the session
sessions.removeAll { $0.id == session.id }
saveSessions()
successMessage = "Session deleted successfully"
clearMessageAfterDelay()
}
func session(withId id: UUID) -> ClimbSession? {
return sessions.first { $0.id == id }
}
func sessions(forGym gymId: UUID) -> [ClimbSession] {
return sessions.filter { $0.gymId == gymId }
}
func getLastUsedGym() -> Gym? {
let recentSessions = sessions.sorted { $0.date > $1.date }
guard let lastSession = recentSessions.first else { return nil }
return gym(withId: lastSession.gymId)
}
func addAttempt(_ attempt: Attempt) {
attempts.append(attempt)
saveAttempts()
successMessage = "Attempt logged successfully"
clearMessageAfterDelay()
}
func updateAttempt(_ attempt: Attempt) {
if let index = attempts.firstIndex(where: { $0.id == attempt.id }) {
attempts[index] = attempt
saveAttempts()
successMessage = "Attempt updated successfully"
clearMessageAfterDelay()
}
}
func deleteAttempt(_ attempt: Attempt) {
attempts.removeAll { $0.id == attempt.id }
saveAttempts()
successMessage = "Attempt deleted successfully"
clearMessageAfterDelay()
}
func attempts(forSession sessionId: UUID) -> [Attempt] {
return attempts.filter { $0.sessionId == sessionId }.sorted { $0.timestamp < $1.timestamp }
}
func attempts(forProblem problemId: UUID) -> [Attempt] {
return attempts.filter { $0.problemId == problemId }.sorted { $0.timestamp > $1.timestamp }
}
func successfulAttempts(forProblem problemId: UUID) -> [Attempt] {
return attempts.filter { $0.problemId == problemId && $0.result.isSuccessful }
}
func completedSessions() -> [ClimbSession] {
return sessions.filter { $0.status == .completed }
}
func totalAttempts() -> Int {
return attempts.count
}
func successfulAttempts() -> Int {
return attempts.filter { $0.result.isSuccessful }.count
}
func completedProblems() -> Int {
let completedProblemIds = Set(
attempts.filter { $0.result.isSuccessful }.map { $0.problemId })
return completedProblemIds.count
}
func favoriteGym() -> Gym? {
let gymSessionCounts = Dictionary(grouping: sessions, by: { $0.gymId })
.mapValues { $0.count }
guard let mostUsedGymId = gymSessionCounts.max(by: { $0.value < $1.value })?.key else {
return nil
}
return gym(withId: mostUsedGymId)
}
func resetAllData() {
gyms.removeAll()
problems.removeAll()
sessions.removeAll()
attempts.removeAll()
activeSession = nil
userDefaults.removeObject(forKey: Keys.gyms)
userDefaults.removeObject(forKey: Keys.problems)
userDefaults.removeObject(forKey: Keys.sessions)
userDefaults.removeObject(forKey: Keys.attempts)
userDefaults.removeObject(forKey: Keys.activeSession)
successMessage = "All data has been reset"
clearMessageAfterDelay()
}
func exportData() -> Data? {
do {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS"
let exportData = ClimbDataExport(
exportedAt: dateFormatter.string(from: Date()),
gyms: gyms.map { AndroidGym(from: $0) },
problems: problems.map { AndroidProblem(from: $0) },
sessions: sessions.map { AndroidClimbSession(from: $0) },
attempts: attempts.map { AndroidAttempt(from: $0) }
)
// Collect referenced image paths
let referencedImagePaths = collectReferencedImagePaths()
return try ZipUtils.createExportZip(
exportData: exportData,
referencedImagePaths: referencedImagePaths
)
} catch {
setError("Export failed: \(error.localizedDescription)")
return nil
}
}
func importData(from data: Data) throws {
do {
let importResult = try ZipUtils.extractImportZip(data: data)
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS"
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .custom { decoder in
let container = try decoder.singleValueContainer()
let dateString = try container.decode(String.self)
if let date = ISO8601DateFormatter().date(from: dateString) {
return date
}
if let date = dateFormatter.date(from: dateString) {
return date
}
return Date()
}
print("Raw JSON content preview:")
print(String(decoding: importResult.jsonData.prefix(500), as: UTF8.self) + "...")
let importData = try decoder.decode(ClimbDataExport.self, from: importResult.jsonData)
print("Successfully decoded import data:")
print("- Gyms: \(importData.gyms.count)")
print("- Problems: \(importData.problems.count)")
print("- Sessions: \(importData.sessions.count)")
print("- Attempts: \(importData.attempts.count)")
try validateImportData(importData)
resetAllData()
let updatedProblems = updateProblemImagePaths(
problems: importData.problems,
imagePathMapping: importResult.imagePathMapping
)
self.gyms = importData.gyms.map { $0.toGym() }
self.problems = updatedProblems.map { $0.toProblem() }
self.sessions = importData.sessions.map { $0.toClimbSession() }
self.attempts = importData.attempts.map { $0.toAttempt() }
saveGyms()
saveProblems()
saveSessions()
saveAttempts()
successMessage =
"Data imported successfully with \(importResult.imagePathMapping.count) images"
clearMessageAfterDelay()
} catch {
setError("Import failed: \(error.localizedDescription)")
throw error
}
}
func clearMessages() {
errorMessage = nil
successMessage = nil
}
private func clearMessageAfterDelay() {
Task {
try? await Task.sleep(nanoseconds: 3_000_000_000) // 3 seconds
successMessage = nil
errorMessage = nil
}
}
func setError(_ message: String) {
errorMessage = message
clearMessageAfterDelay()
}
}
struct ClimbDataExport: Codable {
let exportedAt: String
let gyms: [AndroidGym]
let problems: [AndroidProblem]
let sessions: [AndroidClimbSession]
let attempts: [AndroidAttempt]
init(
exportedAt: String, gyms: [AndroidGym], problems: [AndroidProblem],
sessions: [AndroidClimbSession], attempts: [AndroidAttempt]
) {
self.exportedAt = exportedAt
self.gyms = gyms
self.problems = problems
self.sessions = sessions
self.attempts = attempts
}
}
struct AndroidGym: Codable {
let id: String
let name: String
let location: String?
let supportedClimbTypes: [ClimbType]
let difficultySystems: [DifficultySystem]
let notes: String?
let createdAt: String
let updatedAt: String
init(from gym: Gym) {
self.id = gym.id.uuidString
self.name = gym.name
self.location = gym.location
self.supportedClimbTypes = gym.supportedClimbTypes
self.difficultySystems = gym.difficultySystems
self.notes = gym.notes
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS"
self.createdAt = formatter.string(from: gym.createdAt)
self.updatedAt = formatter.string(from: gym.updatedAt)
}
init(
id: String, name: String, location: String?, supportedClimbTypes: [ClimbType],
difficultySystems: [DifficultySystem], notes: String?, createdAt: String, updatedAt: String
) {
self.id = id
self.name = name
self.location = location
self.supportedClimbTypes = supportedClimbTypes
self.difficultySystems = difficultySystems
self.notes = notes
self.createdAt = createdAt
self.updatedAt = updatedAt
}
func toGym() -> Gym {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS"
let gymId = UUID(uuidString: id) ?? UUID()
let createdDate = formatter.date(from: createdAt) ?? Date()
let updatedDate = formatter.date(from: updatedAt) ?? Date()
return Gym.fromImport(
id: gymId,
name: name,
location: location,
supportedClimbTypes: supportedClimbTypes,
difficultySystems: difficultySystems,
customDifficultyGrades: [],
notes: notes,
createdAt: createdDate,
updatedAt: updatedDate
)
}
}
struct AndroidProblem: Codable {
let id: String
let gymId: String
let name: String?
let description: String?
let climbType: ClimbType
let difficulty: DifficultyGrade
let imagePaths: [String]?
let createdAt: String
let updatedAt: String
init(from problem: Problem) {
self.id = problem.id.uuidString
self.gymId = problem.gymId.uuidString
self.name = problem.name
self.description = problem.description
self.climbType = problem.climbType
self.difficulty = problem.difficulty
self.imagePaths = problem.imagePaths.isEmpty ? nil : problem.imagePaths
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS"
self.createdAt = formatter.string(from: problem.createdAt)
self.updatedAt = formatter.string(from: problem.updatedAt)
}
init(
id: String, gymId: String, name: String?, description: String?, climbType: ClimbType,
difficulty: DifficultyGrade, imagePaths: [String]?, createdAt: String, updatedAt: String
) {
self.id = id
self.gymId = gymId
self.name = name
self.description = description
self.climbType = climbType
self.difficulty = difficulty
self.imagePaths = imagePaths
self.createdAt = createdAt
self.updatedAt = updatedAt
}
func toProblem() -> Problem {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS"
let problemId = UUID(uuidString: id) ?? UUID()
let preservedGymId = UUID(uuidString: gymId) ?? UUID()
let createdDate = formatter.date(from: createdAt) ?? Date()
let updatedDate = formatter.date(from: updatedAt) ?? Date()
return Problem.fromImport(
id: problemId,
gymId: preservedGymId,
name: name,
description: description,
climbType: climbType,
difficulty: difficulty,
setter: nil,
tags: [],
location: nil,
imagePaths: imagePaths ?? [],
isActive: true,
dateSet: nil,
notes: nil,
createdAt: createdDate,
updatedAt: updatedDate
)
}
func withUpdatedImagePaths(_ newImagePaths: [String]) -> AndroidProblem {
return AndroidProblem(
id: self.id,
gymId: self.gymId,
name: self.name,
description: self.description,
climbType: self.climbType,
difficulty: self.difficulty,
imagePaths: newImagePaths.isEmpty ? nil : newImagePaths,
createdAt: self.createdAt,
updatedAt: self.updatedAt
)
}
}
struct AndroidClimbSession: Codable {
let id: String
let gymId: String
let date: String
let startTime: String?
let endTime: String?
let duration: Int?
let status: SessionStatus
let createdAt: String
let updatedAt: String
init(from session: ClimbSession) {
self.id = session.id.uuidString
self.gymId = session.gymId.uuidString
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS"
self.date = formatter.string(from: session.date)
self.startTime = session.startTime != nil ? formatter.string(from: session.startTime!) : nil
self.endTime = session.endTime != nil ? formatter.string(from: session.endTime!) : nil
self.duration = session.duration
self.status = session.status
self.createdAt = formatter.string(from: session.createdAt)
self.updatedAt = formatter.string(from: session.updatedAt)
}
init(
id: String, gymId: String, date: String, startTime: String?, endTime: String?,
duration: Int?, status: SessionStatus, createdAt: String, updatedAt: String
) {
self.id = id
self.gymId = gymId
self.date = date
self.startTime = startTime
self.endTime = endTime
self.duration = duration
self.status = status
self.createdAt = createdAt
self.updatedAt = updatedAt
}
func toClimbSession() -> ClimbSession {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS"
// Preserve original IDs and dates
let sessionId = UUID(uuidString: id) ?? UUID()
let preservedGymId = UUID(uuidString: gymId) ?? UUID()
let sessionDate = formatter.date(from: date) ?? Date()
let sessionStartTime = startTime != nil ? formatter.date(from: startTime!) : nil
let sessionEndTime = endTime != nil ? formatter.date(from: endTime!) : nil
let createdDate = formatter.date(from: createdAt) ?? Date()
let updatedDate = formatter.date(from: updatedAt) ?? Date()
return ClimbSession.fromImport(
id: sessionId,
gymId: preservedGymId,
date: sessionDate,
startTime: sessionStartTime,
endTime: sessionEndTime,
duration: duration,
status: status,
notes: nil,
createdAt: createdDate,
updatedAt: updatedDate
)
}
}
struct AndroidAttempt: Codable {
let id: String
let sessionId: String
let problemId: String
let result: AttemptResult
let highestHold: String?
let notes: String?
let duration: Int?
let restTime: Int?
let timestamp: String
let createdAt: String
init(from attempt: Attempt) {
self.id = attempt.id.uuidString
self.sessionId = attempt.sessionId.uuidString
self.problemId = attempt.problemId.uuidString
self.result = attempt.result
self.highestHold = attempt.highestHold
self.notes = attempt.notes
self.duration = attempt.duration
self.restTime = attempt.restTime
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS"
self.timestamp = formatter.string(from: attempt.timestamp)
self.createdAt = formatter.string(from: attempt.createdAt)
}
init(
id: String, sessionId: String, problemId: String, result: AttemptResult,
highestHold: String?, notes: String?, duration: Int?, restTime: Int?,
timestamp: String, createdAt: String
) {
self.id = id
self.sessionId = sessionId
self.problemId = problemId
self.result = result
self.highestHold = highestHold
self.notes = notes
self.duration = duration
self.restTime = restTime
self.timestamp = timestamp
self.createdAt = createdAt
}
func toAttempt() -> Attempt {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS"
let attemptId = UUID(uuidString: id) ?? UUID()
let preservedSessionId = UUID(uuidString: sessionId) ?? UUID()
let preservedProblemId = UUID(uuidString: problemId) ?? UUID()
let attemptTimestamp = formatter.date(from: timestamp) ?? Date()
let createdDate = formatter.date(from: createdAt) ?? Date()
return Attempt.fromImport(
id: attemptId,
sessionId: preservedSessionId,
problemId: preservedProblemId,
result: result,
highestHold: highestHold,
notes: notes,
duration: duration,
restTime: restTime,
timestamp: attemptTimestamp,
createdAt: createdDate
)
}
}
// MARK: - Helper Functions
extension ClimbingDataManager {
private func collectReferencedImagePaths() -> Set<String> {
var imagePaths = Set<String>()
for problem in problems {
imagePaths.formUnion(problem.imagePaths)
}
return imagePaths
}
private func updateProblemImagePaths(
problems: [AndroidProblem],
imagePathMapping: [String: String]
) -> [AndroidProblem] {
return problems.map { problem in
let updatedImagePaths = (problem.imagePaths ?? []).compactMap { oldPath in
let fileName = URL(fileURLWithPath: oldPath).lastPathComponent
return imagePathMapping[fileName]
}
return problem.withUpdatedImagePaths(updatedImagePaths)
}
}
private func validateImportData(_ importData: ClimbDataExport) throws {
if importData.gyms.isEmpty {
throw NSError(
domain: "ImportError", code: 1,
userInfo: [NSLocalizedDescriptionKey: "Import data is invalid: no gyms found"])
}
}
}
// MARK: - Preview Helper
extension ClimbingDataManager {
static var preview: ClimbingDataManager {
let manager = ClimbingDataManager()
let sampleGym = Gym(
name: "Sample Climbing Gym",
location: "123 Rock St, Boulder, CO",
supportedClimbTypes: [.boulder, .rope],
difficultySystems: [.vScale, .yds]
)
manager.gyms = [sampleGym]
let sampleProblem = Problem(
gymId: sampleGym.id,
name: "Crimpy Overhang",
description: "Technical overhang with small holds",
climbType: .boulder,
difficulty: DifficultyGrade(system: .vScale, grade: "V4"),
setter: "John Doe",
tags: ["technical", "overhang"],
location: "Cave area"
)
manager.problems = [sampleProblem]
return manager
}
}

View File

@@ -0,0 +1,554 @@
//
// AddAttemptView.swift
// OpenClimb
//
// Created by OpenClimb on 2025-01-17.
//
import SwiftUI
struct AddAttemptView: View {
let session: ClimbSession
let gym: Gym
@EnvironmentObject var dataManager: ClimbingDataManager
@Environment(\.dismiss) private var dismiss
@State private var selectedProblem: Problem?
@State private var selectedResult: AttemptResult = .fall
@State private var highestHold = ""
@State private var notes = ""
@State private var duration: Int = 0
@State private var restTime: Int = 0
@State private var showingCreateProblem = false
// New problem creation state
@State private var newProblemName = ""
@State private var newProblemGrade = ""
@State private var selectedClimbType: ClimbType = .boulder
@State private var selectedDifficultySystem: DifficultySystem = .vScale
private var activeProblems: [Problem] {
dataManager.activeProblems(forGym: gym.id)
}
private var availableClimbTypes: [ClimbType] {
gym.supportedClimbTypes
}
private var availableDifficultySystems: [DifficultySystem] {
DifficultySystem.systemsForClimbType(selectedClimbType).filter { system in
gym.difficultySystems.contains(system)
}
}
private var availableGrades: [String] {
selectedDifficultySystem.availableGrades
}
var body: some View {
NavigationView {
Form {
if !showingCreateProblem {
ProblemSelectionSection()
} else {
CreateProblemSection()
}
AttemptDetailsSection()
}
.navigationTitle("Add Attempt")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Add") {
saveAttempt()
}
.disabled(!canSave)
}
}
}
.onAppear {
setupInitialValues()
}
.onChange(of: selectedClimbType) { _ in
updateDifficultySystem()
}
.onChange(of: selectedDifficultySystem) { _ in
resetGradeIfNeeded()
}
}
@ViewBuilder
private func ProblemSelectionSection() -> some View {
Section("Select Problem") {
if activeProblems.isEmpty {
VStack(alignment: .leading, spacing: 12) {
Text("No active problems in this gym")
.foregroundColor(.secondary)
Button("Create New Problem") {
showingCreateProblem = true
}
.buttonStyle(.borderedProminent)
}
.padding(.vertical, 8)
} else {
ForEach(activeProblems, id: \.id) { problem in
ProblemSelectionRow(
problem: problem,
isSelected: selectedProblem?.id == problem.id
) {
selectedProblem = problem
}
}
Button("Create New Problem") {
showingCreateProblem = true
}
.foregroundColor(.blue)
}
}
}
@ViewBuilder
private func CreateProblemSection() -> some View {
Section {
HStack {
Text("Create New Problem")
.font(.headline)
Spacer()
Button("Back") {
showingCreateProblem = false
}
.foregroundColor(.blue)
}
}
Section("Problem Details") {
TextField("Problem Name", text: $newProblemName)
}
Section("Climb Type") {
ForEach(availableClimbTypes, id: \.self) { climbType in
HStack {
Text(climbType.displayName)
Spacer()
if selectedClimbType == climbType {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.blue)
} else {
Image(systemName: "circle")
.foregroundColor(.gray)
}
}
.contentShape(Rectangle())
.onTapGesture {
selectedClimbType = climbType
}
}
}
Section("Difficulty") {
VStack(alignment: .leading, spacing: 12) {
Text("Difficulty System")
.font(.subheadline)
.fontWeight(.medium)
ForEach(availableDifficultySystems, id: \.self) { system in
HStack {
Text(system.displayName)
Spacer()
if selectedDifficultySystem == system {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.blue)
} else {
Image(systemName: "circle")
.foregroundColor(.gray)
}
}
.contentShape(Rectangle())
.onTapGesture {
selectedDifficultySystem = system
}
}
}
if selectedDifficultySystem == .custom {
TextField("Grade (Required)", text: $newProblemGrade)
.keyboardType(.numberPad)
} else {
VStack(alignment: .leading, spacing: 8) {
Text("Grade (Required)")
.font(.subheadline)
.fontWeight(.medium)
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack(spacing: 8) {
ForEach(availableGrades, id: \.self) { grade in
Button(grade) {
newProblemGrade = grade
}
.buttonStyle(.bordered)
.controlSize(.small)
.tint(newProblemGrade == grade ? .blue : .gray)
}
}
.padding(.horizontal, 1)
}
}
}
}
}
@ViewBuilder
private func AttemptDetailsSection() -> some View {
Section("Attempt Result") {
ForEach(AttemptResult.allCases, id: \.self) { result in
HStack {
Text(result.displayName)
Spacer()
if selectedResult == result {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.blue)
} else {
Image(systemName: "circle")
.foregroundColor(.gray)
}
}
.contentShape(Rectangle())
.onTapGesture {
selectedResult = result
}
}
}
Section("Additional Details") {
TextField("Highest Hold (Optional)", text: $highestHold)
VStack(alignment: .leading, spacing: 8) {
Text("Notes (Optional)")
.font(.headline)
TextEditor(text: $notes)
.frame(minHeight: 80)
.padding(8)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(.quaternary)
)
}
HStack {
Text("Duration (seconds)")
Spacer()
TextField("0", value: $duration, format: .number)
.keyboardType(.numberPad)
.textFieldStyle(.roundedBorder)
.frame(width: 80)
}
HStack {
Text("Rest Time (seconds)")
Spacer()
TextField("0", value: $restTime, format: .number)
.keyboardType(.numberPad)
.textFieldStyle(.roundedBorder)
.frame(width: 80)
}
}
}
private var canSave: Bool {
if showingCreateProblem {
return !newProblemGrade.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
} else {
return selectedProblem != nil
}
}
private func setupInitialValues() {
// Auto-select climb type if there's only one available
if gym.supportedClimbTypes.count == 1 {
selectedClimbType = gym.supportedClimbTypes.first!
}
updateDifficultySystem()
}
private func updateDifficultySystem() {
let available = availableDifficultySystems
if !available.contains(selectedDifficultySystem) {
selectedDifficultySystem = available.first ?? .custom
}
if available.count == 1 {
selectedDifficultySystem = available.first!
}
}
private func resetGradeIfNeeded() {
let availableGrades = selectedDifficultySystem.availableGrades
if !availableGrades.isEmpty && !availableGrades.contains(newProblemGrade) {
newProblemGrade = ""
}
}
private func saveAttempt() {
if showingCreateProblem {
let difficulty = DifficultyGrade(
system: selectedDifficultySystem, grade: newProblemGrade)
let newProblem = Problem(
gymId: gym.id,
name: newProblemName.isEmpty ? nil : newProblemName,
climbType: selectedClimbType,
difficulty: difficulty
)
dataManager.addProblem(newProblem)
let attempt = Attempt(
sessionId: session.id,
problemId: newProblem.id,
result: selectedResult,
highestHold: highestHold.isEmpty ? nil : highestHold,
notes: notes.isEmpty ? nil : notes,
duration: duration == 0 ? nil : duration,
restTime: restTime == 0 ? nil : restTime,
timestamp: Date()
)
dataManager.addAttempt(attempt)
} else {
guard let problem = selectedProblem else { return }
let attempt = Attempt(
sessionId: session.id,
problemId: problem.id,
result: selectedResult,
highestHold: highestHold.isEmpty ? nil : highestHold,
notes: notes.isEmpty ? nil : notes,
duration: duration > 0 ? duration : nil,
restTime: restTime > 0 ? restTime : nil
)
dataManager.addAttempt(attempt)
}
dismiss()
}
}
struct ProblemSelectionRow: View {
let problem: Problem
let isSelected: Bool
let action: () -> Void
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(problem.name ?? "Unnamed Problem")
.font(.headline)
.fontWeight(.medium)
Text("\(problem.difficulty.system.displayName): \(problem.difficulty.grade)")
.font(.subheadline)
.foregroundColor(.blue)
if let location = problem.location {
Text(location)
.font(.caption)
.foregroundColor(.secondary)
}
}
Spacer()
if isSelected {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.blue)
} else {
Image(systemName: "circle")
.foregroundColor(.gray)
}
}
.contentShape(Rectangle())
.onTapGesture(perform: action)
.padding(.vertical, 4)
}
}
struct EditAttemptView: View {
let attempt: Attempt
@EnvironmentObject var dataManager: ClimbingDataManager
@Environment(\.dismiss) private var dismiss
@State private var selectedProblem: Problem?
@State private var selectedResult: AttemptResult
@State private var highestHold: String
@State private var notes: String
@State private var duration: Int
@State private var restTime: Int
private var availableProblems: [Problem] {
dataManager.problems.filter { $0.isActive }
}
init(attempt: Attempt) {
self.attempt = attempt
self._selectedResult = State(initialValue: attempt.result)
self._highestHold = State(initialValue: attempt.highestHold ?? "")
self._notes = State(initialValue: attempt.notes ?? "")
self._duration = State(initialValue: attempt.duration ?? 0)
self._restTime = State(initialValue: attempt.restTime ?? 0)
}
var body: some View {
NavigationView {
Form {
Section("Problem") {
if availableProblems.isEmpty {
Text("No problems available")
.foregroundColor(.secondary)
} else {
ForEach(availableProblems, id: \.id) { problem in
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(problem.name ?? "Unnamed Problem")
.font(.headline)
Text(
"\(problem.difficulty.system.displayName): \(problem.difficulty.grade)"
)
.font(.subheadline)
.foregroundColor(.blue)
}
Spacer()
if selectedProblem?.id == problem.id {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.blue)
}
}
.contentShape(Rectangle())
.onTapGesture {
selectedProblem = problem
}
}
}
}
Section("Result") {
ForEach(AttemptResult.allCases, id: \.self) { result in
HStack {
Text(result.displayName)
Spacer()
if selectedResult == result {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.blue)
} else {
Image(systemName: "circle")
.foregroundColor(.gray)
}
}
.contentShape(Rectangle())
.onTapGesture {
selectedResult = result
}
}
}
Section("Details") {
TextField("Highest Hold (Optional)", text: $highestHold)
VStack(alignment: .leading, spacing: 8) {
Text("Notes (Optional)")
.font(.headline)
TextEditor(text: $notes)
.frame(minHeight: 80)
.padding(8)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(.quaternary)
)
}
HStack {
Text("Duration (seconds)")
Spacer()
TextField("0", value: $duration, format: .number)
.keyboardType(.numberPad)
.textFieldStyle(.roundedBorder)
.frame(width: 80)
}
HStack {
Text("Rest Time (seconds)")
Spacer()
TextField("0", value: $restTime, format: .number)
.keyboardType(.numberPad)
.textFieldStyle(.roundedBorder)
.frame(width: 80)
}
}
}
.navigationTitle("Edit Attempt")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Update") {
updateAttempt()
}
.disabled(selectedProblem == nil)
}
}
}
.onAppear {
selectedProblem = dataManager.problem(withId: attempt.problemId)
}
}
private func updateAttempt() {
guard let problem = selectedProblem else { return }
let updatedAttempt = attempt.updated(
result: selectedResult,
highestHold: highestHold.isEmpty ? nil : highestHold,
notes: notes.isEmpty ? nil : notes,
duration: duration > 0 ? duration : nil,
restTime: restTime > 0 ? restTime : nil
)
dataManager.updateAttempt(updatedAttempt)
dismiss()
}
}
#Preview {
AddAttemptView(
session: ClimbSession(gymId: UUID()),
gym: Gym(
name: "Sample Gym",
supportedClimbTypes: [.boulder],
difficultySystems: [.vScale]
)
)
.environmentObject(ClimbingDataManager.preview)
}

View File

@@ -0,0 +1,216 @@
//
// AddEditGymView.swift
// OpenClimb
//
// Created by OpenClimb on 2025-01-17.
//
import SwiftUI
struct AddEditGymView: View {
let gymId: UUID?
@EnvironmentObject var dataManager: ClimbingDataManager
@Environment(\.dismiss) private var dismiss
@State private var name = ""
@State private var location = ""
@State private var notes = ""
@State private var selectedClimbTypes = Set<ClimbType>()
@State private var selectedDifficultySystems = Set<DifficultySystem>()
@State private var customDifficultyGrades: [String] = []
@State private var isEditing = false
private var existingGym: Gym? {
guard let gymId = gymId else { return nil }
return dataManager.gym(withId: gymId)
}
private var availableDifficultySystems: [DifficultySystem] {
if selectedClimbTypes.isEmpty {
return []
} else {
return selectedClimbTypes.flatMap { climbType in
DifficultySystem.systemsForClimbType(climbType)
}.removingDuplicates()
}
}
init(gymId: UUID? = nil) {
self.gymId = gymId
}
var body: some View {
NavigationView {
Form {
BasicInfoSection()
ClimbTypesSection()
DifficultySystemsSection()
NotesSection()
}
.navigationTitle(isEditing ? "Edit Gym" : "Add Gym")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Save") {
saveGym()
}
.disabled(!canSave)
}
}
}
.onAppear {
loadExistingGym()
}
.onChange(of: selectedClimbTypes) { _ in
updateAvailableDifficultySystems()
}
}
@ViewBuilder
private func BasicInfoSection() -> some View {
Section("Basic Information") {
TextField("Gym Name", text: $name)
TextField("Location (Optional)", text: $location)
}
}
@ViewBuilder
private func ClimbTypesSection() -> some View {
Section("Supported Climb Types") {
ForEach(ClimbType.allCases, id: \.self) { climbType in
HStack {
Text(climbType.displayName)
Spacer()
if selectedClimbTypes.contains(climbType) {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.blue)
} else {
Image(systemName: "circle")
.foregroundColor(.gray)
}
}
.contentShape(Rectangle())
.onTapGesture {
if selectedClimbTypes.contains(climbType) {
selectedClimbTypes.remove(climbType)
} else {
selectedClimbTypes.insert(climbType)
}
}
}
}
}
@ViewBuilder
private func DifficultySystemsSection() -> some View {
Section("Difficulty Systems") {
if selectedClimbTypes.isEmpty {
Text("Select climb types first to see available difficulty systems")
.foregroundColor(.secondary)
.font(.caption)
} else {
ForEach(availableDifficultySystems, id: \.self) { system in
HStack {
Text(system.displayName)
Spacer()
if selectedDifficultySystems.contains(system) {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.blue)
} else {
Image(systemName: "circle")
.foregroundColor(.gray)
}
}
.contentShape(Rectangle())
.onTapGesture {
if selectedDifficultySystems.contains(system) {
selectedDifficultySystems.remove(system)
} else {
selectedDifficultySystems.insert(system)
}
}
}
}
}
}
@ViewBuilder
private func NotesSection() -> some View {
Section("Notes (Optional)") {
TextEditor(text: $notes)
.frame(minHeight: 100)
}
}
private var canSave: Bool {
!name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !selectedClimbTypes.isEmpty
&& !selectedDifficultySystems.isEmpty
}
private func loadExistingGym() {
if let gym = existingGym {
isEditing = true
name = gym.name
location = gym.location ?? ""
notes = gym.notes ?? ""
selectedClimbTypes = Set(gym.supportedClimbTypes)
selectedDifficultySystems = Set(gym.difficultySystems)
customDifficultyGrades = gym.customDifficultyGrades
}
}
private func updateAvailableDifficultySystems() {
// Remove selected systems that are no longer available
let availableSet = Set(availableDifficultySystems)
selectedDifficultySystems = selectedDifficultySystems.intersection(availableSet)
}
private func saveGym() {
let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedLocation = location.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedNotes = notes.trimmingCharacters(in: .whitespacesAndNewlines)
if isEditing, let gym = existingGym {
let updatedGym = gym.updated(
name: trimmedName,
location: trimmedLocation.isEmpty ? nil : trimmedLocation,
supportedClimbTypes: Array(selectedClimbTypes),
difficultySystems: Array(selectedDifficultySystems),
customDifficultyGrades: customDifficultyGrades,
notes: trimmedNotes.isEmpty ? nil : trimmedNotes
)
dataManager.updateGym(updatedGym)
} else {
let newGym = Gym(
name: trimmedName,
location: trimmedLocation.isEmpty ? nil : trimmedLocation,
supportedClimbTypes: Array(selectedClimbTypes),
difficultySystems: Array(selectedDifficultySystems),
customDifficultyGrades: customDifficultyGrades,
notes: trimmedNotes.isEmpty ? nil : trimmedNotes
)
dataManager.addGym(newGym)
}
dismiss()
}
}
extension Array where Element: Hashable {
func removingDuplicates() -> [Element] {
var seen = Set<Element>()
return filter { seen.insert($0).inserted }
}
}
#Preview {
AddEditGymView()
.environmentObject(ClimbingDataManager.preview)
}

View File

@@ -0,0 +1,529 @@
//
// AddEditProblemView.swift
// OpenClimb
//
// Created by OpenClimb on 2025-01-17.
//
import PhotosUI
import SwiftUI
struct AddEditProblemView: View {
let problemId: UUID?
let gymId: UUID?
@EnvironmentObject var dataManager: ClimbingDataManager
@Environment(\.dismiss) private var dismiss
@State private var selectedGym: Gym?
@State private var name = ""
@State private var description = ""
@State private var selectedClimbType: ClimbType = .boulder
@State private var selectedDifficultySystem: DifficultySystem = .vScale
@State private var difficultyGrade = ""
@State private var setter = ""
@State private var location = ""
@State private var tags = ""
@State private var notes = ""
@State private var isActive = true
@State private var dateSet = Date()
@State private var imagePaths: [String] = []
@State private var selectedPhotos: [PhotosPickerItem] = []
@State private var imageData: [Data] = []
@State private var isEditing = false
private var existingProblem: Problem? {
guard let problemId = problemId else { return nil }
return dataManager.problem(withId: problemId)
}
private var availableClimbTypes: [ClimbType] {
selectedGym?.supportedClimbTypes ?? ClimbType.allCases
}
var availableDifficultySystems: [DifficultySystem] {
guard let gym = selectedGym else {
return DifficultySystem.systemsForClimbType(selectedClimbType)
}
let compatibleSystems = DifficultySystem.systemsForClimbType(selectedClimbType)
let gymSupportedSystems = gym.difficultySystems.filter { system in
compatibleSystems.contains(system)
}
return gymSupportedSystems.isEmpty ? compatibleSystems : gymSupportedSystems
}
private var availableGrades: [String] {
selectedDifficultySystem.availableGrades
}
init(problemId: UUID? = nil, gymId: UUID? = nil) {
self.problemId = problemId
self.gymId = gymId
}
var body: some View {
NavigationView {
Form {
GymSelectionSection()
BasicInfoSection()
ClimbTypeSection()
DifficultySection()
LocationAndSetterSection()
TagsSection()
PhotosSection()
AdditionalInfoSection()
}
.navigationTitle(isEditing ? "Edit Problem" : "Add Problem")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Save") {
saveProblem()
}
.disabled(!canSave)
}
}
}
.onAppear {
loadExistingProblem()
setupInitialGym()
}
.onChange(of: selectedGym) { _ in
updateAvailableOptions()
}
.onChange(of: selectedClimbType) { _ in
updateDifficultySystem()
}
.onChange(of: selectedDifficultySystem) { _ in
resetGradeIfNeeded()
}
.onChange(of: selectedPhotos) { _ in
Task {
await loadSelectedPhotos()
}
}
}
@ViewBuilder
private func GymSelectionSection() -> some View {
Section("Select Gym") {
if dataManager.gyms.isEmpty {
Text("No gyms available. Add a gym first.")
.foregroundColor(.secondary)
} else {
ForEach(dataManager.gyms, id: \.id) { gym in
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(gym.name)
.font(.headline)
if let location = gym.location, !location.isEmpty {
Text(location)
.font(.caption)
.foregroundColor(.secondary)
}
}
Spacer()
if selectedGym?.id == gym.id {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.blue)
}
}
.contentShape(Rectangle())
.onTapGesture {
selectedGym = gym
}
}
}
}
}
@ViewBuilder
private func BasicInfoSection() -> some View {
Section("Problem Details") {
TextField("Problem Name (Optional)", text: $name)
VStack(alignment: .leading, spacing: 8) {
Text("Description (Optional)")
.font(.headline)
TextEditor(text: $description)
.frame(minHeight: 80)
.padding(8)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(.quaternary)
)
}
TextField("Route Setter (Optional)", text: $setter)
}
}
@ViewBuilder
private func ClimbTypeSection() -> some View {
if let gym = selectedGym {
Section("Climb Type") {
ForEach(availableClimbTypes, id: \.self) { climbType in
HStack {
Text(climbType.displayName)
Spacer()
if selectedClimbType == climbType {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.blue)
} else {
Image(systemName: "circle")
.foregroundColor(.gray)
}
}
.contentShape(Rectangle())
.onTapGesture {
selectedClimbType = climbType
}
}
}
}
}
@ViewBuilder
private func DifficultySection() -> some View {
Section("Difficulty") {
// Difficulty System
VStack(alignment: .leading, spacing: 8) {
Text("Difficulty System")
.font(.headline)
ForEach(availableDifficultySystems, id: \.self) { system in
HStack {
Text(system.displayName)
Spacer()
if selectedDifficultySystem == system {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.blue)
} else {
Image(systemName: "circle")
.foregroundColor(.gray)
}
}
.contentShape(Rectangle())
.onTapGesture {
selectedDifficultySystem = system
}
}
}
// Grade Selection
VStack(alignment: .leading, spacing: 8) {
Text("Grade (Required)")
.font(.headline)
if selectedDifficultySystem == .custom || availableGrades.isEmpty {
TextField("Enter custom grade", text: $difficultyGrade)
.textFieldStyle(.roundedBorder)
} else {
Menu {
if !difficultyGrade.isEmpty {
Button("Clear Selection") {
difficultyGrade = ""
}
Divider()
}
ForEach(availableGrades, id: \.self) { grade in
Button(grade) {
difficultyGrade = grade
}
}
} label: {
HStack {
Text(difficultyGrade.isEmpty ? "Select Grade" : difficultyGrade)
.foregroundColor(difficultyGrade.isEmpty ? .secondary : .primary)
.fontWeight(difficultyGrade.isEmpty ? .regular : .semibold)
Spacer()
Image(systemName: "chevron.down")
.foregroundColor(.secondary)
.font(.caption)
}
.padding()
.background(
RoundedRectangle(cornerRadius: 8)
.fill(.gray.opacity(0.1))
.stroke(
difficultyGrade.isEmpty
? .red.opacity(0.5) : .gray.opacity(0.3), lineWidth: 1)
)
}
.buttonStyle(.plain)
}
if difficultyGrade.isEmpty {
Text("Please select a grade to continue")
.font(.caption)
.foregroundColor(.red)
.italic()
} else {
Text("Selected: \(difficultyGrade)")
.font(.caption)
.foregroundColor(.blue)
}
}
}
}
@ViewBuilder
private func LocationAndSetterSection() -> some View {
Section("Location & Details") {
TextField(
"Location (Optional)", text: $location, prompt: Text("e.g., 'Cave area', 'Wall 3'"))
DatePicker(
"Date Set",
selection: $dateSet,
displayedComponents: [.date]
)
}
}
@ViewBuilder
private func TagsSection() -> some View {
Section("Tags (Optional)") {
TextField("Tags", text: $tags, prompt: Text("e.g., crimpy, dynamic (comma-separated)"))
}
}
@ViewBuilder
private func PhotosSection() -> some View {
Section("Photos") {
PhotosPicker(
selection: $selectedPhotos,
maxSelectionCount: 5,
matching: .images
) {
HStack {
Image(systemName: "photo.on.rectangle.angled")
.foregroundColor(.blue)
Text("Add Photos (\(imageData.count)/5)")
Spacer()
}
}
if !imageData.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) {
ForEach(imageData.indices, id: \.self) { index in
if let uiImage = UIImage(data: imageData[index]) {
Image(uiImage: uiImage)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 80, height: 80)
.clipped()
.cornerRadius(8)
.overlay(alignment: .topTrailing) {
Button(action: {
imageData.remove(at: index)
if index < imagePaths.count {
imagePaths.remove(at: index)
}
}) {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.red)
.background(Circle().fill(.white))
}
.offset(x: 8, y: -8)
}
} else {
RoundedRectangle(cornerRadius: 8)
.fill(.gray.opacity(0.3))
.frame(width: 80, height: 80)
.overlay {
Image(systemName: "photo")
.foregroundColor(.gray)
}
}
}
}
.padding(.horizontal, 1)
}
}
}
}
@ViewBuilder
private func AdditionalInfoSection() -> some View {
Section("Additional Information") {
VStack(alignment: .leading, spacing: 8) {
Text("Notes (Optional)")
.font(.headline)
TextEditor(text: $notes)
.frame(minHeight: 80)
.padding(8)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(.quaternary)
)
}
Toggle("Problem is currently active", isOn: $isActive)
}
}
private var canSave: Bool {
selectedGym != nil
&& !difficultyGrade.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
private func setupInitialGym() {
if let gymId = gymId, selectedGym == nil {
selectedGym = dataManager.gym(withId: gymId)
}
}
private func loadExistingProblem() {
if let problem = existingProblem {
isEditing = true
selectedGym = dataManager.gym(withId: problem.gymId)
name = problem.name ?? ""
description = problem.description ?? ""
selectedClimbType = problem.climbType
selectedDifficultySystem = problem.difficulty.system
difficultyGrade = problem.difficulty.grade
setter = problem.setter ?? ""
location = problem.location ?? ""
tags = problem.tags.joined(separator: ", ")
notes = problem.notes ?? ""
isActive = problem.isActive
imagePaths = problem.imagePaths
// Load image data for preview
imageData = []
for imagePath in problem.imagePaths {
if let data = try? Data(contentsOf: URL(fileURLWithPath: imagePath)) {
imageData.append(data)
}
}
if let dateSet = problem.dateSet {
self.dateSet = dateSet
}
}
}
private func updateAvailableOptions() {
guard let gym = selectedGym else { return }
// Auto-select climb type if there's only one available
if gym.supportedClimbTypes.count == 1, selectedClimbType != gym.supportedClimbTypes.first! {
selectedClimbType = gym.supportedClimbTypes.first!
}
updateDifficultySystem()
}
private func updateDifficultySystem() {
let available = availableDifficultySystems
if !available.contains(selectedDifficultySystem) {
selectedDifficultySystem = available.first ?? .custom
}
if available.count == 1, selectedDifficultySystem != available.first! {
selectedDifficultySystem = available.first!
}
}
private func resetGradeIfNeeded() {
let availableGrades = selectedDifficultySystem.availableGrades
if !availableGrades.isEmpty && !availableGrades.contains(difficultyGrade) {
difficultyGrade = ""
}
}
private func loadSelectedPhotos() async {
for item in selectedPhotos {
if let data = try? await item.loadTransferable(type: Data.self) {
// Save to app's documents directory
let documentsPath = FileManager.default.urls(
for: .documentDirectory, in: .userDomainMask
).first!
let imageName = "photo_\(UUID().uuidString).jpg"
let imagePath = documentsPath.appendingPathComponent(imageName)
do {
try data.write(to: imagePath)
imagePaths.append(imagePath.path)
imageData.append(data)
} catch {
print("Failed to save image: \(error)")
}
}
}
selectedPhotos.removeAll()
}
private func saveProblem() {
guard let gym = selectedGym else { return }
let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedDescription = description.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedSetter = setter.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedLocation = location.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedNotes = notes.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedTags = tags.split(separator: ",").map {
$0.trimmingCharacters(in: .whitespacesAndNewlines)
}.filter { !$0.isEmpty }
let difficulty = DifficultyGrade(system: selectedDifficultySystem, grade: difficultyGrade)
if isEditing, let problem = existingProblem {
let updatedProblem = problem.updated(
name: trimmedName.isEmpty ? nil : trimmedName,
description: trimmedDescription.isEmpty ? nil : trimmedDescription,
climbType: selectedClimbType,
difficulty: difficulty,
setter: trimmedSetter.isEmpty ? nil : trimmedSetter,
tags: trimmedTags,
location: trimmedLocation.isEmpty ? nil : trimmedLocation,
imagePaths: imagePaths,
isActive: isActive,
dateSet: dateSet,
notes: trimmedNotes.isEmpty ? nil : trimmedNotes
)
dataManager.updateProblem(updatedProblem)
} else {
let newProblem = Problem(
gymId: gym.id,
name: trimmedName.isEmpty ? nil : trimmedName,
description: trimmedDescription.isEmpty ? nil : trimmedDescription,
climbType: selectedClimbType,
difficulty: difficulty,
setter: trimmedSetter.isEmpty ? nil : trimmedSetter,
tags: trimmedTags,
location: trimmedLocation.isEmpty ? nil : trimmedLocation,
imagePaths: imagePaths,
dateSet: dateSet,
notes: trimmedNotes.isEmpty ? nil : trimmedNotes
)
dataManager.addProblem(newProblem)
}
dismiss()
}
}
#Preview {
AddEditProblemView()
.environmentObject(ClimbingDataManager.preview)
}

View File

@@ -0,0 +1,143 @@
//
// AddEditSessionView.swift
// OpenClimb
//
// Created by OpenClimb on 2025-01-17.
//
import SwiftUI
struct AddEditSessionView: View {
let sessionId: UUID?
@EnvironmentObject var dataManager: ClimbingDataManager
@Environment(\.dismiss) private var dismiss
@State private var selectedGym: Gym?
@State private var sessionDate = Date()
@State private var notes = ""
@State private var isEditing = false
private var existingSession: ClimbSession? {
guard let sessionId = sessionId else { return nil }
return dataManager.session(withId: sessionId)
}
init(sessionId: UUID? = nil) {
self.sessionId = sessionId
}
var body: some View {
NavigationView {
Form {
GymSelectionSection()
SessionDetailsSection()
}
.navigationTitle(isEditing ? "Edit Session" : "New Session")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Save") {
saveSession()
}
.disabled(selectedGym == nil)
}
}
}
.onAppear {
loadExistingSession()
}
}
@ViewBuilder
private func GymSelectionSection() -> some View {
Section("Select Gym") {
if dataManager.gyms.isEmpty {
Text("No gyms available. Add a gym first.")
.foregroundColor(.secondary)
} else {
ForEach(dataManager.gyms, id: \.id) { gym in
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(gym.name)
.font(.headline)
if let location = gym.location, !location.isEmpty {
Text(location)
.font(.caption)
.foregroundColor(.secondary)
}
}
Spacer()
if selectedGym?.id == gym.id {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.blue)
}
}
.contentShape(Rectangle())
.onTapGesture {
selectedGym = gym
}
}
}
}
}
@ViewBuilder
private func SessionDetailsSection() -> some View {
Section("Session Details") {
DatePicker(
"Date",
selection: $sessionDate,
displayedComponents: [.date]
)
VStack(alignment: .leading, spacing: 8) {
Text("Notes (Optional)")
.font(.headline)
TextEditor(text: $notes)
.frame(minHeight: 100)
.padding(8)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(.quaternary)
)
}
}
}
private func loadExistingSession() {
if let session = existingSession {
isEditing = true
selectedGym = dataManager.gym(withId: session.gymId)
sessionDate = session.date
notes = session.notes ?? ""
}
}
private func saveSession() {
guard let gym = selectedGym else { return }
if isEditing, let session = existingSession {
let updatedSession = session.updated(notes: notes.isEmpty ? nil : notes)
dataManager.updateSession(updatedSession)
} else {
dataManager.startSession(gymId: gym.id, notes: notes.isEmpty ? nil : notes)
}
dismiss()
}
}
#Preview {
AddEditSessionView()
.environmentObject(ClimbingDataManager.preview)
}

View File

@@ -0,0 +1,407 @@
//
// AnalyticsView.swift
// OpenClimb
//
// Created by OpenClimb on 2025-01-17.
//
import SwiftUI
struct AnalyticsView: View {
@EnvironmentObject var dataManager: ClimbingDataManager
var body: some View {
NavigationView {
ScrollView {
LazyVStack(spacing: 20) {
HeaderSection()
OverallStatsSection()
ProgressChartSection()
FavoriteGymSection()
RecentActivitySection()
}
.padding()
}
.navigationTitle("Analytics")
}
}
}
struct HeaderSection: View {
var body: some View {
HStack {
Image(systemName: "mountain.2.fill")
.font(.title)
.foregroundColor(.blue)
Text("Analytics")
.font(.title)
.fontWeight(.bold)
Spacer()
}
}
}
struct OverallStatsSection: View {
@EnvironmentObject var dataManager: ClimbingDataManager
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Overall Stats")
.font(.title2)
.fontWeight(.bold)
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 2), spacing: 16) {
StatCard(
title: "Sessions",
value: "\(dataManager.completedSessions().count)",
icon: "play.fill",
color: .blue
)
StatCard(
title: "Problems",
value: "\(dataManager.problems.count)",
icon: "star.fill",
color: .orange
)
StatCard(
title: "Attempts",
value: "\(dataManager.totalAttempts())",
icon: "hand.raised.fill",
color: .green
)
StatCard(
title: "Gyms",
value: "\(dataManager.gyms.count)",
icon: "location.fill",
color: .purple
)
}
}
.padding()
.background(
RoundedRectangle(cornerRadius: 16)
.fill(.regularMaterial)
)
}
}
struct StatCard: View {
let title: String
let value: String
let icon: String
let color: Color
var body: some View {
VStack(spacing: 8) {
Image(systemName: icon)
.font(.title2)
.foregroundColor(color)
Text(value)
.font(.title)
.fontWeight(.bold)
.foregroundColor(.primary)
Text(title)
.font(.caption)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity)
.padding()
.background(
RoundedRectangle(cornerRadius: 12)
.fill(.ultraThinMaterial)
)
}
}
struct ProgressChartSection: View {
@EnvironmentObject var dataManager: ClimbingDataManager
@State private var selectedSystem: DifficultySystem = .vScale
private var progressData: [ProgressDataPoint] {
calculateProgressOverTime()
}
private var usedSystems: [DifficultySystem] {
Set(progressData.map { $0.difficultySystem }).sorted { $0.rawValue < $1.rawValue }
}
var body: some View {
VStack(alignment: .leading, spacing: 16) {
HStack {
Text("Progress Over Time")
.font(.title2)
.fontWeight(.bold)
Spacer()
if usedSystems.count > 1 {
Menu {
ForEach(usedSystems, id: \.self) { system in
Button(system.displayName) {
selectedSystem = system
}
}
} label: {
Text(selectedSystem.displayName)
.font(.caption)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(.blue.opacity(0.1))
)
.foregroundColor(.blue)
}
}
}
let filteredData = progressData.filter { $0.difficultySystem == selectedSystem }
if !filteredData.isEmpty {
VStack {
// Simple text-based chart placeholder
VStack(alignment: .leading, spacing: 8) {
ForEach(filteredData.indices.prefix(5), id: \.self) { index in
let point = filteredData[index]
HStack {
Text("Session \(index + 1)")
.font(.caption)
.frame(width: 80, alignment: .leading)
Rectangle()
.fill(.blue)
.frame(width: CGFloat(point.maxGradeNumeric * 5), height: 20)
Text(point.maxGrade)
.font(.caption)
.foregroundColor(.blue)
}
}
if filteredData.count > 5 {
Text("... and \(filteredData.count - 5) more sessions")
.font(.caption)
.foregroundColor(.secondary)
}
}
}
.frame(height: 200)
Text(
"X: session number, Y: max \(selectedSystem.displayName.lowercased()) grade achieved"
)
.font(.caption)
.foregroundColor(.secondary)
} else {
VStack(spacing: 8) {
Image(systemName: "chart.line.uptrend.xyaxis")
.font(.title)
.foregroundColor(.secondary)
Text("No progress data available for \(selectedSystem.displayName) system")
.font(.subheadline)
.foregroundColor(.secondary)
}
.frame(height: 200)
.frame(maxWidth: .infinity)
}
}
.padding()
.background(
RoundedRectangle(cornerRadius: 16)
.fill(.regularMaterial)
)
.onAppear {
if let firstSystem = usedSystems.first {
selectedSystem = firstSystem
}
}
}
private func calculateProgressOverTime() -> [ProgressDataPoint] {
let sessions = dataManager.completedSessions().sorted { $0.date < $1.date }
let problems = dataManager.problems
let attempts = dataManager.attempts
return sessions.compactMap { session in
let sessionAttempts = attempts.filter { $0.sessionId == session.id }
let attemptedProblemIds = sessionAttempts.map { $0.problemId }
let attemptedProblems = problems.filter { attemptedProblemIds.contains($0.id) }
guard
let highestGradeProblem = attemptedProblems.max(by: {
$0.difficulty.numericValue < $1.difficulty.numericValue
})
else {
return nil
}
return ProgressDataPoint(
date: session.date,
maxGrade: highestGradeProblem.difficulty.grade,
maxGradeNumeric: highestGradeProblem.difficulty.numericValue,
climbType: highestGradeProblem.climbType,
difficultySystem: highestGradeProblem.difficulty.system
)
}
}
}
struct FavoriteGymSection: View {
@EnvironmentObject var dataManager: ClimbingDataManager
private var favoriteGymInfo: (gym: Gym, sessionCount: Int)? {
let gymSessionCounts = Dictionary(grouping: dataManager.sessions, by: { $0.gymId })
.mapValues { $0.count }
guard let mostUsedGymId = gymSessionCounts.max(by: { $0.value < $1.value })?.key,
let gym = dataManager.gym(withId: mostUsedGymId)
else {
return nil
}
return (gym, gymSessionCounts[mostUsedGymId] ?? 0)
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Favorite Gym")
.font(.title2)
.fontWeight(.bold)
if let info = favoriteGymInfo {
VStack(alignment: .leading, spacing: 8) {
Text(info.gym.name)
.font(.title3)
.fontWeight(.semibold)
Text("\(info.sessionCount) sessions")
.font(.subheadline)
.foregroundColor(.secondary)
}
} else {
Text("No sessions yet")
.font(.subheadline)
.foregroundColor(.secondary)
}
}
.padding()
.background(
RoundedRectangle(cornerRadius: 16)
.fill(.regularMaterial)
)
}
}
struct RecentActivitySection: View {
@EnvironmentObject var dataManager: ClimbingDataManager
private var recentSessionsCount: Int {
dataManager.sessions.count
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Recent Activity")
.font(.title2)
.fontWeight(.bold)
if recentSessionsCount > 0 {
Text("You've had \(recentSessionsCount) sessions")
.font(.subheadline)
} else {
Text("No recent activity")
.font(.subheadline)
.foregroundColor(.secondary)
}
}
.padding()
.background(
RoundedRectangle(cornerRadius: 16)
.fill(.regularMaterial)
)
}
}
struct ProgressDataPoint {
let date: Date
let maxGrade: String
let maxGradeNumeric: Int
let climbType: ClimbType
let difficultySystem: DifficultySystem
}
// MARK: - Helper Functions
func gradeToNumeric(_ system: DifficultySystem, _ grade: String) -> Int {
switch system {
case .vScale:
if grade == "VB" { return 0 }
return Int(grade.replacingOccurrences(of: "V", with: "")) ?? 0
case .font:
let fontMapping: [String: Int] = [
"3": 3, "4A": 4, "4B": 5, "4C": 6, "5A": 7, "5B": 8, "5C": 9,
"6A": 10, "6A+": 11, "6B": 12, "6B+": 13, "6C": 14, "6C+": 15,
"7A": 16, "7A+": 17, "7B": 18, "7B+": 19, "7C": 20, "7C+": 21,
"8A": 22, "8A+": 23, "8B": 24, "8B+": 25, "8C": 26, "8C+": 27,
]
return fontMapping[grade] ?? 0
case .yds:
let ydsMapping: [String: Int] = [
"5.0": 50, "5.1": 51, "5.2": 52, "5.3": 53, "5.4": 54, "5.5": 55,
"5.6": 56, "5.7": 57, "5.8": 58, "5.9": 59, "5.10a": 60, "5.10b": 61,
"5.10c": 62, "5.10d": 63, "5.11a": 64, "5.11b": 65, "5.11c": 66,
"5.11d": 67, "5.12a": 68, "5.12b": 69, "5.12c": 70, "5.12d": 71,
"5.13a": 72, "5.13b": 73, "5.13c": 74, "5.13d": 75, "5.14a": 76,
"5.14b": 77, "5.14c": 78, "5.14d": 79, "5.15a": 80, "5.15b": 81,
"5.15c": 82, "5.15d": 83,
]
return ydsMapping[grade] ?? 0
case .custom:
return Int(grade) ?? 0
}
}
func numericToGrade(_ system: DifficultySystem, _ numeric: Int) -> String {
switch system {
case .vScale:
return numeric == 0 ? "VB" : "V\(numeric)"
case .font:
let fontMapping: [Int: String] = [
3: "3", 4: "4A", 5: "4B", 6: "4C", 7: "5A", 8: "5B", 9: "5C",
10: "6A", 11: "6A+", 12: "6B", 13: "6B+", 14: "6C", 15: "6C+",
16: "7A", 17: "7A+", 18: "7B", 19: "7B+", 20: "7C", 21: "7C+",
22: "8A", 23: "8A+", 24: "8B", 25: "8B+", 26: "8C", 27: "8C+",
]
return fontMapping[numeric] ?? "\(numeric)"
case .yds:
let ydsMapping: [Int: String] = [
50: "5.0", 51: "5.1", 52: "5.2", 53: "5.3", 54: "5.4", 55: "5.5",
56: "5.6", 57: "5.7", 58: "5.8", 59: "5.9", 60: "5.10a", 61: "5.10b",
62: "5.10c", 63: "5.10d", 64: "5.11a", 65: "5.11b", 66: "5.11c",
67: "5.11d", 68: "5.12a", 69: "5.12b", 70: "5.12c", 71: "5.12d",
72: "5.13a", 73: "5.13b", 74: "5.13c", 75: "5.13d", 76: "5.14a",
77: "5.14b", 78: "5.14c", 79: "5.14d", 80: "5.15a", 81: "5.15b",
82: "5.15c", 83: "5.15d",
]
return ydsMapping[numeric] ?? "\(numeric)"
case .custom:
return "\(numeric)"
}
}
#Preview {
AnalyticsView()
.environmentObject(ClimbingDataManager.preview)
}

View File

@@ -0,0 +1,430 @@
//
// GymDetailView.swift
// OpenClimb
//
// Created by OpenClimb on 2025-01-17.
//
import SwiftUI
struct GymDetailView: View {
let gymId: UUID
@EnvironmentObject var dataManager: ClimbingDataManager
@Environment(\.dismiss) private var dismiss
@State private var showingDeleteAlert = false
private var gym: Gym? {
dataManager.gym(withId: gymId)
}
private var problems: [Problem] {
dataManager.problems(forGym: gymId)
}
private var sessions: [ClimbSession] {
dataManager.sessions(forGym: gymId)
}
private var gymAttempts: [Attempt] {
let problemIds = Set(problems.map { $0.id })
return dataManager.attempts.filter { problemIds.contains($0.problemId) }
}
private var gymStats: GymStats {
calculateGymStats()
}
var body: some View {
ScrollView {
LazyVStack(spacing: 20) {
if let gym = gym {
GymHeaderCard(gym: gym)
GymStatsCard(stats: gymStats)
if !problems.isEmpty {
RecentProblemsSection(problems: problems.prefix(5))
}
if !sessions.isEmpty {
RecentSessionsSection(sessions: sessions.prefix(3))
}
if problems.isEmpty && sessions.isEmpty {
EmptyGymStateView()
}
} else {
Text("Gym not found")
.foregroundColor(.secondary)
}
}
.padding()
}
.navigationTitle(gym?.name ?? "Gym Details")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItemGroup(placement: .navigationBarTrailing) {
if gym != nil {
Menu {
Button("Edit Gym") {
// Navigate to edit view
}
Button(role: .destructive) {
showingDeleteAlert = true
} label: {
Label("Delete Gym", systemImage: "trash")
}
} label: {
Image(systemName: "ellipsis.circle")
}
}
}
}
.alert("Delete Gym", isPresented: $showingDeleteAlert) {
Button("Cancel", role: .cancel) {}
Button("Delete", role: .destructive) {
if let gym = gym {
dataManager.deleteGym(gym)
dismiss()
}
}
} message: {
Text(
"Are you sure you want to delete this gym? This will also delete all problems and sessions associated with this gym."
)
}
}
private func calculateGymStats() -> GymStats {
let uniqueProblemsClimbed = Set(gymAttempts.map { $0.problemId }).count
let totalSessions = sessions.count
let activeSessions = sessions.count { $0.status == .active }
return GymStats(
totalProblems: problems.count,
totalSessions: totalSessions,
totalAttempts: gymAttempts.count,
uniqueProblemsClimbed: uniqueProblemsClimbed,
activeSessions: activeSessions
)
}
}
struct GymHeaderCard: View {
let gym: Gym
var body: some View {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 8) {
Text(gym.name)
.font(.title)
.fontWeight(.bold)
if let location = gym.location, !location.isEmpty {
Text(location)
.font(.subheadline)
.foregroundColor(.secondary)
}
if let notes = gym.notes, !notes.isEmpty {
Text(notes)
.font(.body)
.padding(.top, 4)
}
}
// Supported Climb Types
if !gym.supportedClimbTypes.isEmpty {
VStack(alignment: .leading, spacing: 8) {
Text("Climb Types")
.font(.headline)
.fontWeight(.semibold)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(gym.supportedClimbTypes, id: \.self) { climbType in
Text(climbType.displayName)
.font(.caption)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(.blue.opacity(0.1))
)
.foregroundColor(.blue)
}
}
.padding(.horizontal, 1)
}
}
}
// Difficulty Systems
if !gym.difficultySystems.isEmpty {
VStack(alignment: .leading, spacing: 8) {
Text("Difficulty Systems")
.font(.headline)
.fontWeight(.semibold)
Text(gym.difficultySystems.map { $0.displayName }.joined(separator: ", "))
.font(.subheadline)
.foregroundColor(.secondary)
}
}
}
.padding()
.background(
RoundedRectangle(cornerRadius: 16)
.fill(.regularMaterial)
)
}
}
struct GymStatsCard: View {
let stats: GymStats
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Statistics")
.font(.title2)
.fontWeight(.bold)
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 2), spacing: 16) {
StatItem(label: "Problems", value: "\(stats.totalProblems)")
StatItem(label: "Sessions", value: "\(stats.totalSessions)")
StatItem(label: "Total Attempts", value: "\(stats.totalAttempts)")
StatItem(label: "Problems Climbed", value: "\(stats.uniqueProblemsClimbed)")
}
if stats.activeSessions > 0 {
HStack {
StatItem(label: "Active Sessions", value: "\(stats.activeSessions)")
Spacer()
}
}
}
.padding()
.background(
RoundedRectangle(cornerRadius: 16)
.fill(.regularMaterial)
)
}
}
struct RecentProblemsSection: View {
let problems: any Sequence<Problem>
@EnvironmentObject var dataManager: ClimbingDataManager
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text(
"Problems (\(dataManager.problems(forGym: Array(problems).first?.gymId ?? UUID()).count))"
)
.font(.title2)
.fontWeight(.bold)
LazyVStack(spacing: 12) {
ForEach(Array(problems), id: \.id) { problem in
NavigationLink(destination: ProblemDetailView(problemId: problem.id)) {
ProblemRowCard(problem: problem)
}
.buttonStyle(.plain)
}
}
if dataManager.problems(forGym: Array(problems).first?.gymId ?? UUID()).count > 5 {
Text(
"... and \(dataManager.problems(forGym: Array(problems).first?.gymId ?? UUID()).count - 5) more problems"
)
.font(.caption)
.foregroundColor(.secondary)
}
}
.padding()
.background(
RoundedRectangle(cornerRadius: 16)
.fill(.regularMaterial)
)
}
}
struct RecentSessionsSection: View {
let sessions: any Sequence<ClimbSession>
@EnvironmentObject var dataManager: ClimbingDataManager
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text(
"Recent Sessions (\(dataManager.sessions(forGym: Array(sessions).first?.gymId ?? UUID()).count))"
)
.font(.title2)
.fontWeight(.bold)
LazyVStack(spacing: 12) {
ForEach(Array(sessions), id: \.id) { session in
NavigationLink(destination: SessionDetailView(sessionId: session.id)) {
SessionRowCard(session: session)
}
.buttonStyle(.plain)
}
}
if dataManager.sessions(forGym: Array(sessions).first?.gymId ?? UUID()).count > 3 {
Text(
"... and \(dataManager.sessions(forGym: Array(sessions).first?.gymId ?? UUID()).count - 3) more sessions"
)
.font(.caption)
.foregroundColor(.secondary)
}
}
.padding()
.background(
RoundedRectangle(cornerRadius: 16)
.fill(.regularMaterial)
)
}
}
struct ProblemRowCard: View {
let problem: Problem
@EnvironmentObject var dataManager: ClimbingDataManager
private var problemAttempts: [Attempt] {
dataManager.attempts(forProblem: problem.id)
}
private var isCompleted: Bool {
problemAttempts.contains { $0.result.isSuccessful }
}
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(problem.name ?? "Unnamed Problem")
.font(.headline)
.fontWeight(.semibold)
.foregroundColor(.primary)
Text(
"\(problem.difficulty.grade)\(problem.climbType.displayName)\(problemAttempts.count) attempts"
)
.font(.subheadline)
.foregroundColor(.secondary)
}
Spacer()
if isCompleted {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
}
}
.padding()
.background(
RoundedRectangle(cornerRadius: 12)
.fill(.ultraThinMaterial)
.stroke(.quaternary, lineWidth: 1)
)
}
}
struct SessionRowCard: View {
let session: ClimbSession
@EnvironmentObject var dataManager: ClimbingDataManager
private var sessionAttempts: [Attempt] {
dataManager.attempts(forSession: session.id)
}
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(session.status == .active ? "Active Session" : "Session")
.font(.headline)
.fontWeight(.semibold)
.foregroundColor(.primary)
if session.status == .active {
Text("ACTIVE")
.font(.caption)
.fontWeight(.medium)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(
RoundedRectangle(cornerRadius: 4)
.fill(.green.opacity(0.2))
)
.foregroundColor(.green)
}
}
Text("\(formatDate(session.date))\(sessionAttempts.count) attempts")
.font(.subheadline)
.foregroundColor(.secondary)
}
Spacer()
if let duration = session.duration {
Text("\(duration)min")
.font(.caption)
.foregroundColor(.secondary)
}
}
.padding()
.background(
RoundedRectangle(cornerRadius: 12)
.fill(.ultraThinMaterial)
.stroke(.quaternary, lineWidth: 1)
)
}
private func formatDate(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.dateStyle = .medium
return formatter.string(from: date)
}
}
struct EmptyGymStateView: View {
var body: some View {
VStack(spacing: 20) {
Image(systemName: "figure.climbing")
.font(.system(size: 60))
.foregroundColor(.secondary)
VStack(spacing: 8) {
Text("No activity yet")
.font(.title2)
.fontWeight(.bold)
Text("Start a session or add problems to see them here")
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
}
.padding(40)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(.regularMaterial)
)
}
}
struct GymStats {
let totalProblems: Int
let totalSessions: Int
let totalAttempts: Int
let uniqueProblemsClimbed: Int
let activeSessions: Int
}
#Preview {
NavigationView {
GymDetailView(gymId: UUID())
.environmentObject(ClimbingDataManager.preview)
}
}

View File

@@ -0,0 +1,476 @@
//
// ProblemDetailView.swift
// OpenClimb
//
// Created by OpenClimb on 2025-01-17.
//
import SwiftUI
struct ProblemDetailView: View {
let problemId: UUID
@EnvironmentObject var dataManager: ClimbingDataManager
@Environment(\.dismiss) private var dismiss
@State private var showingDeleteAlert = false
@State private var showingImageViewer = false
@State private var selectedImageIndex = 0
@State private var showingEditProblem = false
private var problem: Problem? {
dataManager.problem(withId: problemId)
}
private var gym: Gym? {
guard let problem = problem else { return nil }
return dataManager.gym(withId: problem.gymId)
}
private var attempts: [Attempt] {
dataManager.attempts(forProblem: problemId)
}
private var successfulAttempts: [Attempt] {
attempts.filter { $0.result.isSuccessful }
}
private var attemptsWithSessions: [(Attempt, ClimbSession)] {
attempts.compactMap { attempt in
guard let session = dataManager.session(withId: attempt.sessionId) else { return nil }
return (attempt, session)
}.sorted { $0.1.date > $1.1.date }
}
var body: some View {
ScrollView {
LazyVStack(spacing: 20) {
if let problem = problem, let gym = gym {
ProblemHeaderCard(problem: problem, gym: gym)
ProgressSummaryCard(
totalAttempts: attempts.count,
successfulAttempts: successfulAttempts.count,
firstSuccess: firstSuccessInfo
)
if !problem.imagePaths.isEmpty {
PhotosSection(imagePaths: problem.imagePaths)
}
AttemptHistorySection(attemptsWithSessions: attemptsWithSessions)
} else {
Text("Problem not found")
.foregroundColor(.secondary)
}
}
.padding()
}
.navigationTitle("Problem Details")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItemGroup(placement: .navigationBarTrailing) {
if problem != nil {
Menu {
Button("Edit Problem") {
showingEditProblem = true
}
Button(role: .destructive) {
showingDeleteAlert = true
} label: {
Label("Delete Problem", systemImage: "trash")
}
} label: {
Image(systemName: "ellipsis.circle")
}
}
}
}
.alert("Delete Problem", isPresented: $showingDeleteAlert) {
Button("Cancel", role: .cancel) {}
Button("Delete", role: .destructive) {
if let problem = problem {
dataManager.deleteProblem(problem)
dismiss()
}
}
} message: {
Text(
"Are you sure you want to delete this problem? This will also delete all attempts associated with this problem."
)
}
.sheet(isPresented: $showingEditProblem) {
if let problem = problem {
AddEditProblemView(problemId: problem.id)
}
}
.sheet(isPresented: $showingImageViewer) {
if let problem = problem, !problem.imagePaths.isEmpty {
ImageViewerView(
imagePaths: problem.imagePaths,
initialIndex: selectedImageIndex
)
}
}
}
private var firstSuccessInfo: (date: Date, result: AttemptResult)? {
guard
let firstSuccess = successfulAttempts.min(by: { attempt1, attempt2 in
let session1 = dataManager.session(withId: attempt1.sessionId)
let session2 = dataManager.session(withId: attempt2.sessionId)
return session1?.date ?? Date() < session2?.date ?? Date()
})
else { return nil }
let session = dataManager.session(withId: firstSuccess.sessionId)
return (date: session?.date ?? Date(), result: firstSuccess.result)
}
}
struct ProblemHeaderCard: View {
let problem: Problem
let gym: Gym
var body: some View {
VStack(alignment: .leading, spacing: 16) {
HStack {
VStack(alignment: .leading, spacing: 8) {
Text(problem.name ?? "Unnamed Problem")
.font(.title)
.fontWeight(.bold)
Text(gym.name)
.font(.title2)
.foregroundColor(.secondary)
if let location = problem.location {
Text(location)
.font(.subheadline)
.foregroundColor(.secondary)
}
}
Spacer()
VStack(alignment: .trailing, spacing: 8) {
Text(problem.difficulty.grade)
.font(.title)
.fontWeight(.bold)
.foregroundColor(.blue)
Text(problem.climbType.displayName)
.font(.subheadline)
.foregroundColor(.secondary)
Text(problem.difficulty.system.displayName)
.font(.caption)
.foregroundColor(.secondary)
}
}
if let description = problem.description, !description.isEmpty {
Text(description)
.font(.body)
}
if let setter = problem.setter, !setter.isEmpty {
Text("Set by: \(setter)")
.font(.subheadline)
.foregroundColor(.secondary)
}
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, 1)
}
}
if let notes = problem.notes, !notes.isEmpty {
Text(notes)
.font(.caption)
.foregroundColor(.secondary)
.padding(.top, 4)
}
if !problem.isActive {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.orange)
Text("Inactive Problem")
.font(.subheadline)
.fontWeight(.medium)
.foregroundColor(.orange)
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(.orange.opacity(0.1))
)
}
}
.padding()
.background(
RoundedRectangle(cornerRadius: 16)
.fill(.regularMaterial)
)
}
}
struct ProgressSummaryCard: View {
let totalAttempts: Int
let successfulAttempts: Int
let firstSuccess: (date: Date, result: AttemptResult)?
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Progress Summary")
.font(.title2)
.fontWeight(.bold)
if totalAttempts == 0 {
Text("No attempts recorded yet")
.foregroundColor(.secondary)
.frame(maxWidth: .infinity, alignment: .center)
.padding()
} else {
HStack {
StatItem(label: "Total Attempts", value: "\(totalAttempts)")
StatItem(label: "Successful", value: "\(successfulAttempts)")
}
if let firstSuccess = firstSuccess {
VStack(alignment: .leading, spacing: 4) {
Text("First Success")
.font(.subheadline)
.fontWeight(.medium)
Text(
"\(formatDate(firstSuccess.date)) (\(firstSuccess.result.displayName))"
)
.font(.subheadline)
.foregroundColor(.blue)
}
.padding(.top, 8)
}
}
}
.padding()
.background(
RoundedRectangle(cornerRadius: 16)
.fill(.regularMaterial)
)
}
private func formatDate(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.dateStyle = .medium
return formatter.string(from: date)
}
}
struct PhotosSection: View {
let imagePaths: [String]
@State private var showingImageViewer = false
@State private var selectedImageIndex = 0
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Photos")
.font(.title2)
.fontWeight(.bold)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) {
ForEach(imagePaths.indices, id: \.self) { index in
AsyncImage(url: URL(fileURLWithPath: imagePaths[index])) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
} placeholder: {
RoundedRectangle(cornerRadius: 12)
.fill(.gray.opacity(0.3))
}
.frame(width: 120, height: 120)
.clipped()
.cornerRadius(12)
.onTapGesture {
selectedImageIndex = index
showingImageViewer = true
}
}
}
.padding(.horizontal, 1)
}
}
.padding()
.background(
RoundedRectangle(cornerRadius: 16)
.fill(.regularMaterial)
)
.sheet(isPresented: $showingImageViewer) {
ImageViewerView(
imagePaths: imagePaths,
initialIndex: selectedImageIndex
)
}
}
}
struct AttemptHistorySection: View {
let attemptsWithSessions: [(Attempt, ClimbSession)]
@EnvironmentObject var dataManager: ClimbingDataManager
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Attempt History (\(attemptsWithSessions.count))")
.font(.title2)
.fontWeight(.bold)
if attemptsWithSessions.isEmpty {
VStack(spacing: 12) {
Image(systemName: "hand.raised.slash")
.font(.title)
.foregroundColor(.secondary)
Text("No attempts yet")
.font(.headline)
.foregroundColor(.secondary)
Text("Start a session and track your attempts on this problem!")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.padding()
.background(
RoundedRectangle(cornerRadius: 16)
.fill(.regularMaterial)
)
} else {
LazyVStack(spacing: 12) {
ForEach(attemptsWithSessions.indices, id: \.self) { index in
let (attempt, session) = attemptsWithSessions[index]
AttemptHistoryCard(attempt: attempt, session: session)
}
}
}
}
}
}
struct AttemptHistoryCard: View {
let attempt: Attempt
let session: ClimbSession
@EnvironmentObject var dataManager: ClimbingDataManager
private var gym: Gym? {
dataManager.gym(withId: session.gymId)
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(formatDate(session.date))
.font(.headline)
.fontWeight(.semibold)
if let gym = gym {
Text(gym.name)
.font(.subheadline)
.foregroundColor(.secondary)
}
}
Spacer()
AttemptResultBadge(result: attempt.result)
}
if let notes = attempt.notes, !notes.isEmpty {
Text(notes)
.font(.subheadline)
.foregroundColor(.secondary)
}
if let highestHold = attempt.highestHold, !highestHold.isEmpty {
Text("Highest hold: \(highestHold)")
.font(.caption)
.foregroundColor(.secondary)
}
}
.padding()
.background(
RoundedRectangle(cornerRadius: 12)
.fill(.ultraThinMaterial)
)
}
private func formatDate(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.dateStyle = .medium
return formatter.string(from: date)
}
}
struct ImageViewerView: View {
let imagePaths: [String]
let initialIndex: Int
@Environment(\.dismiss) private var dismiss
@State private var currentIndex: Int
init(imagePaths: [String], initialIndex: Int) {
self.imagePaths = imagePaths
self.initialIndex = initialIndex
self._currentIndex = State(initialValue: initialIndex)
}
var body: some View {
NavigationView {
TabView(selection: $currentIndex) {
ForEach(imagePaths.indices, id: \.self) { index in
AsyncImage(url: URL(fileURLWithPath: imagePaths[index])) { image in
image
.resizable()
.aspectRatio(contentMode: .fit)
} placeholder: {
ProgressView()
}
.tag(index)
}
}
.tabViewStyle(.page(indexDisplayMode: .always))
.navigationTitle("Photo \(currentIndex + 1) of \(imagePaths.count)")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") {
dismiss()
}
}
}
}
}
}
#Preview {
NavigationView {
ProblemDetailView(problemId: UUID())
.environmentObject(ClimbingDataManager.preview)
}
}

View File

@@ -0,0 +1,443 @@
//
// SessionDetailView.swift
// OpenClimb
//
// Created by OpenClimb on 2025-01-17.
//
import SwiftUI
struct SessionDetailView: View {
let sessionId: UUID
@EnvironmentObject var dataManager: ClimbingDataManager
@Environment(\.dismiss) private var dismiss
@State private var showingDeleteAlert = false
@State private var showingAddAttempt = false
@State private var editingAttempt: Attempt?
private var session: ClimbSession? {
dataManager.session(withId: sessionId)
}
private var gym: Gym? {
guard let session = session else { return nil }
return dataManager.gym(withId: session.gymId)
}
private var attempts: [Attempt] {
dataManager.attempts(forSession: sessionId)
}
private var attemptsWithProblems: [(Attempt, Problem)] {
attempts.compactMap { attempt in
guard let problem = dataManager.problem(withId: attempt.problemId) else { return nil }
return (attempt, problem)
}.sorted { $0.0.timestamp < $1.0.timestamp }
}
private var sessionStats: SessionStats {
calculateSessionStats()
}
var body: some View {
ScrollView {
LazyVStack(spacing: 20) {
if let session = session, let gym = gym {
SessionHeaderCard(session: session, gym: gym, stats: sessionStats)
SessionStatsCard(stats: sessionStats)
AttemptsSection(attemptsWithProblems: attemptsWithProblems)
} else {
Text("Session not found")
.foregroundColor(.secondary)
}
}
.padding()
}
.navigationTitle("Session Details")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItemGroup(placement: .navigationBarTrailing) {
if let session = session {
if session.status == .active {
Button("End Session") {
dataManager.endSession(session.id)
dismiss()
}
.foregroundColor(.orange)
} else {
Menu {
Button(role: .destructive) {
showingDeleteAlert = true
} label: {
Label("Delete Session", systemImage: "trash")
}
} label: {
Image(systemName: "ellipsis.circle")
}
}
}
}
}
.overlay(alignment: .bottomTrailing) {
if session?.status == .active {
Button(action: { showingAddAttempt = true }) {
Image(systemName: "plus")
.font(.title2)
.foregroundColor(.white)
.frame(width: 56, height: 56)
.background(Circle().fill(.blue))
.shadow(radius: 4)
}
.padding()
}
}
.alert("Delete Session", isPresented: $showingDeleteAlert) {
Button("Cancel", role: .cancel) {}
Button("Delete", role: .destructive) {
if let session = session {
dataManager.deleteSession(session)
dismiss()
}
}
} message: {
Text(
"Are you sure you want to delete this session? This will also delete all attempts associated with this session."
)
}
.sheet(isPresented: $showingAddAttempt) {
if let session = session, let gym = gym {
AddAttemptView(session: session, gym: gym)
}
}
.sheet(item: $editingAttempt) { attempt in
EditAttemptView(attempt: attempt)
}
}
private func calculateSessionStats() -> SessionStats {
let successfulAttempts = attempts.filter { $0.result.isSuccessful }
let uniqueProblems = Set(attempts.map { $0.problemId })
let completedProblems = Set(successfulAttempts.map { $0.problemId })
let attemptedProblems = uniqueProblems.compactMap { dataManager.problem(withId: $0) }
let boulderProblems = attemptedProblems.filter { $0.climbType == .boulder }
let ropeProblems = attemptedProblems.filter { $0.climbType == .rope }
let boulderRange = gradeRange(for: boulderProblems)
let ropeRange = gradeRange(for: ropeProblems)
return SessionStats(
totalAttempts: attempts.count,
successfulAttempts: successfulAttempts.count,
uniqueProblemsAttempted: uniqueProblems.count,
uniqueProblemsCompleted: completedProblems.count,
boulderRange: boulderRange,
ropeRange: ropeRange
)
}
private func gradeRange(for problems: [Problem]) -> String? {
guard !problems.isEmpty else { return nil }
let grades = problems.map { $0.difficulty }.sorted()
if grades.count == 1 {
return grades.first?.grade
} else {
return "\(grades.first?.grade ?? "") - \(grades.last?.grade ?? "")"
}
}
}
struct SessionHeaderCard: View {
let session: ClimbSession
let gym: Gym
let stats: SessionStats
var body: some View {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 8) {
Text(gym.name)
.font(.title)
.fontWeight(.bold)
Text(formatDate(session.date))
.font(.title2)
.foregroundColor(.blue)
if let duration = session.duration {
Text("Duration: \(duration) minutes")
.font(.subheadline)
.foregroundColor(.secondary)
}
if let notes = session.notes, !notes.isEmpty {
Text(notes)
.font(.body)
.padding(.top, 4)
}
}
// Status indicator
HStack {
Image(systemName: session.status == .active ? "play.fill" : "checkmark.circle.fill")
.foregroundColor(session.status == .active ? .green : .blue)
Text(session.status == .active ? "In Progress" : "Completed")
.font(.subheadline)
.fontWeight(.medium)
.foregroundColor(session.status == .active ? .green : .blue)
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(
RoundedRectangle(cornerRadius: 8)
.fill((session.status == .active ? Color.green : Color.blue).opacity(0.1))
)
}
.padding()
.background(
RoundedRectangle(cornerRadius: 16)
.fill(.regularMaterial)
)
}
private func formatDate(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.dateStyle = .full
return formatter.string(from: date)
}
}
struct SessionStatsCard: View {
let stats: SessionStats
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Session Stats")
.font(.title2)
.fontWeight(.bold)
if stats.totalAttempts == 0 {
Text("No attempts recorded yet")
.foregroundColor(.secondary)
.frame(maxWidth: .infinity, alignment: .center)
.padding()
} else {
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 2), spacing: 16) {
StatItem(label: "Total Attempts", value: "\(stats.totalAttempts)")
StatItem(label: "Problems", value: "\(stats.uniqueProblemsAttempted)")
StatItem(label: "Successful", value: "\(stats.successfulAttempts)")
StatItem(label: "Completed", value: "\(stats.uniqueProblemsCompleted)")
}
// Grade ranges
VStack(alignment: .leading, spacing: 8) {
if let boulderRange = stats.boulderRange, let ropeRange = stats.ropeRange {
HStack {
StatItem(label: "Boulder Range", value: boulderRange)
StatItem(label: "Rope Range", value: ropeRange)
}
} else if let singleRange = stats.boulderRange ?? stats.ropeRange {
StatItem(label: "Grade Range", value: singleRange)
.frame(maxWidth: .infinity, alignment: .center)
}
}
}
}
.padding()
.background(
RoundedRectangle(cornerRadius: 16)
.fill(.regularMaterial)
)
}
}
struct StatItem: View {
let label: String
let value: String
var body: some View {
VStack(spacing: 4) {
Text(value)
.font(.title2)
.fontWeight(.bold)
.foregroundColor(.blue)
Text(label)
.font(.caption)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity)
}
}
struct AttemptsSection: View {
let attemptsWithProblems: [(Attempt, Problem)]
@EnvironmentObject var dataManager: ClimbingDataManager
@State private var editingAttempt: Attempt?
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Attempts (\(attemptsWithProblems.count))")
.font(.title2)
.fontWeight(.bold)
if attemptsWithProblems.isEmpty {
VStack(spacing: 12) {
Image(systemName: "hand.raised.slash")
.font(.title)
.foregroundColor(.secondary)
Text("No attempts yet")
.font(.headline)
.foregroundColor(.secondary)
Text("Start attempting problems to see your progress!")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.padding()
.background(
RoundedRectangle(cornerRadius: 16)
.fill(.regularMaterial)
)
} else {
LazyVStack(spacing: 12) {
ForEach(attemptsWithProblems.indices, id: \.self) { index in
let (attempt, problem) = attemptsWithProblems[index]
AttemptCard(attempt: attempt, problem: problem)
.onTapGesture {
editingAttempt = attempt
}
}
}
}
}
.sheet(item: $editingAttempt) { attempt in
EditAttemptView(attempt: attempt)
}
}
}
struct AttemptCard: View {
let attempt: Attempt
let problem: Problem
@EnvironmentObject var dataManager: ClimbingDataManager
@State private var showingDeleteAlert = false
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(problem.name ?? "Unknown Problem")
.font(.headline)
.fontWeight(.semibold)
Text("\(problem.difficulty.system.displayName): \(problem.difficulty.grade)")
.font(.subheadline)
.foregroundColor(.blue)
if let location = problem.location {
Text(location)
.font(.caption)
.foregroundColor(.secondary)
}
}
Spacer()
VStack(alignment: .trailing, spacing: 8) {
AttemptResultBadge(result: attempt.result)
HStack(spacing: 12) {
Button(action: { showingDeleteAlert = true }) {
Image(systemName: "trash")
.font(.caption)
.foregroundColor(.red)
}
.buttonStyle(.plain)
}
}
}
if let notes = attempt.notes, !notes.isEmpty {
Text(notes)
.font(.subheadline)
.foregroundColor(.secondary)
}
if let highestHold = attempt.highestHold, !highestHold.isEmpty {
Text("Highest hold: \(highestHold)")
.font(.caption)
.foregroundColor(.secondary)
}
}
.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?")
}
}
}
struct AttemptResultBadge: View {
let result: AttemptResult
private var badgeColor: Color {
switch result {
case .success, .flash:
return .green
case .fall:
return .orange
case .noProgress:
return .red
}
}
var body: some View {
Text(result.displayName)
.font(.caption)
.fontWeight(.medium)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(badgeColor.opacity(0.1))
)
.foregroundColor(badgeColor)
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(badgeColor.opacity(0.3), lineWidth: 1)
)
}
}
struct SessionStats {
let totalAttempts: Int
let successfulAttempts: Int
let uniqueProblemsAttempted: Int
let uniqueProblemsCompleted: Int
let boulderRange: String?
let ropeRange: String?
}
#Preview {
NavigationView {
SessionDetailView(sessionId: UUID())
.environmentObject(ClimbingDataManager.preview)
}
}

View File

@@ -0,0 +1,171 @@
//
// GymsView.swift
// OpenClimb
//
// Created by OpenClimb on 2025-01-17.
//
import SwiftUI
struct GymsView: View {
@EnvironmentObject var dataManager: ClimbingDataManager
@State private var showingAddGym = false
var body: some View {
NavigationView {
VStack {
if dataManager.gyms.isEmpty {
EmptyGymsView()
} else {
GymsList()
}
}
.navigationTitle("Gyms")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Add") {
showingAddGym = true
}
}
}
.sheet(isPresented: $showingAddGym) {
AddEditGymView()
}
}
}
}
struct GymsList: View {
@EnvironmentObject var dataManager: ClimbingDataManager
var body: some View {
List(dataManager.gyms, id: \.id) { gym in
NavigationLink(destination: GymDetailView(gymId: gym.id)) {
GymRow(gym: gym)
}
}
.listStyle(.plain)
}
}
struct GymRow: View {
let gym: Gym
@EnvironmentObject var dataManager: ClimbingDataManager
private var problemCount: Int {
dataManager.problems(forGym: gym.id).count
}
private var sessionCount: Int {
dataManager.sessions(forGym: gym.id).count
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
// Header
VStack(alignment: .leading, spacing: 4) {
Text(gym.name)
.font(.headline)
.fontWeight(.bold)
if let location = gym.location, !location.isEmpty {
Text(location)
.font(.subheadline)
.foregroundColor(.secondary)
}
}
// Climb Types
if !gym.supportedClimbTypes.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(gym.supportedClimbTypes, id: \.self) { climbType in
Text(climbType.displayName)
.font(.caption)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(.blue.opacity(0.1))
)
.foregroundColor(.blue)
}
}
}
}
// Difficulty Systems
if !gym.difficultySystems.isEmpty {
Text(
"Systems: \(gym.difficultySystems.map { $0.displayName }.joined(separator: ", "))"
)
.font(.caption)
.foregroundColor(.secondary)
}
// Stats
HStack {
Label("\(problemCount)", systemImage: "star.fill")
.font(.caption)
.foregroundColor(.orange)
Label("\(sessionCount)", systemImage: "play.fill")
.font(.caption)
.foregroundColor(.green)
Spacer()
}
// Notes preview
if let notes = gym.notes, !notes.isEmpty {
Text(notes)
.font(.caption)
.foregroundColor(.secondary)
.lineLimit(2)
}
}
.padding(.vertical, 4)
}
}
struct EmptyGymsView: View {
@State private var showingAddGym = false
var body: some View {
VStack(spacing: 20) {
Spacer()
Image(systemName: "location.fill")
.font(.system(size: 60))
.foregroundColor(.secondary)
VStack(spacing: 8) {
Text("No Gyms Added")
.font(.title2)
.fontWeight(.bold)
Text("Add your favorite climbing gyms to start tracking your progress!")
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
}
Button("Add Gym") {
showingAddGym = true
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
Spacer()
}
.sheet(isPresented: $showingAddGym) {
AddEditGymView()
}
}
}
#Preview {
GymsView()
.environmentObject(ClimbingDataManager.preview)
}

View File

@@ -0,0 +1,362 @@
//
// ProblemsView.swift
// OpenClimb
//
// Created by OpenClimb on 2025-01-17.
//
import SwiftUI
struct ProblemsView: View {
@EnvironmentObject var dataManager: ClimbingDataManager
@State private var showingAddProblem = false
@State private var selectedClimbType: ClimbType?
@State private var selectedGym: Gym?
@State private var searchText = ""
private var filteredProblems: [Problem] {
var filtered = dataManager.problems
// Apply search filter
if !searchText.isEmpty {
filtered = filtered.filter { problem in
(problem.name?.localizedCaseInsensitiveContains(searchText) ?? false)
|| (problem.description?.localizedCaseInsensitiveContains(searchText) ?? false)
|| (problem.location?.localizedCaseInsensitiveContains(searchText) ?? false)
|| (problem.setter?.localizedCaseInsensitiveContains(searchText) ?? false)
|| problem.tags.contains { $0.localizedCaseInsensitiveContains(searchText) }
}
}
// Apply climb type filter
if let climbType = selectedClimbType {
filtered = filtered.filter { $0.climbType == climbType }
}
// Apply gym filter
if let gym = selectedGym {
filtered = filtered.filter { $0.gymId == gym.id }
}
return filtered.sorted { $0.updatedAt > $1.updatedAt }
}
var body: some View {
NavigationView {
VStack(spacing: 0) {
if !dataManager.problems.isEmpty {
FilterSection()
.padding()
.background(.regularMaterial)
}
if filteredProblems.isEmpty {
EmptyProblemsView(
isEmpty: dataManager.problems.isEmpty,
isFiltered: !dataManager.problems.isEmpty
)
} else {
ProblemsList(problems: filteredProblems)
}
}
.navigationTitle("Problems")
.searchable(text: $searchText, prompt: "Search problems...")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
if !dataManager.gyms.isEmpty {
Button("Add") {
showingAddProblem = true
}
}
}
}
.sheet(isPresented: $showingAddProblem) {
AddEditProblemView()
}
}
}
}
struct FilterSection: View {
@EnvironmentObject var dataManager: ClimbingDataManager
@State private var selectedClimbType: ClimbType?
@State private var selectedGym: Gym?
var body: some View {
VStack(spacing: 12) {
// Climb Type Filter
VStack(alignment: .leading, spacing: 8) {
Text("Climb Type")
.font(.subheadline)
.fontWeight(.medium)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
FilterChip(
title: "All Types",
isSelected: selectedClimbType == nil
) {
selectedClimbType = nil
}
ForEach(ClimbType.allCases, id: \.self) { climbType in
FilterChip(
title: climbType.displayName,
isSelected: selectedClimbType == climbType
) {
selectedClimbType = climbType
}
}
}
.padding(.horizontal, 1)
}
}
// Gym Filter
VStack(alignment: .leading, spacing: 8) {
Text("Gym")
.font(.subheadline)
.fontWeight(.medium)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
FilterChip(
title: "All Gyms",
isSelected: selectedGym == nil
) {
selectedGym = nil
}
ForEach(dataManager.gyms, id: \.id) { gym in
FilterChip(
title: gym.name,
isSelected: selectedGym?.id == gym.id
) {
selectedGym = gym
}
}
}
.padding(.horizontal, 1)
}
}
// Results count
if selectedClimbType != nil || selectedGym != nil {
HStack {
Text(
"Showing \(filteredProblems.count) of \(dataManager.problems.count) problems"
)
.font(.caption)
.foregroundColor(.secondary)
Spacer()
}
}
}
}
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 {
let title: String
let isSelected: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
Text(title)
.font(.caption)
.fontWeight(.medium)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(isSelected ? .blue : .clear)
.stroke(.blue, lineWidth: 1)
)
.foregroundColor(isSelected ? .white : .blue)
}
.buttonStyle(.plain)
}
}
struct ProblemsList: View {
let problems: [Problem]
@EnvironmentObject var dataManager: ClimbingDataManager
var body: some View {
List(problems) { problem in
NavigationLink(destination: ProblemDetailView(problemId: problem.id)) {
ProblemRow(problem: problem)
}
}
.listStyle(.plain)
}
}
struct ProblemRow: View {
let problem: Problem
@EnvironmentObject var dataManager: ClimbingDataManager
private var gym: Gym? {
dataManager.gym(withId: problem.gymId)
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(problem.name ?? "Unnamed Problem")
.font(.headline)
.fontWeight(.semibold)
Text(gym?.name ?? "Unknown Gym")
.font(.subheadline)
.foregroundColor(.secondary)
}
Spacer()
VStack(alignment: .trailing, spacing: 4) {
Text(problem.difficulty.grade)
.font(.title2)
.fontWeight(.bold)
.foregroundColor(.blue)
Text(problem.climbType.displayName)
.font(.caption)
.foregroundColor(.secondary)
}
}
if let location = problem.location {
Text("Location: \(location)")
.font(.caption)
.foregroundColor(.secondary)
}
if !problem.tags.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 6) {
ForEach(problem.tags.prefix(3), id: \.self) { tag in
Text(tag)
.font(.caption2)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(
RoundedRectangle(cornerRadius: 4)
.fill(.blue.opacity(0.1))
)
.foregroundColor(.blue)
}
}
}
}
if !problem.imagePaths.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(problem.imagePaths.prefix(3), id: \.self) { imagePath in
AsyncImage(url: URL(fileURLWithPath: imagePath)) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
} placeholder: {
RoundedRectangle(cornerRadius: 8)
.fill(.gray.opacity(0.3))
}
.frame(width: 60, height: 60)
.clipped()
.cornerRadius(8)
}
}
}
}
if !problem.isActive {
Text("Inactive")
.font(.caption)
.foregroundColor(.red)
.fontWeight(.medium)
}
}
.padding(.vertical, 4)
}
}
struct EmptyProblemsView: View {
let isEmpty: Bool
let isFiltered: Bool
@EnvironmentObject var dataManager: ClimbingDataManager
@State private var showingAddProblem = false
var body: some View {
VStack(spacing: 20) {
Spacer()
Image(systemName: "star.fill")
.font(.system(size: 60))
.foregroundColor(.secondary)
VStack(spacing: 8) {
Text(title)
.font(.title2)
.fontWeight(.bold)
Text(subtitle)
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
}
if isEmpty && !dataManager.gyms.isEmpty {
Button("Add Problem") {
showingAddProblem = true
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
}
Spacer()
}
.sheet(isPresented: $showingAddProblem) {
AddEditProblemView()
}
}
private var title: String {
if isEmpty {
return dataManager.gyms.isEmpty ? "No Gyms Available" : "No Problems Yet"
} else {
return "No Problems Match Filters"
}
}
private var subtitle: String {
if isEmpty {
return dataManager.gyms.isEmpty
? "Add a gym first to start tracking problems and routes!"
: "Start tracking your favorite problems and routes!"
} else {
return "Try adjusting your filters to see more problems."
}
}
}
#Preview {
ProblemsView()
.environmentObject(ClimbingDataManager.preview)
}

View File

@@ -0,0 +1,243 @@
//
// SessionsView.swift
// OpenClimb
//
// Created by OpenClimb on 2025-01-17.
//
import Combine
import SwiftUI
struct SessionsView: View {
@EnvironmentObject var dataManager: ClimbingDataManager
@State private var showingAddSession = false
var body: some View {
NavigationView {
VStack(spacing: 0) {
// Active session banner
if let activeSession = dataManager.activeSession,
let gym = dataManager.gym(withId: activeSession.gymId)
{
VStack(spacing: 8) {
ActiveSessionBanner(session: activeSession, gym: gym)
.padding(.horizontal)
}
.padding(.top, 8)
}
// Sessions list
if dataManager.sessions.isEmpty && dataManager.activeSession == nil {
EmptySessionsView()
} else {
SessionsList()
}
}
.navigationTitle("Sessions")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
if dataManager.gyms.isEmpty {
EmptyView()
} else if dataManager.activeSession == nil {
Button("Start Session") {
if dataManager.gyms.count == 1 {
dataManager.startSession(gymId: dataManager.gyms.first!.id)
} else {
showingAddSession = true
}
}
}
}
}
.sheet(isPresented: $showingAddSession) {
AddEditSessionView()
}
}
}
}
struct ActiveSessionBanner: View {
let session: ClimbSession
let gym: Gym
@EnvironmentObject var dataManager: ClimbingDataManager
@State private var currentTime = Date()
private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
var body: some View {
NavigationLink(destination: SessionDetailView(sessionId: session.id)) {
HStack {
VStack(alignment: .leading, spacing: 4) {
HStack {
Image(systemName: "play.fill")
.foregroundColor(.green)
.font(.caption)
Text("Active Session")
.font(.headline)
.fontWeight(.bold)
}
Text(gym.name)
.font(.subheadline)
.foregroundColor(.secondary)
if let startTime = session.startTime {
Text(formatDuration(from: startTime, to: currentTime))
.font(.caption)
.foregroundColor(.secondary)
}
}
Spacer()
Button("End") {
dataManager.endSession(session.id)
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
}
.padding()
.background(
RoundedRectangle(cornerRadius: 12)
.fill(.green.opacity(0.1))
.stroke(.green.opacity(0.3), lineWidth: 1)
)
}
.buttonStyle(.plain)
.onReceive(timer) { _ in
currentTime = 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 SessionsList: View {
@EnvironmentObject var dataManager: ClimbingDataManager
private var completedSessions: [ClimbSession] {
dataManager.sessions
.filter { $0.status == .completed }
.sorted { $0.date > $1.date }
}
var body: some View {
List(completedSessions) { session in
NavigationLink(destination: SessionDetailView(sessionId: session.id)) {
SessionRow(session: session)
}
}
.listStyle(.plain)
}
}
struct SessionRow: View {
let session: ClimbSession
@EnvironmentObject var dataManager: ClimbingDataManager
private var gym: Gym? {
dataManager.gym(withId: session.gymId)
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(gym?.name ?? "Unknown Gym")
.font(.headline)
.fontWeight(.semibold)
Spacer()
Text(formatDate(session.date))
.font(.caption)
.foregroundColor(.secondary)
}
if let duration = session.duration {
Text("Duration: \(duration) minutes")
.font(.subheadline)
.foregroundColor(.secondary)
}
if let notes = session.notes, !notes.isEmpty {
Text(notes)
.font(.caption)
.foregroundColor(.secondary)
.lineLimit(2)
}
}
.padding(.vertical, 4)
}
private func formatDate(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.dateStyle = .medium
return formatter.string(from: date)
}
}
struct EmptySessionsView: View {
@EnvironmentObject var dataManager: ClimbingDataManager
@State private var showingAddSession = false
var body: some View {
VStack(spacing: 20) {
Spacer()
Image(systemName: "figure.climbing")
.font(.system(size: 60))
.foregroundColor(.secondary)
VStack(spacing: 8) {
Text(dataManager.gyms.isEmpty ? "No Gyms Available" : "No Sessions Yet")
.font(.title2)
.fontWeight(.bold)
Text(
dataManager.gyms.isEmpty
? "Add a gym first to start tracking your climbing sessions!"
: "Start your first climbing session!"
)
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
}
if !dataManager.gyms.isEmpty {
Button("Start Session") {
if dataManager.gyms.count == 1 {
dataManager.startSession(gymId: dataManager.gyms.first!.id)
} else {
showingAddSession = true
}
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
}
Spacer()
}
.sheet(isPresented: $showingAddSession) {
AddEditSessionView()
}
}
}
#Preview {
SessionsView()
.environmentObject(ClimbingDataManager.preview)
}

View File

@@ -0,0 +1,441 @@
//
// SettingsView.swift
// OpenClimb
//
// Created by OpenClimb on 2025-01-17.
//
import SwiftUI
import UniformTypeIdentifiers
struct SettingsView: View {
@EnvironmentObject var dataManager: ClimbingDataManager
@State private var showingResetAlert = false
@State private var showingExportSheet = false
@State private var showingImportSheet = false
@State private var exportData: Data?
var body: some View {
NavigationView {
List {
DataManagementSection()
AppInfoSection()
}
.navigationTitle("Settings")
}
}
}
struct DataManagementSection: View {
@EnvironmentObject var dataManager: ClimbingDataManager
@State private var showingResetAlert = false
@State private var showingExportSheet = false
@State private var showingImportSheet = false
@State private var exportData: Data?
@State private var isExporting = false
var body: some View {
Section("Data Management") {
// Export Data
Button(action: {
exportDataAsync()
}) {
HStack {
if isExporting {
ProgressView()
.scaleEffect(0.8)
Text("Exporting...")
.foregroundColor(.secondary)
} else {
Image(systemName: "square.and.arrow.up")
.foregroundColor(.blue)
Text("Export Data")
}
Spacer()
}
}
.disabled(isExporting)
.foregroundColor(.primary)
// Import Data
Button(action: {
showingImportSheet = true
}) {
HStack {
Image(systemName: "square.and.arrow.down")
.foregroundColor(.green)
Text("Import Data")
Spacer()
}
}
.foregroundColor(.primary)
// Reset All Data
Button(action: {
showingResetAlert = true
}) {
HStack {
Image(systemName: "trash")
.foregroundColor(.red)
Text("Reset All Data")
Spacer()
}
}
.foregroundColor(.red)
}
.alert("Reset All Data", isPresented: $showingResetAlert) {
Button("Cancel", role: .cancel) {}
Button("Reset", role: .destructive) {
dataManager.resetAllData()
}
} message: {
Text(
"Are you sure you want to reset all data? This will permanently delete:\n\n• All gyms and their information\n• All problems and their images\n• All climbing sessions\n• All attempts and progress data\n\nThis action cannot be undone. Consider exporting your data first."
)
}
.sheet(isPresented: $showingExportSheet) {
if let data = exportData {
ExportDataView(data: data)
} else {
Text("No export data available")
}
}
.sheet(isPresented: $showingImportSheet) {
ImportDataView()
}
}
private func exportDataAsync() {
isExporting = true
Task {
let data = await withCheckedContinuation { continuation in
DispatchQueue.global(qos: .userInitiated).async {
let result = dataManager.exportData()
continuation.resume(returning: result)
}
}
await MainActor.run {
isExporting = false
if let data = data {
exportData = data
showingExportSheet = true
} else {
// Error message should already be set by dataManager
exportData = nil
}
}
}
}
}
struct AppInfoSection: View {
private var appVersion: String {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown"
}
private var buildNumber: String {
Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "Unknown"
}
var body: some View {
Section("App Information") {
HStack {
Image(systemName: "mountain.2.fill")
.foregroundColor(.blue)
VStack(alignment: .leading) {
Text("OpenClimb")
.font(.headline)
Text("Track your climbing progress")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
}
HStack {
Image(systemName: "info.circle")
.foregroundColor(.blue)
Text("Version")
Spacer()
Text("\(appVersion) (\(buildNumber))")
.foregroundColor(.secondary)
}
HStack {
Image(systemName: "person.fill")
.foregroundColor(.blue)
Text("Developer")
Spacer()
Text("OpenClimb Team")
.foregroundColor(.secondary)
}
}
}
}
struct ExportDataView: View {
let data: Data
@Environment(\.dismiss) private var dismiss
@State private var tempFileURL: URL?
var body: some View {
NavigationView {
VStack(spacing: 20) {
Image(systemName: "square.and.arrow.up")
.font(.system(size: 60))
.foregroundColor(.blue)
Text("Export Data")
.font(.title)
.fontWeight(.bold)
Text(
"Your climbing data has been prepared for export. Use the share button below to save or send your data."
)
.multilineTextAlignment(.center)
.padding(.horizontal)
if let fileURL = tempFileURL {
ShareLink(
item: fileURL,
preview: SharePreview(
"OpenClimb Data Export",
image: Image(systemName: "mountain.2.fill"))
) {
Label("Share Data", systemImage: "square.and.arrow.up")
.font(.headline)
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding()
.background(
RoundedRectangle(cornerRadius: 12)
.fill(.blue)
)
}
.padding(.horizontal)
.buttonStyle(.plain)
} else {
Button(action: {}) {
Label("Preparing Export...", systemImage: "hourglass")
.font(.headline)
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding()
.background(
RoundedRectangle(cornerRadius: 12)
.fill(.gray)
)
}
.disabled(true)
.padding(.horizontal)
}
Spacer()
}
.padding()
.navigationTitle("Export")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") {
dismiss()
}
}
}
.onAppear {
if tempFileURL == nil {
createTempFile()
}
}
.onDisappear {
// Delay cleanup to ensure sharing is complete
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
cleanupTempFile()
}
}
}
}
private func createTempFile() {
DispatchQueue.global(qos: .userInitiated).async {
do {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
let isoString = formatter.string(from: Date())
let timestamp = isoString.replacingOccurrences(of: ":", with: "-")
.replacingOccurrences(of: ".", with: "-")
let filename = "openclimb_export_\(timestamp).zip"
guard
let documentsURL = FileManager.default.urls(
for: .documentDirectory, in: .userDomainMask
).first
else {
print("Could not access Documents directory")
return
}
let fileURL = documentsURL.appendingPathComponent(filename)
// Write the ZIP data to the file
try data.write(to: fileURL)
DispatchQueue.main.async {
self.tempFileURL = fileURL
}
} catch {
print("Failed to create export file: \(error)")
}
}
}
private func cleanupTempFile() {
if let fileURL = tempFileURL {
// Clean up after a delay to ensure sharing is complete
DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) {
try? FileManager.default.removeItem(at: fileURL)
print("Cleaned up export file: \(fileURL.lastPathComponent)")
}
}
}
}
struct ImportDataView: View {
@EnvironmentObject var dataManager: ClimbingDataManager
@Environment(\.dismiss) private var dismiss
@State private var isImporting = false
@State private var importError: String?
@State private var showingDocumentPicker = false
var body: some View {
NavigationView {
VStack(spacing: 20) {
Image(systemName: "square.and.arrow.down")
.font(.system(size: 60))
.foregroundColor(.green)
Text("Import Data")
.font(.title)
.fontWeight(.bold)
VStack(spacing: 12) {
Text("Import climbing data from a previously exported ZIP file.")
.multilineTextAlignment(.center)
Text(
"Fully compatible with Android exports - identical ZIP format with images."
)
.font(.subheadline)
.foregroundColor(.blue)
.multilineTextAlignment(.center)
Text("⚠️ Warning: This will replace all current data!")
.font(.subheadline)
.foregroundColor(.red)
.multilineTextAlignment(.center)
}
.padding(.horizontal)
Button(action: {
showingDocumentPicker = true
}) {
if isImporting {
HStack {
ProgressView()
.scaleEffect(0.8)
Text("Importing...")
}
} else {
Label("Select ZIP File to Import", systemImage: "folder.badge.plus")
}
}
.font(.headline)
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding()
.background(
RoundedRectangle(cornerRadius: 12)
.fill(isImporting ? .gray : .green)
)
.padding(.horizontal)
.disabled(isImporting)
if let error = importError {
Text(error)
.foregroundColor(.red)
.padding()
.background(
RoundedRectangle(cornerRadius: 8)
.fill(.red.opacity(0.1))
)
}
Spacer()
}
.padding()
.navigationTitle("Import Data")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Cancel") {
dismiss()
}
}
}
.fileImporter(
isPresented: $showingDocumentPicker,
allowedContentTypes: [.zip, .archive],
allowsMultipleSelection: false
) { result in
switch result {
case .success(let urls):
if let url = urls.first {
importData(from: url)
}
case .failure(let error):
importError = "Failed to select file: \(error.localizedDescription)"
}
}
}
}
private func importData(from url: URL) {
isImporting = true
importError = nil
Task {
do {
// Access the security-scoped resource
guard url.startAccessingSecurityScopedResource() else {
await MainActor.run {
isImporting = false
importError = "Failed to access selected file"
}
return
}
defer { url.stopAccessingSecurityScopedResource() }
let data = try Data(contentsOf: url)
try dataManager.importData(from: data)
await MainActor.run {
isImporting = false
dismiss()
}
} catch {
await MainActor.run {
isImporting = false
importError = "Import failed: \(error.localizedDescription)"
}
}
}
}
}
#Preview {
SettingsView()
.environmentObject(ClimbingDataManager.preview)
}