1.5.0 Initial run as iOS in a monorepo
This commit is contained in:
362
ios/OpenClimb.xcodeproj/project.pbxproj
Normal file
362
ios/OpenClimb.xcodeproj/project.pbxproj
Normal 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 */;
|
||||
}
|
||||
7
ios/OpenClimb.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
7
ios/OpenClimb.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -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>
|
||||
Binary file not shown.
@@ -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>
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
6
ios/OpenClimb/Assets.xcassets/Contents.json
Normal file
6
ios/OpenClimb/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
114
ios/OpenClimb/ContentView.swift
Normal file
114
ios/OpenClimb/ContentView.swift
Normal 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
8
ios/OpenClimb/Info.plist
Normal 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>
|
||||
561
ios/OpenClimb/Models/DataModels.swift
Normal file
561
ios/OpenClimb/Models/DataModels.swift
Normal 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
|
||||
}
|
||||
}
|
||||
17
ios/OpenClimb/OpenClimbApp.swift
Normal file
17
ios/OpenClimb/OpenClimbApp.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
654
ios/OpenClimb/Utils/ZipUtils.swift
Normal file
654
ios/OpenClimb/Utils/ZipUtils.swift
Normal 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: ¤tOffset
|
||||
)
|
||||
|
||||
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: ¤tOffset
|
||||
)
|
||||
|
||||
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: ¤tOffset
|
||||
)
|
||||
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]
|
||||
}
|
||||
834
ios/OpenClimb/ViewModels/ClimbingDataManager.swift
Normal file
834
ios/OpenClimb/ViewModels/ClimbingDataManager.swift
Normal 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
|
||||
}
|
||||
}
|
||||
554
ios/OpenClimb/Views/AddEdit/AddAttemptView.swift
Normal file
554
ios/OpenClimb/Views/AddEdit/AddAttemptView.swift
Normal 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)
|
||||
}
|
||||
216
ios/OpenClimb/Views/AddEdit/AddEditGymView.swift
Normal file
216
ios/OpenClimb/Views/AddEdit/AddEditGymView.swift
Normal 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)
|
||||
}
|
||||
529
ios/OpenClimb/Views/AddEdit/AddEditProblemView.swift
Normal file
529
ios/OpenClimb/Views/AddEdit/AddEditProblemView.swift
Normal 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)
|
||||
}
|
||||
143
ios/OpenClimb/Views/AddEdit/AddEditSessionView.swift
Normal file
143
ios/OpenClimb/Views/AddEdit/AddEditSessionView.swift
Normal 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)
|
||||
}
|
||||
407
ios/OpenClimb/Views/AnalyticsView.swift
Normal file
407
ios/OpenClimb/Views/AnalyticsView.swift
Normal 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)
|
||||
}
|
||||
430
ios/OpenClimb/Views/Detail/GymDetailView.swift
Normal file
430
ios/OpenClimb/Views/Detail/GymDetailView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
476
ios/OpenClimb/Views/Detail/ProblemDetailView.swift
Normal file
476
ios/OpenClimb/Views/Detail/ProblemDetailView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
443
ios/OpenClimb/Views/Detail/SessionDetailView.swift
Normal file
443
ios/OpenClimb/Views/Detail/SessionDetailView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
171
ios/OpenClimb/Views/GymsView.swift
Normal file
171
ios/OpenClimb/Views/GymsView.swift
Normal 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)
|
||||
}
|
||||
362
ios/OpenClimb/Views/ProblemsView.swift
Normal file
362
ios/OpenClimb/Views/ProblemsView.swift
Normal 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)
|
||||
}
|
||||
243
ios/OpenClimb/Views/SessionsView.swift
Normal file
243
ios/OpenClimb/Views/SessionsView.swift
Normal 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)
|
||||
}
|
||||
441
ios/OpenClimb/Views/SettingsView.swift
Normal file
441
ios/OpenClimb/Views/SettingsView.swift
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user