Moved to Ascently
All checks were successful
Ascently Docker Deploy / build-and-push (push) Successful in 2m31s

This commit is contained in:
2025-10-13 14:54:54 -06:00
parent 30d2b3938e
commit 09b4055985
137 changed files with 788 additions and 483 deletions

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.atridad.Ascently</string>
</array>
<key>com.apple.developer.healthkit</key>
<true/>
<key>com.apple.developer.healthkit.access</key>
<array/>
</dict>
</plist>

View File

@@ -0,0 +1,10 @@
import SwiftUI
@main
struct AscentlyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}

View File

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

View File

@@ -0,0 +1,38 @@
{
"images": [
{
"filename": "app_icon_1024.png",
"idiom": "universal",
"platform": "ios",
"size": "1024x1024"
},
{
"appearances": [
{
"appearance": "luminosity",
"value": "dark"
}
],
"filename": "app_icon_1024_dark.png",
"idiom": "universal",
"platform": "ios",
"size": "1024x1024"
},
{
"appearances": [
{
"appearance": "luminosity",
"value": "tinted"
}
],
"filename": "app_icon_1024_tinted.png",
"idiom": "universal",
"platform": "ios",
"size": "1024x1024"
}
],
"info": {
"author": "xcode",
"version": 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://schemas.android.com/2000/svg">
<!-- Dark background with rounded corners for iOS -->
<rect width="1024" height="1024" fill="#1A1A1A" rx="180" ry="180"/>
<!-- Transform to match Android layout exactly -->
<g transform="translate(512, 512) scale(4.75) translate(-54, -42.5)">
<!-- Left mountain (yellow/amber) - matches Android coordinates with white border -->
<polygon points="15,70 35,25 55,70"
fill="#FFC107"
stroke="#FFFFFF"
stroke-width="3"
stroke-linejoin="round"/>
<!-- Right mountain (red) - matches Android coordinates with white border -->
<polygon points="40,70 65,15 90,70"
fill="#F44336"
stroke="#FFFFFF"
stroke-width="3"
stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 913 B

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://schemas.android.com/2000/svg">
<!-- White background with rounded corners for iOS -->
<rect width="1024" height="1024" fill="#FFFFFF" rx="180" ry="180"/>
<!-- Transform to match Android layout exactly -->
<g transform="translate(512, 512) scale(4.75) translate(-54, -42.5)">
<!-- Left mountain (yellow/amber) - matches Android coordinates -->
<polygon points="15,70 35,25 55,70"
fill="#FFC107"
stroke="#1C1C1C"
stroke-width="3"
stroke-linejoin="round"/>
<!-- Right mountain (red) - matches Android coordinates -->
<polygon points="40,70 65,15 90,70"
fill="#F44336"
stroke="#1C1C1C"
stroke-width="3"
stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 878 B

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://schemas.android.com/2000/svg">
<!-- Transparent background with rounded corners for iOS tinted mode -->
<rect width="1024" height="1024" fill="transparent" rx="180" ry="180"/>
<!-- Transform to match Android layout exactly -->
<g transform="translate(512, 512) scale(4.75) translate(-54, -42.5)">
<!-- Left mountain - matches Android coordinates, black fill for tinting -->
<polygon points="15,70 35,25 55,70"
fill="#000000"
stroke="#000000"
stroke-width="3"
stroke-linejoin="round"
opacity="0.8"/>
<!-- Right mountain - matches Android coordinates, black fill for tinting -->
<polygon points="40,70 65,15 90,70"
fill="#000000"
stroke="#000000"
stroke-width="3"
stroke-linejoin="round"
opacity="0.9"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 981 B

View File

@@ -0,0 +1,56 @@
{
"images": [
{
"filename": "app_logo_256.png",
"idiom": "universal",
"scale": "1x"
},
{
"appearances": [
{
"appearance": "luminosity",
"value": "dark"
}
],
"filename": "app_logo_256_dark.png",
"idiom": "universal",
"scale": "1x"
},
{
"filename": "app_logo_256.png",
"idiom": "universal",
"scale": "2x"
},
{
"appearances": [
{
"appearance": "luminosity",
"value": "dark"
}
],
"filename": "app_logo_256_dark.png",
"idiom": "universal",
"scale": "2x"
},
{
"filename": "app_logo_256.png",
"idiom": "universal",
"scale": "3x"
},
{
"appearances": [
{
"appearance": "luminosity",
"value": "dark"
}
],
"filename": "app_logo_256_dark.png",
"idiom": "universal",
"scale": "3x"
}
],
"info": {
"author": "xcode",
"version": 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

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

View File

@@ -0,0 +1,39 @@
import SwiftUI
struct AsyncImageView: View {
let imagePath: String
let targetSize: CGSize
@State private var image: UIImage?
var body: some View {
ZStack {
Rectangle()
.fill(Color(.systemGray6))
if let image = image {
Image(uiImage: image)
.resizable()
.scaledToFill()
.transition(.opacity.animation(.easeInOut(duration: 0.3)))
} else {
Image(systemName: "photo")
.font(.system(size: 24))
.foregroundColor(Color(.systemGray3))
}
}
.frame(width: targetSize.width, height: targetSize.height)
.clipped()
.cornerRadius(8)
.task(id: imagePath) {
if self.image != nil {
self.image = nil
}
self.image = await ImageManager.shared.loadThumbnail(
fromPath: imagePath,
targetSize: targetSize
)
}
}
}

View File

@@ -0,0 +1,54 @@
import SwiftUI
import UIKit
struct CameraImagePicker: UIViewControllerRepresentable {
@Binding var isPresented: Bool
let onImageCaptured: (UIImage) -> Void
func makeUIViewController(context: Context) -> UIImagePickerController {
let picker = UIImagePickerController()
picker.delegate = context.coordinator
picker.sourceType = .camera
picker.cameraCaptureMode = .photo
picker.cameraDevice = .rear
picker.allowsEditing = false
return picker
}
func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {
// Nothing here actually... Q_Q
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
let parent: CameraImagePicker
init(_ parent: CameraImagePicker) {
self.parent = parent
}
func imagePickerController(
_ picker: UIImagePickerController,
didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]
) {
if let image = info[.originalImage] as? UIImage {
parent.onImageCaptured(image)
}
parent.isPresented = false
}
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
parent.isPresented = false
}
}
}
// Extension to check camera availability
extension CameraImagePicker {
static var isCameraAvailable: Bool {
UIImagePickerController.isSourceTypeAvailable(.camera)
}
}

View File

@@ -0,0 +1,85 @@
import PhotosUI
import SwiftUI
struct PhotoOptionSheet: View {
@Binding var selectedPhotos: [PhotosPickerItem]
@Binding var imageData: [Data]
let maxImages: Int
let onCameraSelected: () -> Void
let onPhotoLibrarySelected: () -> Void
let onDismiss: () -> Void
var body: some View {
NavigationView {
VStack(spacing: 20) {
Text("Add Photo")
.font(.title2)
.fontWeight(.semibold)
.padding(.top)
Text("Choose how you'd like to add a photo")
.font(.subheadline)
.foregroundColor(.secondary)
VStack(spacing: 16) {
Button(action: {
onPhotoLibrarySelected()
onDismiss()
}) {
HStack {
Image(systemName: "photo.on.rectangle")
.font(.title2)
.foregroundColor(.blue)
Text("Photo Library")
.font(.headline)
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundColor(.secondary)
}
.padding()
.background(.regularMaterial)
.cornerRadius(12)
}
.buttonStyle(PlainButtonStyle())
Button(action: {
onDismiss()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
onCameraSelected()
}
}) {
HStack {
Image(systemName: "camera.fill")
.font(.title2)
.foregroundColor(.blue)
Text("Camera")
.font(.headline)
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundColor(.secondary)
}
.padding()
.background(.regularMaterial)
.cornerRadius(12)
}
.buttonStyle(PlainButtonStyle())
}
.padding(.horizontal)
Spacer()
}
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Cancel") {
onDismiss()
}
}
}
}
.presentationDetents([.height(300)])
.interactiveDismissDisabled(false)
}
}

View File

@@ -0,0 +1,176 @@
import SwiftUI
struct ContentView: View {
@StateObject private var dataManager = ClimbingDataManager()
@State private var selectedTab = 0
@Environment(\.scenePhase) private var scenePhase
@State private var notificationObservers: [NSObjectProtocol] = []
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)
.onChange(of: scenePhase) { oldPhase, newPhase in
if newPhase == .active {
// Add slight delay to ensure app is fully loaded
Task {
try? await Task.sleep(nanoseconds: 200_000_000) // 0.2 seconds
dataManager.onAppBecomeActive()
// Re-verify health integration when app becomes active
await dataManager.healthKitService.verifyAndRestoreIntegration()
}
} else if newPhase == .background {
dataManager.onAppEnterBackground()
}
}
.onAppear {
setupNotificationObservers()
// Trigger auto-sync on app start only
dataManager.syncService.triggerAutoSync(dataManager: dataManager)
// Verify and restore health integration if it was previously enabled
Task {
await dataManager.healthKitService.verifyAndRestoreIntegration()
}
}
.onDisappear {
removeNotificationObservers()
}
.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)
}
}
}
private func setupNotificationObservers() {
// Listen for when the app will enter foreground
let willEnterForegroundObserver = NotificationCenter.default.addObserver(
forName: UIApplication.willEnterForegroundNotification,
object: nil,
queue: .main
) { _ in
print("App will enter foreground - preparing Live Activity check")
Task {
// Small delay to ensure app is fully active
try? await Task.sleep(nanoseconds: 800_000_000) // 0.8 seconds
await dataManager.onAppBecomeActive()
// Re-verify health integration when returning from background
await dataManager.healthKitService.verifyAndRestoreIntegration()
}
}
// Listen for when the app becomes active
let didBecomeActiveObserver = NotificationCenter.default.addObserver(
forName: UIApplication.didBecomeActiveNotification,
object: nil,
queue: .main
) { _ in
print("App did become active - checking Live Activity status")
Task {
try? await Task.sleep(nanoseconds: 300_000_000) // 0.3 seconds
await dataManager.onAppBecomeActive()
// Ensure health integration is verified
await dataManager.healthKitService.verifyAndRestoreIntegration()
}
}
notificationObservers = [willEnterForegroundObserver, didBecomeActiveObserver]
}
private func removeNotificationObservers() {
for observer in notificationObservers {
NotificationCenter.default.removeObserver(observer)
}
notificationObservers.removeAll()
}
}
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()
}

18
ios/Ascently/Info.plist Normal file
View File

@@ -0,0 +1,18 @@
<?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/>
<key>NSSupportsLiveActivities</key>
<true/>
<key>NSPhotoLibraryUsageDescription</key>
<string>This app needs access to your photo library to add photos to climbing problems.</string>
<key>NSCameraUsageDescription</key>
<string>This app needs access to your camera to take photos of climbing problems.</string>
<key>NSHealthShareUsageDescription</key>
<string>This app needs access to save your climbing workouts to Apple Health.</string>
<key>NSHealthUpdateUsageDescription</key>
<string>This app needs access to save your climbing workouts to Apple Health.</string>
</dict>
</plist>

View File

@@ -0,0 +1,19 @@
import ActivityKit
import Foundation
struct SessionActivityAttributes: ActivityAttributes, Sendable {
public struct ContentState: Codable, Hashable, Sendable {
var elapsed: TimeInterval
var totalAttempts: Int
var completedProblems: Int
}
var gymName: String
var startTime: Date
}
extension SessionActivityAttributes {
static var preview: SessionActivityAttributes {
SessionActivityAttributes(gymName: "Summit Climbing", startTime: Date())
}
}

View File

@@ -0,0 +1,460 @@
//
// BackupFormat.swift
import Foundation
// MARK: - Backup Format Specification v2.0
/// Root structure for Ascently backup data
struct DeletedItem: Codable, Hashable {
let id: String
let type: String // "gym", "problem", "session", "attempt"
let deletedAt: String
}
struct ClimbDataBackup: Codable {
let exportedAt: String
let version: String
let formatVersion: String
let gyms: [BackupGym]
let problems: [BackupProblem]
let sessions: [BackupClimbSession]
let attempts: [BackupAttempt]
let deletedItems: [DeletedItem]
init(
exportedAt: String,
version: String = "2.0",
formatVersion: String = "2.0",
gyms: [BackupGym],
problems: [BackupProblem],
sessions: [BackupClimbSession],
attempts: [BackupAttempt],
deletedItems: [DeletedItem] = []
) {
self.exportedAt = exportedAt
self.version = version
self.formatVersion = formatVersion
self.gyms = gyms
self.problems = problems
self.sessions = sessions
self.attempts = attempts
self.deletedItems = deletedItems
}
}
// Platform-neutral gym representation for backup/restore
struct BackupGym: Codable {
let id: String
let name: String
let location: String?
let supportedClimbTypes: [ClimbType]
let difficultySystems: [DifficultySystem]
let customDifficultyGrades: [String]
let notes: String?
let createdAt: String
let updatedAt: String
/// Initialize from native iOS Gym model
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.customDifficultyGrades = gym.customDifficultyGrades
self.notes = gym.notes
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
self.createdAt = formatter.string(from: gym.createdAt)
self.updatedAt = formatter.string(from: gym.updatedAt)
}
/// Initialize with explicit parameters for import
init(
id: String,
name: String,
location: String?,
supportedClimbTypes: [ClimbType],
difficultySystems: [DifficultySystem],
customDifficultyGrades: [String] = [],
notes: String?,
createdAt: String,
updatedAt: String
) {
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
}
/// Convert to native iOS Gym model
func toGym() throws -> Gym {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
guard let uuid = UUID(uuidString: id),
let createdDate = formatter.date(from: createdAt),
let updatedDate = formatter.date(from: updatedAt)
else {
throw BackupError.invalidDateFormat
}
return Gym.fromImport(
id: uuid,
name: name,
location: location,
supportedClimbTypes: supportedClimbTypes,
difficultySystems: difficultySystems,
customDifficultyGrades: customDifficultyGrades,
notes: notes,
createdAt: createdDate,
updatedAt: updatedDate
)
}
}
// Platform-neutral problem representation for backup/restore
struct BackupProblem: Codable {
let id: String
let gymId: String
let name: String?
let description: String?
let climbType: ClimbType
let difficulty: DifficultyGrade
let tags: [String]
let location: String?
let imagePaths: [String]?
let isActive: Bool
let dateSet: String? // ISO 8601 format
let notes: String?
let createdAt: String
let updatedAt: String
/// Initialize from native iOS Problem model
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.tags = problem.tags
self.location = problem.location
self.imagePaths = problem.imagePaths.isEmpty ? nil : problem.imagePaths
self.isActive = problem.isActive
self.notes = problem.notes
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
self.dateSet = problem.dateSet.map { formatter.string(from: $0) }
self.createdAt = formatter.string(from: problem.createdAt)
self.updatedAt = formatter.string(from: problem.updatedAt)
}
/// Initialize with explicit parameters for import
init(
id: String,
gymId: String,
name: String?,
description: String?,
climbType: ClimbType,
difficulty: DifficultyGrade,
tags: [String] = [],
location: String?,
imagePaths: [String]?,
isActive: Bool,
dateSet: String?,
notes: String?,
createdAt: String,
updatedAt: String
) {
self.id = id
self.gymId = gymId
self.name = name
self.description = description
self.climbType = climbType
self.difficulty = difficulty
self.tags = tags
self.location = location
self.imagePaths = imagePaths
self.isActive = isActive
self.dateSet = dateSet
self.notes = notes
self.createdAt = createdAt
self.updatedAt = updatedAt
}
/// Convert to native iOS Problem model
func toProblem() throws -> Problem {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
guard let uuid = UUID(uuidString: id),
let gymUuid = UUID(uuidString: gymId),
let createdDate = formatter.date(from: createdAt),
let updatedDate = formatter.date(from: updatedAt)
else {
throw BackupError.invalidDateFormat
}
let dateSetDate = dateSet.flatMap { formatter.date(from: $0) }
return Problem.fromImport(
id: uuid,
gymId: gymUuid,
name: name,
description: description,
climbType: climbType,
difficulty: difficulty,
tags: tags,
location: location,
imagePaths: imagePaths ?? [],
isActive: isActive,
dateSet: dateSetDate,
notes: notes,
createdAt: createdDate,
updatedAt: updatedDate
)
}
/// Create a copy with updated image paths for import processing
func withUpdatedImagePaths(_ newImagePaths: [String]) -> BackupProblem {
return BackupProblem(
id: self.id,
gymId: self.gymId,
name: self.name,
description: self.description,
climbType: self.climbType,
difficulty: self.difficulty,
tags: self.tags,
location: self.location,
imagePaths: newImagePaths.isEmpty ? nil : newImagePaths,
isActive: self.isActive,
dateSet: self.dateSet,
notes: self.notes,
createdAt: self.createdAt,
updatedAt: self.updatedAt
)
}
}
// Platform-neutral climb session representation for backup/restore
struct BackupClimbSession: Codable {
let id: String
let gymId: String
let date: String // ISO 8601 format
let startTime: String? // ISO 8601 format
let endTime: String? // ISO 8601 format
let duration: Int64? // Duration in seconds
let status: SessionStatus
let notes: String?
let createdAt: String
let updatedAt: String
/// Initialize from native iOS ClimbSession model
init(from session: ClimbSession) {
self.id = session.id.uuidString
self.gymId = session.gymId.uuidString
self.status = session.status
self.notes = session.notes
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
self.date = formatter.string(from: session.date)
self.startTime = session.startTime.map { formatter.string(from: $0) }
self.endTime = session.endTime.map { formatter.string(from: $0) }
self.duration = session.duration.map { Int64($0) }
self.createdAt = formatter.string(from: session.createdAt)
self.updatedAt = formatter.string(from: session.updatedAt)
}
/// Initialize with explicit parameters for import
init(
id: String,
gymId: String,
date: String,
startTime: String?,
endTime: String?,
duration: Int64?,
status: SessionStatus,
notes: String?,
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.notes = notes
self.createdAt = createdAt
self.updatedAt = updatedAt
}
/// Convert to native iOS ClimbSession model
func toClimbSession() throws -> ClimbSession {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
guard let uuid = UUID(uuidString: id),
let gymUuid = UUID(uuidString: gymId),
let dateValue = formatter.date(from: date),
let createdDate = formatter.date(from: createdAt),
let updatedDate = formatter.date(from: updatedAt)
else {
throw BackupError.invalidDateFormat
}
let startTimeValue = startTime.flatMap { formatter.date(from: $0) }
let endTimeValue = endTime.flatMap { formatter.date(from: $0) }
let durationValue = duration.map { Int($0) }
return ClimbSession.fromImport(
id: uuid,
gymId: gymUuid,
date: dateValue,
startTime: startTimeValue,
endTime: endTimeValue,
duration: durationValue,
status: status,
notes: notes,
createdAt: createdDate,
updatedAt: updatedDate
)
}
}
// Platform-neutral attempt representation for backup/restore
struct BackupAttempt: Codable {
let id: String
let sessionId: String
let problemId: String
let result: AttemptResult
let highestHold: String?
let notes: String?
let duration: Int64? // Duration in seconds
let restTime: Int64? // Rest time in seconds
let timestamp: String
let createdAt: String
let updatedAt: String?
/// Initialize from native iOS Attempt model
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.map { Int64($0) }
self.restTime = attempt.restTime.map { Int64($0) }
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
self.timestamp = formatter.string(from: attempt.timestamp)
self.createdAt = formatter.string(from: attempt.createdAt)
self.updatedAt = formatter.string(from: attempt.updatedAt)
}
/// Initialize with explicit parameters for import
init(
id: String,
sessionId: String,
problemId: String,
result: AttemptResult,
highestHold: String?,
notes: String?,
duration: Int64?,
restTime: Int64?,
timestamp: String,
createdAt: String,
updatedAt: 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
self.updatedAt = updatedAt
}
/// Convert to native iOS Attempt model
func toAttempt() throws -> Attempt {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
guard let uuid = UUID(uuidString: id),
let sessionUuid = UUID(uuidString: sessionId),
let problemUuid = UUID(uuidString: problemId),
let timestampDate = formatter.date(from: timestamp),
let createdDate = formatter.date(from: createdAt)
else {
throw BackupError.invalidDateFormat
}
let updatedDateParsed = updatedAt.flatMap { formatter.date(from: $0) }
let updatedDate = updatedDateParsed ?? createdDate
let durationValue = duration.map { Int($0) }
let restTimeValue = restTime.map { Int($0) }
return Attempt.fromImport(
id: uuid,
sessionId: sessionUuid,
problemId: problemUuid,
result: result,
highestHold: highestHold,
notes: notes,
duration: durationValue,
restTime: restTimeValue,
timestamp: timestampDate,
createdAt: createdDate,
updatedAt: updatedDate
)
}
}
// MARK: - Backup Format Errors
enum BackupError: LocalizedError {
case invalidDateFormat
case invalidUUID
case missingRequiredField(String)
case unsupportedFormatVersion(String)
var errorDescription: String? {
switch self {
case .invalidDateFormat:
return "Invalid date format in backup data"
case .invalidUUID:
return "Invalid UUID format in backup data"
case .missingRequiredField(let field):
return "Missing required field: \(field)"
case .unsupportedFormatVersion(let version):
return "Unsupported backup format version: \(version)"
}
}
}
// MARK: - Extensions
// MARK: - Helper Extensions for Optional Mapping
extension Optional {
func map<T>(_ transform: (Wrapped) -> T) -> T? {
return self.flatMap { .some(transform($0)) }
}
}

View File

@@ -0,0 +1,563 @@
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 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, 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.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, 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,
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, 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.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, 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,
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
let updatedAt: Date
init(
sessionId: UUID, problemId: UUID, result: AttemptResult, highestHold: String? = nil,
notes: String? = nil, duration: Int? = nil, restTime: Int? = nil, timestamp: Date = Date()
) {
let now = 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 = now
self.updatedAt = now
}
func updated(
problemId: UUID? = nil, 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: 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,
updatedAt: Date()
)
}
private init(
id: UUID, sessionId: UUID, problemId: UUID, result: AttemptResult, highestHold: String?,
notes: String?, duration: Int?, restTime: Int?, timestamp: Date, createdAt: Date,
updatedAt: 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
self.updatedAt = updatedAt
}
static func fromImport(
id: UUID, sessionId: UUID, problemId: UUID, result: AttemptResult, highestHold: String?,
notes: String?, duration: Int?, restTime: Int?, timestamp: Date, createdAt: Date,
updatedAt: Date
) -> Attempt {
return Attempt(
id: id,
sessionId: sessionId,
problemId: problemId,
result: result,
highestHold: highestHold,
notes: notes,
duration: duration,
restTime: restTime,
timestamp: timestamp,
createdAt: createdAt,
updatedAt: updatedAt
)
}
}
extension DifficultyGrade: Comparable {
static func < (lhs: DifficultyGrade, rhs: DifficultyGrade) -> Bool {
if lhs.system != rhs.system {
return false // Can't compare different systems
}
return lhs.numericValue < rhs.numericValue
}
}

View File

@@ -0,0 +1,236 @@
import Combine
import Foundation
import HealthKit
@MainActor
class HealthKitService: ObservableObject {
static let shared = HealthKitService()
private let healthStore = HKHealthStore()
private var currentWorkoutStartDate: Date?
private var currentWorkoutSessionId: UUID?
@Published var isAuthorized = false
@Published var isEnabled = false
private let userDefaults = UserDefaults.standard
private let isEnabledKey = "healthKitEnabled"
private let workoutStartDateKey = "healthKitWorkoutStartDate"
private let workoutSessionIdKey = "healthKitWorkoutSessionId"
private init() {
loadSettings()
restoreActiveWorkout()
}
func loadSettings() {
isEnabled = userDefaults.bool(forKey: isEnabledKey)
if HKHealthStore.isHealthDataAvailable() {
checkAuthorization()
}
}
/// Restore active workout state
private func restoreActiveWorkout() {
if let startDate = userDefaults.object(forKey: workoutStartDateKey) as? Date,
let sessionIdString = userDefaults.string(forKey: workoutSessionIdKey),
let sessionId = UUID(uuidString: sessionIdString)
{
currentWorkoutStartDate = startDate
currentWorkoutSessionId = sessionId
print("HealthKit: Restored active workout from \(startDate)")
}
}
/// Persist active workout state
private func persistActiveWorkout() {
if let startDate = currentWorkoutStartDate, let sessionId = currentWorkoutSessionId {
userDefaults.set(startDate, forKey: workoutStartDateKey)
userDefaults.set(sessionId.uuidString, forKey: workoutSessionIdKey)
} else {
userDefaults.removeObject(forKey: workoutStartDateKey)
userDefaults.removeObject(forKey: workoutSessionIdKey)
}
}
/// Verify and restore health integration
func verifyAndRestoreIntegration() async {
guard isEnabled else { return }
guard HKHealthStore.isHealthDataAvailable() else {
print("HealthKit: Device does not support HealthKit")
return
}
checkAuthorization()
if !isAuthorized {
print(
"HealthKit: Integration was enabled but authorization lost, attempting to restore..."
)
do {
try await requestAuthorization()
print("HealthKit: Authorization restored successfully")
} catch {
print("HealthKit: Failed to restore authorization: \(error.localizedDescription)")
}
} else {
print("HealthKit: Integration verified - authorization is valid")
}
if hasActiveWorkout() {
print(
"HealthKit: Active workout restored - started at \(currentWorkoutStartDate!)"
)
}
}
func setEnabled(_ enabled: Bool) {
isEnabled = enabled
userDefaults.set(enabled, forKey: isEnabledKey)
}
func requestAuthorization() async throws {
guard HKHealthStore.isHealthDataAvailable() else {
throw HealthKitError.notAvailable
}
let workoutType = HKObjectType.workoutType()
let energyBurnedType = HKObjectType.quantityType(forIdentifier: .activeEnergyBurned)!
let typesToShare: Set<HKSampleType> = [
workoutType,
energyBurnedType,
]
let typesToRead: Set<HKObjectType> = [
workoutType
]
try await healthStore.requestAuthorization(toShare: typesToShare, read: typesToRead)
self.isAuthorized = true
}
private func checkAuthorization() {
let workoutType = HKObjectType.workoutType()
let status = healthStore.authorizationStatus(for: workoutType)
isAuthorized = (status == .sharingAuthorized)
}
func startWorkout(startDate: Date, sessionId: UUID) async throws {
guard isEnabled && isAuthorized else {
return
}
guard HKHealthStore.isHealthDataAvailable() else {
throw HealthKitError.notAvailable
}
currentWorkoutStartDate = startDate
currentWorkoutSessionId = sessionId
persistActiveWorkout()
print("HealthKit: Started workout for session \(sessionId)")
}
func endWorkout(endDate: Date) async throws {
guard isEnabled && isAuthorized else {
return
}
guard let startDate = currentWorkoutStartDate else {
return
}
guard HKHealthStore.isHealthDataAvailable() else {
throw HealthKitError.notAvailable
}
let duration = endDate.timeIntervalSince(startDate)
let calories = estimateCalories(durationInMinutes: duration / 60.0)
let energyBurned = HKQuantity(unit: .kilocalorie(), doubleValue: calories)
let workoutConfiguration = HKWorkoutConfiguration()
workoutConfiguration.activityType = .climbing
workoutConfiguration.locationType = .indoor
let builder = HKWorkoutBuilder(
healthStore: healthStore,
configuration: workoutConfiguration,
device: .local()
)
do {
try await builder.beginCollection(at: startDate)
let energyBurnedType = HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned)!
let energySample = HKQuantitySample(
type: energyBurnedType,
quantity: energyBurned,
start: startDate,
end: endDate
)
try await builder.addSamples([energySample])
try await builder.addMetadata([HKMetadataKeyIndoorWorkout: true])
try await builder.endCollection(at: endDate)
let workout = try await builder.finishWorkout()
print(
"HealthKit: Workout saved successfully with id: \(workout?.uuid.uuidString ?? "unknown")"
)
currentWorkoutStartDate = nil
currentWorkoutSessionId = nil
persistActiveWorkout()
} catch {
print("HealthKit: Failed to save workout: \(error.localizedDescription)")
currentWorkoutStartDate = nil
currentWorkoutSessionId = nil
persistActiveWorkout()
throw HealthKitError.workoutSaveFailed
}
}
func cancelWorkout() {
currentWorkoutStartDate = nil
currentWorkoutSessionId = nil
persistActiveWorkout()
print("HealthKit: Workout cancelled")
}
func hasActiveWorkout() -> Bool {
return currentWorkoutStartDate != nil
}
private func estimateCalories(durationInMinutes: Double) -> Double {
let caloriesPerMinute = 8.0
return durationInMinutes * caloriesPerMinute
}
}
enum HealthKitError: LocalizedError {
case notAvailable
case notAuthorized
case workoutStartFailed
case workoutSaveFailed
var errorDescription: String? {
switch self {
case .notAvailable:
return "HealthKit is not available on this device"
case .notAuthorized:
return "HealthKit authorization not granted"
case .workoutStartFailed:
return "Failed to start HealthKit workout"
case .workoutSaveFailed:
return "Failed to save workout to HealthKit"
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,115 @@
import Combine
import SwiftUI
class AppIconHelper: ObservableObject {
static let shared = AppIconHelper()
@Published var isDarkMode: Bool = false
private init() {
}
func updateDarkModeStatus(for colorScheme: ColorScheme) {
isDarkMode = colorScheme == .dark
}
func isInDarkMode(for colorScheme: ColorScheme) -> Bool {
return colorScheme == .dark
}
var supportsModernIconFeatures: Bool {
if #available(iOS 17.0, *) {
return true
}
return false
}
func getRecommendedIconVariant(for colorScheme: ColorScheme) -> IconVariant {
if colorScheme == .dark {
return .dark
}
return .standard
}
var supportsAlternateIcons: Bool {
if #available(iOS 10.3, *) {
return true
}
return false
}
}
enum IconVariant {
case standard
case dark
case tinted
var description: String {
switch self {
case .standard:
return "Standard"
case .dark:
return "Dark Mode"
case .tinted:
return "Tinted"
}
}
}
enum AppIconError: Error, LocalizedError {
case notSupported
case invalidIconName
case systemError(Error)
var errorDescription: String? {
switch self {
case .notSupported:
return "Alternate icons are not supported on this device"
case .invalidIconName:
return "The specified icon name is invalid"
case .systemError(let error):
return "System error: \(error.localizedDescription)"
}
}
}
struct IconAppearanceModifier: ViewModifier {
@Environment(\.colorScheme) private var colorScheme
@ObservedObject private var iconHelper = AppIconHelper.shared
let onChange: (IconVariant) -> Void
func body(content: Content) -> some View {
content
.onChange(of: colorScheme) {
iconHelper.updateDarkModeStatus(for: colorScheme)
onChange(iconHelper.getRecommendedIconVariant(for: colorScheme))
}
.onAppear {
iconHelper.updateDarkModeStatus(for: colorScheme)
onChange(iconHelper.getRecommendedIconVariant(for: colorScheme))
}
}
}
extension View {
func onIconAppearanceChange(_ onChange: @escaping (IconVariant) -> Void) -> some View {
modifier(IconAppearanceModifier(onChange: onChange))
}
}
#if DEBUG
extension AppIconHelper {
static var preview: AppIconHelper {
let helper = AppIconHelper()
helper.isDarkMode = false
return helper
}
static var darkModePreview: AppIconHelper {
let helper = AppIconHelper()
helper.isDarkMode = true
return helper
}
}
#endif

View File

@@ -0,0 +1,83 @@
//
// DataStateManager.swift
import Foundation
/// Manages the overall data state timestamp for sync purposes
class DataStateManager {
private let userDefaults = UserDefaults.standard
private enum Keys {
static let lastModified = "ascently_data_last_modified"
static let initialized = "ascently_data_state_initialized"
}
static let shared = DataStateManager()
private init() {
// Initialize with current timestamp if this is the first time
if !isInitialized() {
print("DataStateManager: First time initialization")
// Set initial timestamp to a very old date so server data will be considered newer
let epochTime = "1970-01-01T00:00:00.000Z"
userDefaults.set(epochTime, forKey: Keys.lastModified)
markAsInitialized()
print("DataStateManager initialized with epoch timestamp: \(epochTime)")
} else {
print("DataStateManager: Already initialized, current timestamp: \(getLastModified())")
}
}
/// Updates the data state timestamp to the current time. Call this whenever any data is modified
/// (create, update, delete).
func updateDataState() {
let now = ISO8601DateFormatter().string(from: Date())
userDefaults.set(now, forKey: Keys.lastModified)
print("iOS Data state updated to: \(now)")
}
/// Gets the current data state timestamp. This represents when any data was last modified
/// locally.
func getLastModified() -> String {
if let storedTimestamp = userDefaults.string(forKey: Keys.lastModified) {
print("iOS DataStateManager returning stored timestamp: \(storedTimestamp)")
return storedTimestamp
}
// If no timestamp is stored, return epoch time to indicate very old data
// This ensures server data will be considered newer than uninitialized local data
let epochTime = "1970-01-01T00:00:00.000Z"
print("WARNING: No data state timestamp found - returning epoch time: \(epochTime)")
return epochTime
}
/// Sets the data state timestamp to a specific value. Used when importing data from server to
/// sync the state.
func setLastModified(_ timestamp: String) {
userDefaults.set(timestamp, forKey: Keys.lastModified)
print("Data state set to: \(timestamp)")
}
/// Resets the data state (for testing or complete data wipe).
func reset() {
userDefaults.removeObject(forKey: Keys.lastModified)
userDefaults.removeObject(forKey: Keys.initialized)
print("Data state reset")
}
/// Checks if the data state has been initialized.
private func isInitialized() -> Bool {
return userDefaults.bool(forKey: Keys.initialized)
}
/// Marks the data state as initialized.
private func markAsInitialized() {
userDefaults.set(true, forKey: Keys.initialized)
}
/// Gets debug information about the current state.
func getDebugInfo() -> String {
return "DataState(lastModified=\(getLastModified()), initialized=\(isInitialized()))"
}
}

View File

@@ -0,0 +1,578 @@
import Combine
import SwiftUI
#if DEBUG
struct IconTestView: View {
@ObservedObject private var iconHelper = AppIconHelper.shared
@Environment(\.colorScheme) private var colorScheme
@State private var showingTestSheet = false
@State private var testResults: [String] = []
var body: some View {
NavigationStack {
List {
StatusSection()
IconDisplaySection()
TestingSection()
DebugSection()
ResultsSection()
}
.navigationTitle("Icon Testing")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Run Tests") {
runIconTests()
}
}
}
}
.sheet(isPresented: $showingTestSheet) {
IconComparisonSheet()
}
}
@ViewBuilder
private func StatusSection() -> some View {
Section("System Status") {
StatusRow(title: "Color Scheme", value: colorScheme.description)
StatusRow(
title: "Dark Mode Detected",
value: iconHelper.isInDarkMode(for: colorScheme) ? "Yes" : "No")
StatusRow(
title: "iOS 17+ Features",
value: iconHelper.supportsModernIconFeatures ? "Supported" : "Not Available")
StatusRow(
title: "Alternate Icons",
value: iconHelper.supportsAlternateIcons ? "Supported" : "Not Available")
}
}
@ViewBuilder
private func IconDisplaySection() -> some View {
Section("Icon Display Test") {
VStack(spacing: 20) {
// App Icon Representation
HStack(spacing: 20) {
VStack {
RoundedRectangle(cornerRadius: 16)
.fill(.blue.gradient)
.frame(width: 60, height: 60)
.overlay {
Image(systemName: "mountain.2.fill")
.foregroundColor(.white)
.font(.title2)
}
Text("Standard")
.font(.caption)
}
VStack {
RoundedRectangle(cornerRadius: 16)
.fill(.blue.gradient)
.colorInvert()
.frame(width: 60, height: 60)
.overlay {
Image(systemName: "mountain.2.fill")
.foregroundColor(.white)
.font(.title2)
}
Text("Dark Mode")
.font(.caption)
}
VStack {
RoundedRectangle(cornerRadius: 16)
.fill(.secondary)
.frame(width: 60, height: 60)
.overlay {
Image(systemName: "mountain.2.fill")
.foregroundColor(.primary)
.font(.title2)
}
Text("Tinted")
.font(.caption)
}
}
// In-App Icon Test
HStack(spacing: 16) {
Text("In-App Icon:")
.font(.subheadline)
.fontWeight(.medium)
Image("AppLogo")
.resizable()
.frame(width: 24, height: 24)
.background(Circle().fill(.quaternary))
Text("24x24")
.font(.caption)
.foregroundColor(.secondary)
Image("AppLogo")
.resizable()
.frame(width: 32, height: 32)
.background(Circle().fill(.quaternary))
Text("32x32")
.font(.caption)
.foregroundColor(.secondary)
}
}
.padding(.vertical, 8)
}
}
@ViewBuilder
private func DebugSection() -> some View {
Section("Dark Mode Debug") {
HStack {
Text("System Color Scheme:")
.foregroundColor(.secondary)
Spacer()
Text(colorScheme == .dark ? "Dark" : "Light")
.fontWeight(.medium)
.foregroundColor(colorScheme == .dark ? .green : .orange)
}
HStack {
Text("IconHelper Dark Mode:")
.foregroundColor(.secondary)
Spacer()
Text(iconHelper.isDarkMode ? "Dark" : "Light")
.fontWeight(.medium)
.foregroundColor(iconHelper.isDarkMode ? .green : .orange)
}
HStack {
Text("Recommended Variant:")
.foregroundColor(.secondary)
Spacer()
Text(iconHelper.getRecommendedIconVariant(for: colorScheme).description)
.fontWeight(.medium)
}
// Current app icon preview
VStack {
Text("Current App Icon Preview")
.font(.headline)
.padding(.top)
HStack(spacing: 20) {
VStack {
RoundedRectangle(cornerRadius: 16)
.fill(colorScheme == .dark ? .black : Color(.systemGray6))
.frame(width: 60, height: 60)
.overlay {
// Mock app icon based on current mode
if colorScheme == .dark {
ZStack {
// Left mountain (yellow/amber) - Android #FFC107
Polygon(points: [
CGPoint(x: 0.2, y: 0.8), CGPoint(x: 0.45, y: 0.3),
CGPoint(x: 0.7, y: 0.8),
])
.fill(Color(red: 1.0, green: 0.76, blue: 0.03))
.stroke(
Color(red: 0.11, green: 0.11, blue: 0.11),
lineWidth: 1
)
.frame(width: 50, height: 50)
// Right mountain (red) - Android #F44336, overlapping
Polygon(points: [
CGPoint(x: 0.5, y: 0.8), CGPoint(x: 0.75, y: 0.2),
CGPoint(x: 1.0, y: 0.8),
])
.fill(Color(red: 0.96, green: 0.26, blue: 0.21))
.stroke(
Color(red: 0.11, green: 0.11, blue: 0.11),
lineWidth: 1
)
.frame(width: 50, height: 50)
}
} else {
ZStack {
// Left mountain (yellow/amber) - Android #FFC107
Polygon(points: [
CGPoint(x: 0.2, y: 0.8), CGPoint(x: 0.45, y: 0.3),
CGPoint(x: 0.7, y: 0.8),
])
.fill(Color(red: 1.0, green: 0.76, blue: 0.03))
.stroke(
Color(red: 0.11, green: 0.11, blue: 0.11),
lineWidth: 1
)
.frame(width: 50, height: 50)
// Right mountain (red) - Android #F44336, overlapping
Polygon(points: [
CGPoint(x: 0.5, y: 0.8), CGPoint(x: 0.75, y: 0.2),
CGPoint(x: 1.0, y: 0.8),
])
.fill(Color(red: 0.96, green: 0.26, blue: 0.21))
.stroke(
Color(red: 0.11, green: 0.11, blue: 0.11),
lineWidth: 1
)
.frame(width: 50, height: 50)
}
}
}
Text(colorScheme == .dark ? "Dark Mode" : "Light Mode")
.font(.caption)
}
}
}
.padding(.vertical, 8)
}
}
@ViewBuilder
private func TestingSection() -> some View {
Section("Testing Tools") {
Button("Compare Light/Dark Modes") {
showingTestSheet = true
}
Button("Test Icon Appearance Changes") {
testIconAppearanceChanges()
}
Button("Validate Asset Configuration") {
validateAssetConfiguration()
}
Button("Check Bundle Resources") {
checkBundleResources()
}
}
}
@ViewBuilder
private func ResultsSection() -> some View {
if !testResults.isEmpty {
Section("Test Results") {
ForEach(testResults.indices, id: \.self) { index in
HStack {
Image(
systemName: testResults[index].contains("PASS")
? "checkmark.circle.fill" : "exclamationmark.triangle.fill"
)
.foregroundColor(testResults[index].contains("PASS") ? .green : .orange)
Text(testResults[index])
.font(.caption)
}
}
Button("Clear Results") {
testResults.removeAll()
}
.foregroundColor(.red)
}
}
}
private func runIconTests() {
testResults.removeAll()
// Test 1: Check iOS version compatibility
if iconHelper.supportsModernIconFeatures {
testResults.append("PASS: iOS 17+ features supported")
} else {
testResults.append(
"WARNING: Running on iOS version that doesn't support modern icon features")
}
// Test 2: Check dark mode detection
let detectedDarkMode = iconHelper.isInDarkMode(for: colorScheme)
let systemDarkMode = colorScheme == .dark
if detectedDarkMode == systemDarkMode {
testResults.append("PASS: Dark mode detection matches system setting")
} else {
testResults.append("WARNING: Dark mode detection mismatch")
}
// Test 3: Check recommended variant
let variant = iconHelper.getRecommendedIconVariant(for: colorScheme)
testResults.append("PASS: Recommended icon variant: \(variant.description)")
// Test 4: Test asset availability
validateAssetConfiguration()
// Test 5: Test bundle resources
checkBundleResources()
}
private func testIconAppearanceChanges() {
iconHelper.updateDarkModeStatus(for: colorScheme)
let variant = iconHelper.getRecommendedIconVariant(for: colorScheme)
testResults.append(
"PASS: Icon appearance test completed - Current variant: \(variant.description)")
}
private func validateAssetConfiguration() {
// Check if main bundle contains the expected icon assets
let expectedAssets = [
"AppIcon",
"AppLogo",
]
for asset in expectedAssets {
testResults.append("PASS: Asset '\(asset)' configuration found")
}
}
private func checkBundleResources() {
// Check bundle identifier
let bundleId = Bundle.main.bundleIdentifier ?? "Unknown"
testResults.append("PASS: Bundle ID: \(bundleId)")
// Check app version
let version =
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown"
let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "Unknown"
testResults.append("PASS: App version: \(version) (\(build))")
}
}
struct StatusRow: View {
let title: String
let value: String
var body: some View {
HStack {
Text(title)
.foregroundColor(.secondary)
Spacer()
Text(value)
.fontWeight(.medium)
}
}
}
struct IconComparisonSheet: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.colorScheme) private var colorScheme
var body: some View {
NavigationStack {
VStack(spacing: 30) {
Text("Icon Appearance Comparison")
.font(.title2)
.fontWeight(.bold)
VStack(spacing: 20) {
// Current Mode
VStack {
Text("Current Mode: \(colorScheme.description)")
.font(.headline)
HStack(spacing: 20) {
Image("AppLogo")
.resizable()
.frame(width: 64, height: 64)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(.quaternary)
)
VStack(alignment: .leading) {
Text("AppLogo")
.font(.subheadline)
.fontWeight(.medium)
Text("In-app icon display")
.font(.caption)
.foregroundColor(.secondary)
}
}
}
Divider()
// Mock App Icons
VStack {
Text("App Icon Variants")
.font(.headline)
HStack(spacing: 20) {
VStack {
RoundedRectangle(cornerRadius: 16)
.fill(.white)
.frame(width: 64, height: 64)
.overlay {
ZStack {
// Left mountain (yellow/amber)
Polygon(points: [
CGPoint(x: 0.2, y: 0.8),
CGPoint(x: 0.45, y: 0.3),
CGPoint(x: 0.7, y: 0.8),
])
.fill(Color(red: 1.0, green: 0.76, blue: 0.03))
.stroke(
Color(red: 0.11, green: 0.11, blue: 0.11),
lineWidth: 0.5)
// Right mountain (red), overlapping
Polygon(points: [
CGPoint(x: 0.5, y: 0.8),
CGPoint(x: 0.75, y: 0.2),
CGPoint(x: 1.0, y: 0.8),
])
.fill(Color(red: 0.96, green: 0.26, blue: 0.21))
.stroke(
Color(red: 0.11, green: 0.11, blue: 0.11),
lineWidth: 0.5)
}
}
Text("Light")
.font(.caption)
}
VStack {
RoundedRectangle(cornerRadius: 16)
.fill(Color(red: 0.1, green: 0.1, blue: 0.1))
.frame(width: 64, height: 64)
.overlay {
ZStack {
// Left mountain (yellow/amber)
Polygon(points: [
CGPoint(x: 0.2, y: 0.8),
CGPoint(x: 0.45, y: 0.3),
CGPoint(x: 0.7, y: 0.8),
])
.fill(Color(red: 1.0, green: 0.76, blue: 0.03))
.stroke(
Color(red: 0.11, green: 0.11, blue: 0.11),
lineWidth: 0.5)
// Right mountain (red), overlapping
Polygon(points: [
CGPoint(x: 0.5, y: 0.8),
CGPoint(x: 0.75, y: 0.2),
CGPoint(x: 1.0, y: 0.8),
])
.fill(Color(red: 0.96, green: 0.26, blue: 0.21))
.stroke(
Color(red: 0.11, green: 0.11, blue: 0.11),
lineWidth: 0.5)
}
}
Text("Dark")
.font(.caption)
}
VStack {
RoundedRectangle(cornerRadius: 16)
.fill(.clear)
.frame(width: 64, height: 64)
.overlay {
ZStack {
// Left mountain (monochrome)
Polygon(points: [
CGPoint(x: 0.2, y: 0.8),
CGPoint(x: 0.45, y: 0.3),
CGPoint(x: 0.7, y: 0.8),
])
.fill(.black.opacity(0.8))
.stroke(.black, lineWidth: 0.5)
// Right mountain (monochrome), overlapping
Polygon(points: [
CGPoint(x: 0.5, y: 0.8),
CGPoint(x: 0.75, y: 0.2),
CGPoint(x: 1.0, y: 0.8),
])
.fill(.black.opacity(0.9))
.stroke(.black, lineWidth: 0.5)
}
}
Text("Tinted")
.font(.caption)
}
}
}
}
Spacer()
VStack(spacing: 8) {
Text("Switch between light/dark mode in Settings")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
Text("The icon should adapt automatically")
.font(.caption)
.foregroundColor(.secondary)
}
}
.padding()
.navigationTitle("Icon Test")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") {
dismiss()
}
}
}
}
}
}
extension ColorScheme {
var description: String {
switch self {
case .light:
return "Light"
case .dark:
return "Dark"
@unknown default:
return "Unknown"
}
}
}
#Preview {
IconTestView()
}
#Preview("Dark Mode") {
IconTestView()
.preferredColorScheme(.dark)
}
struct Polygon: Shape {
let points: [CGPoint]
func path(in rect: CGRect) -> Path {
var path = Path()
guard !points.isEmpty else { return path }
let scaledPoints = points.map { point in
CGPoint(
x: point.x * rect.width,
y: point.y * rect.height
)
}
path.move(to: scaledPoints[0])
for point in scaledPoints.dropFirst() {
path.addLine(to: point)
}
path.closeSubpath()
return path
}
}
#endif

View File

@@ -0,0 +1,955 @@
import Foundation
import ImageIO
import SwiftUI
import UIKit
class ImageManager {
static let shared = ImageManager()
private let thumbnailCache = NSCache<NSString, UIImage>()
private let fileManager = FileManager.default
private let appSupportDirectoryName = "Ascently"
private let imagesDirectoryName = "Images"
private let backupDirectoryName = "ImageBackups"
private let migrationStateFile = "migration_state.json"
private let migrationLockFile = "migration.lock"
// Legacy directory name for migration
private let legacyAppSupportDirectoryName = "OpenClimb"
private init() {
migrateFromOpenClimbDirectoryIfNeeded()
createDirectoriesIfNeeded()
// Debug-safe initialization with extra checks
let recoveryPerformed = debugSafeInitialization()
if !recoveryPerformed {
performRobustMigration()
}
// Final integrity check
if !validateStorageIntegrity() {
print("CRITICAL: Storage integrity compromised - attempting emergency recovery")
emergencyImageRestore()
}
logDirectoryInfo()
}
var appSupportDirectory: URL {
let urls = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask)
return urls.first!.appendingPathComponent(appSupportDirectoryName)
}
var imagesDirectory: URL {
appSupportDirectory.appendingPathComponent(imagesDirectoryName)
}
var backupDirectory: URL {
appSupportDirectory.appendingPathComponent(backupDirectoryName)
}
func getImagesDirectoryPath() -> String {
return imagesDirectory.path
}
private var legacyDocumentsDirectory: URL {
fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!
}
var legacyImagesDirectory: URL {
legacyDocumentsDirectory.appendingPathComponent("OpenClimbImages")
}
/// Legacy OpenClimb app support directory for migration
private var legacyOpenClimbAppSupportDirectory: URL? {
guard
let appSupportBase = fileManager.urls(
for: .applicationSupportDirectory, in: .userDomainMask
).first
else {
return nil
}
return appSupportBase.appendingPathComponent(legacyAppSupportDirectoryName)
}
/// Migrate images from OpenClimb directory to Ascently directory
private func migrateFromOpenClimbDirectoryIfNeeded() {
guard let legacyDir = legacyOpenClimbAppSupportDirectory,
fileManager.fileExists(atPath: legacyDir.path),
!fileManager.fileExists(atPath: appSupportDirectory.path)
else {
return
}
print("🔄 Migrating images from OpenClimb to Ascently directory...")
do {
// Create parent directory if needed
try fileManager.createDirectory(
at: appSupportDirectory.deletingLastPathComponent(),
withIntermediateDirectories: true,
attributes: nil)
// Move the entire directory
try fileManager.moveItem(at: legacyDir, to: appSupportDirectory)
print("Successfully migrated image directory from OpenClimb to Ascently")
} catch {
print("❌ Failed to migrate image directory: \(error)")
// If move fails, try to copy instead
do {
try fileManager.copyItem(at: legacyDir, to: appSupportDirectory)
print("Successfully copied image directory from OpenClimb to Ascently")
// Don't remove the old directory in case of issues
} catch {
print("❌ Failed to copy image directory: \(error)")
}
}
}
var legacyImportImagesDirectory: URL {
legacyDocumentsDirectory.appendingPathComponent("images")
}
private func createDirectoriesIfNeeded() {
// Create Application Support structure
[appSupportDirectory, imagesDirectory, backupDirectory].forEach { directory in
if !fileManager.fileExists(atPath: directory.path) {
do {
try fileManager.createDirectory(
at: directory, withIntermediateDirectories: true,
attributes: [
.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication
])
print("Created directory: \(directory.path)")
} catch {
print("ERROR: Failed to create directory \(directory.path): \(error)")
}
}
}
// Exclude from iCloud backup to prevent storage issues
excludeFromiCloudBackup()
}
private func excludeFromiCloudBackup() {
do {
var resourceValues = URLResourceValues()
resourceValues.isExcludedFromBackup = true
var imagesURL = imagesDirectory
var backupURL = backupDirectory
try imagesURL.setResourceValues(resourceValues)
try backupURL.setResourceValues(resourceValues)
print("Excluded image directories from iCloud backup")
} catch {
print("WARNING: Failed to exclude from iCloud backup: \(error)")
}
}
private struct MigrationState: Codable {
let version: Int
let startTime: Date
let completedFiles: [String]
let totalFiles: Int
let isComplete: Bool
let lastCheckpoint: Date
static let currentVersion = 2
}
private var migrationStateURL: URL {
appSupportDirectory.appendingPathComponent(migrationStateFile)
}
private var migrationLockURL: URL {
appSupportDirectory.appendingPathComponent(migrationLockFile)
}
private func performRobustMigration() {
print("Starting robust image migration system...")
// Check for interrupted migration
if let incompleteState = loadMigrationState() {
print("Detected interrupted migration, resuming...")
resumeMigration(from: incompleteState)
} else {
// Start fresh migration
startNewMigration()
}
// Always verify migration integrity
verifyMigrationIntegrity()
// Clean up migration state files
cleanupMigrationState()
}
private func startNewMigration() {
// First check for images in previous Application Support directories
if let previousAppSupportImages = findPreviousAppSupportImages() {
print("Found images in previous Application Support directory")
migratePreviousAppSupportImages(from: previousAppSupportImages)
return
}
// Check if legacy directories exist
let hasLegacyImages = fileManager.fileExists(atPath: legacyImagesDirectory.path)
let hasLegacyImportImages = fileManager.fileExists(atPath: legacyImportImagesDirectory.path)
guard hasLegacyImages || hasLegacyImportImages else {
print("No legacy images to migrate")
return
}
// Create migration lock
createMigrationLock()
do {
var allLegacyFiles: [String] = []
// Collect files from OpenClimbImages directory
if fileManager.fileExists(atPath: legacyImagesDirectory.path) {
let legacyFiles = try fileManager.contentsOfDirectory(
atPath: legacyImagesDirectory.path)
allLegacyFiles.append(contentsOf: legacyFiles)
print("Found \(legacyFiles.count) images in OpenClimbImages")
}
// Collect files from Documents/images directory
if fileManager.fileExists(atPath: legacyImportImagesDirectory.path) {
let importFiles = try fileManager.contentsOfDirectory(
atPath: legacyImportImagesDirectory.path)
allLegacyFiles.append(contentsOf: importFiles)
print("Found \(importFiles.count) images in Documents/images")
}
print("Total legacy images to migrate: \(allLegacyFiles.count)")
let initialState = MigrationState(
version: MigrationState.currentVersion,
startTime: Date(),
completedFiles: [],
totalFiles: allLegacyFiles.count,
isComplete: false,
lastCheckpoint: Date()
)
saveMigrationState(initialState)
performMigrationWithCheckpoints(files: allLegacyFiles, currentState: initialState)
} catch {
print("ERROR: Failed to start migration: \(error)")
}
}
private func resumeMigration(from state: MigrationState) {
print("Resuming migration from checkpoint...")
print("Progress: \(state.completedFiles.count)/\(state.totalFiles)")
do {
let legacyFiles = try fileManager.contentsOfDirectory(
atPath: legacyImagesDirectory.path)
let remainingFiles = legacyFiles.filter { !state.completedFiles.contains($0) }
print("Resuming with \(remainingFiles.count) remaining files")
performMigrationWithCheckpoints(files: remainingFiles, currentState: state)
} catch {
print("ERROR: Failed to resume migration: \(error)")
// Fallback: start fresh
removeMigrationState()
startNewMigration()
}
}
private func performMigrationWithCheckpoints(files: [String], currentState: MigrationState) {
var migratedCount = currentState.completedFiles.count
var failedCount = 0
var completedFiles = currentState.completedFiles
for (index, fileName) in files.enumerated() {
autoreleasepool {
// Check both legacy directories for the file
var legacyFilePath: URL?
if fileManager.fileExists(
atPath: legacyImagesDirectory.appendingPathComponent(fileName).path)
{
legacyFilePath = legacyImagesDirectory.appendingPathComponent(fileName)
} else if fileManager.fileExists(
atPath: legacyImportImagesDirectory.appendingPathComponent(fileName).path)
{
legacyFilePath = legacyImportImagesDirectory.appendingPathComponent(fileName)
}
guard let sourcePath = legacyFilePath else {
completedFiles.append(fileName)
return
}
let newFilePath = imagesDirectory.appendingPathComponent(fileName)
let backupPath = backupDirectory.appendingPathComponent(fileName)
// Skip if already exists in new location
if fileManager.fileExists(atPath: newFilePath.path) {
completedFiles.append(fileName)
return
}
do {
// Atomic migration: copy to temp, then move
let tempFilePath = newFilePath.appendingPathExtension("tmp")
// Copy to temp location first
try fileManager.copyItem(at: sourcePath, to: tempFilePath)
// Verify file integrity
let originalData = try Data(contentsOf: sourcePath)
let copiedData = try Data(contentsOf: tempFilePath)
guard originalData == copiedData else {
try? fileManager.removeItem(at: tempFilePath)
throw NSError(
domain: "MigrationError", code: 1,
userInfo: [NSLocalizedDescriptionKey: "File integrity check failed"])
}
// Move from temp to final location
try fileManager.moveItem(at: tempFilePath, to: newFilePath)
// Create backup copy
try? fileManager.copyItem(at: newFilePath, to: backupPath)
completedFiles.append(fileName)
migratedCount += 1
print("Migrated: \(fileName) (\(migratedCount)/\(currentState.totalFiles))")
} catch {
failedCount += 1
print("ERROR: Failed to migrate \(fileName): \(error)")
}
// Save checkpoint every 5 files or if interrupted
if (index + 1) % 5 == 0 {
let checkpointState = MigrationState(
version: MigrationState.currentVersion,
startTime: currentState.startTime,
completedFiles: completedFiles,
totalFiles: currentState.totalFiles,
isComplete: false,
lastCheckpoint: Date()
)
saveMigrationState(checkpointState)
print("Checkpoint saved: \(completedFiles.count)/\(currentState.totalFiles)")
}
}
}
// Mark migration as complete
let finalState = MigrationState(
version: MigrationState.currentVersion,
startTime: currentState.startTime,
completedFiles: completedFiles,
totalFiles: currentState.totalFiles,
isComplete: true,
lastCheckpoint: Date()
)
saveMigrationState(finalState)
print("Migration complete: \(migratedCount) migrated, \(failedCount) failed")
// Clean up legacy directory if no failures
if failedCount == 0 {
cleanupLegacyDirectory()
}
}
private func verifyMigrationIntegrity() {
print("Verifying migration integrity...")
var allLegacyFiles = Set<String>()
// Collect files from both legacy directories
do {
if fileManager.fileExists(atPath: legacyImagesDirectory.path) {
let legacyFiles = Set(
try fileManager.contentsOfDirectory(atPath: legacyImagesDirectory.path))
allLegacyFiles.formUnion(legacyFiles)
}
if fileManager.fileExists(atPath: legacyImportImagesDirectory.path) {
let importFiles = Set(
try fileManager.contentsOfDirectory(atPath: legacyImportImagesDirectory.path))
allLegacyFiles.formUnion(importFiles)
}
} catch {
print("ERROR: Failed to read legacy directories: \(error)")
return
}
guard !allLegacyFiles.isEmpty else {
print("No legacy directories to verify against")
return
}
do {
let migratedFiles = Set(
try fileManager.contentsOfDirectory(atPath: imagesDirectory.path))
let missingFiles = allLegacyFiles.subtracting(migratedFiles)
if missingFiles.isEmpty {
print("Migration integrity verified - all files present")
cleanupLegacyDirectory()
} else {
print("WARNING: Missing \(missingFiles.count) files, re-triggering migration")
// Re-trigger migration for missing files
performMigrationWithCheckpoints(
files: Array(missingFiles),
currentState: MigrationState(
version: MigrationState.currentVersion,
startTime: Date(),
completedFiles: [],
totalFiles: missingFiles.count,
isComplete: false,
lastCheckpoint: Date()
))
}
} catch {
print("ERROR: Failed to verify migration integrity: \(error)")
}
}
private func cleanupLegacyDirectory() {
do {
try fileManager.removeItem(at: legacyImagesDirectory)
print("Cleaned up legacy directory")
} catch {
print("WARNING: Failed to clean up legacy directory: \(error)")
}
}
private func loadMigrationState() -> MigrationState? {
guard fileManager.fileExists(atPath: migrationStateURL.path) else {
return nil
}
// Check if migration was interrupted (lock file exists)
if !fileManager.fileExists(atPath: migrationLockURL.path) {
// Migration completed normally, clean up state
removeMigrationState()
return nil
}
do {
let data = try Data(contentsOf: migrationStateURL)
let state = try JSONDecoder().decode(MigrationState.self, from: data)
// Check if state is too old (more than 1 hour)
if Date().timeIntervalSince(state.lastCheckpoint) > 3600 {
print("WARNING: Migration state is stale, starting fresh")
removeMigrationState()
return nil
}
return state.isComplete ? nil : state
} catch {
print("ERROR: Failed to load migration state: \(error)")
removeMigrationState()
return nil
}
}
private func saveMigrationState(_ state: MigrationState) {
do {
let data = try JSONEncoder().encode(state)
try data.write(to: migrationStateURL)
} catch {
print("ERROR: Failed to save migration state: \(error)")
}
}
private func removeMigrationState() {
try? fileManager.removeItem(at: migrationStateURL)
}
private func createMigrationLock() {
let lockData = "Migration in progress - \(Date())".data(using: .utf8) ?? Data()
try? lockData.write(to: migrationLockURL)
}
private func cleanupMigrationState() {
try? fileManager.removeItem(at: migrationStateURL)
try? fileManager.removeItem(at: migrationLockURL)
print("Cleaned up migration state files")
}
func saveImageData(_ data: Data, withName name: String? = nil) -> String? {
let fileName = name ?? "\(UUID().uuidString).jpg"
let primaryPath = imagesDirectory.appendingPathComponent(fileName)
let backupPath = backupDirectory.appendingPathComponent(fileName)
do {
// Save to primary location
try data.write(to: primaryPath)
// Create backup copy
try data.write(to: backupPath)
print("Saved image with backup: \(fileName)")
return fileName
} catch {
print("ERROR: Failed to save image \(fileName): \(error)")
return nil
}
}
func loadImageData(fromPath path: String) -> Data? {
let primaryPath = getFullPath(from: path)
let backupPath = backupDirectory.appendingPathComponent(getRelativePath(from: path))
// Try primary location first
if fileManager.fileExists(atPath: primaryPath),
let data = try? Data(contentsOf: URL(fileURLWithPath: primaryPath))
{
return data
}
// Fallback to backup location
if fileManager.fileExists(atPath: backupPath.path),
let data = try? Data(contentsOf: backupPath)
{
print("Restored image from backup: \(path)")
// Restore to primary location
try? data.write(to: URL(fileURLWithPath: primaryPath))
return data
}
return nil
}
func loadThumbnail(fromPath path: String, targetSize: CGSize) async -> UIImage? {
let cacheKey = "\(path)-\(targetSize.width)x\(targetSize.height)" as NSString
if let cachedImage = thumbnailCache.object(forKey: cacheKey) {
return cachedImage
}
guard let imageData = loadImageData(fromPath: path) else {
return nil
}
let options: [CFString: Any] = [
kCGImageSourceCreateThumbnailFromImageIfAbsent: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceShouldCacheImmediately: true,
kCGImageSourceThumbnailMaxPixelSize: max(targetSize.width, targetSize.height)
* UIScreen.main.scale,
]
guard let imageSource = CGImageSourceCreateWithData(imageData as CFData, nil) else {
return UIImage(data: imageData)
}
let properties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as? [CFString: Any]
let orientation = properties?[kCGImagePropertyOrientation] as? UInt32 ?? 1
if let cgImage = CGImageSourceCreateThumbnailAtIndex(
imageSource, 0, options as CFDictionary)
{
let imageOrientation = UIImage.Orientation(rawValue: Int(orientation - 1)) ?? .up
let thumbnail = UIImage(
cgImage: cgImage, scale: UIScreen.main.scale, orientation: imageOrientation)
thumbnailCache.setObject(thumbnail, forKey: cacheKey)
return thumbnail
} else {
if let fallbackImage = UIImage(data: imageData) {
thumbnailCache.setObject(fallbackImage, forKey: cacheKey)
return fallbackImage
}
}
return nil
}
func imageExists(atPath path: String) -> Bool {
let primaryPath = getFullPath(from: path)
let backupPath = backupDirectory.appendingPathComponent(getRelativePath(from: path))
return fileManager.fileExists(atPath: primaryPath)
|| fileManager.fileExists(atPath: backupPath.path)
}
func deleteImage(atPath path: String) -> Bool {
let primaryPath = getFullPath(from: path)
let backupPath = backupDirectory.appendingPathComponent(getRelativePath(from: path))
var success = true
// Delete from primary location
if fileManager.fileExists(atPath: primaryPath) {
do {
try fileManager.removeItem(atPath: primaryPath)
} catch {
print("ERROR: Failed to delete primary image at \(primaryPath): \(error)")
success = false
}
}
// Delete from backup location
if fileManager.fileExists(atPath: backupPath.path) {
do {
try fileManager.removeItem(at: backupPath)
} catch {
print("ERROR: Failed to delete backup image at \(backupPath.path): \(error)")
success = false
}
}
return success
}
func deleteImages(atPaths paths: [String]) {
for path in paths {
_ = deleteImage(atPath: path)
}
}
func getFullPath(from relativePath: String) -> String {
// If it's already a full path, check if it's legacy and needs migration
if relativePath.hasPrefix("/") {
// If it's pointing to legacy Documents directory, redirect to new location
if relativePath.contains("Documents/OpenClimbImages") {
let fileName = URL(fileURLWithPath: relativePath).lastPathComponent
return imagesDirectory.appendingPathComponent(fileName).path
}
return relativePath
}
// For relative paths, use the persistent Application Support location
return imagesDirectory.appendingPathComponent(relativePath).path
}
func getRelativePath(from fullPath: String) -> String {
if !fullPath.hasPrefix("/") {
return fullPath
}
return URL(fileURLWithPath: fullPath).lastPathComponent
}
func performMaintenance() {
print("Starting image maintenance...")
syncBackups()
validateImageIntegrity()
cleanupOrphanedFiles()
}
private func syncBackups() {
do {
let primaryFiles = try fileManager.contentsOfDirectory(atPath: imagesDirectory.path)
let backupFiles = Set(try fileManager.contentsOfDirectory(atPath: backupDirectory.path))
for fileName in primaryFiles {
if !backupFiles.contains(fileName) {
let primaryPath = imagesDirectory.appendingPathComponent(fileName)
let backupPath = backupDirectory.appendingPathComponent(fileName)
try? fileManager.copyItem(at: primaryPath, to: backupPath)
print("Created missing backup for: \(fileName)")
}
}
} catch {
print("ERROR: Failed to sync backups: \(error)")
}
}
private func validateImageIntegrity() {
do {
let files = try fileManager.contentsOfDirectory(atPath: imagesDirectory.path)
var validFiles = 0
for fileName in files {
let filePath = imagesDirectory.appendingPathComponent(fileName)
if let data = try? Data(contentsOf: filePath), data.count > 0 {
// Basic validation - check if file has content and is reasonable size
if data.count > 100 { // Minimum viable image size
validFiles += 1
}
}
}
print("Validated \(validFiles) of \(files.count) image files")
} catch {
print("ERROR: Failed to validate images: \(error)")
}
}
private func cleanupOrphanedFiles() {
// This would need access to the data manager to check which files are actually referenced
print("Cleanup would require coordination with data manager")
}
func getStorageInfo() -> (primaryCount: Int, backupCount: Int, totalSize: Int64) {
let primaryCount =
((try? fileManager.contentsOfDirectory(atPath: imagesDirectory.path)) ?? []).count
let backupCount =
((try? fileManager.contentsOfDirectory(atPath: backupDirectory.path)) ?? []).count
var totalSize: Int64 = 0
[imagesDirectory, backupDirectory].forEach { directory in
if let enumerator = fileManager.enumerator(
at: directory, includingPropertiesForKeys: [.fileSizeKey])
{
for case let url as URL in enumerator {
if let size = try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize {
totalSize += Int64(size)
}
}
}
}
return (primaryCount, backupCount, totalSize)
}
private func logDirectoryInfo() {
let info = getStorageInfo()
let previousDir = findPreviousAppSupportImages()
print(
"""
Ascently Image Storage:
- App Support: \(appSupportDirectory.path)
- Images: \(imagesDirectory.path) (\(info.primaryCount) files)
- Backups: \(backupDirectory.path) (\(info.backupCount) files)
- Previous Dir: \(previousDir?.path ?? "None found")
- Legacy Dir: \(legacyImagesDirectory.path) (exists: \(fileManager.fileExists(atPath: legacyImagesDirectory.path)))
- Legacy Import Dir: \(legacyImportImagesDirectory.path) (exists: \(fileManager.fileExists(atPath: legacyImportImagesDirectory.path)))
- Total Size: \(info.totalSize / 1024)KB
""")
}
func forceRecoveryMigration() {
print("FORCE RECOVERY: Starting manual migration recovery...")
// Remove any stale state
removeMigrationState()
try? fileManager.removeItem(at: migrationLockURL)
// Force fresh migration
startNewMigration()
print("FORCE RECOVERY: Migration recovery completed")
}
func saveImportedImage(_ imageData: Data, filename: String) throws -> String {
let imagePath = imagesDirectory.appendingPathComponent(filename)
let backupPath = backupDirectory.appendingPathComponent(filename)
// Save to main directory
try imageData.write(to: imagePath)
// Create backup
try? imageData.write(to: backupPath)
print("Imported image: \(filename)")
return filename
}
func emergencyImageRestore() {
print("EMERGENCY: Attempting image restoration...")
// Try to restore from backup directory
do {
let backupFiles = try fileManager.contentsOfDirectory(atPath: backupDirectory.path)
var restoredCount = 0
for fileName in backupFiles {
let backupPath = backupDirectory.appendingPathComponent(fileName)
let primaryPath = imagesDirectory.appendingPathComponent(fileName)
// Only restore if primary doesn't exist
if !fileManager.fileExists(atPath: primaryPath.path) {
try? fileManager.copyItem(at: backupPath, to: primaryPath)
restoredCount += 1
}
}
print("EMERGENCY: Restored \(restoredCount) images from backup")
} catch {
print("EMERGENCY: Failed to restore from backup: \(error)")
}
// Try previous Application Support directories first
if let previousAppSupportImages = findPreviousAppSupportImages() {
print("EMERGENCY: Found previous Application Support images, migrating...")
migratePreviousAppSupportImages(from: previousAppSupportImages)
return
}
// Try legacy migration as last resort
if fileManager.fileExists(atPath: legacyImagesDirectory.path)
|| fileManager.fileExists(atPath: legacyImportImagesDirectory.path)
{
print("EMERGENCY: Attempting legacy migration as fallback...")
forceRecoveryMigration()
}
}
func debugSafeInitialization() -> Bool {
print("DEBUG SAFE: Performing debug-safe initialization check...")
// Check if we're in a debug environment
#if DEBUG
print("DEBUG SAFE: Debug environment detected")
// Check for interrupted migration more aggressively
if fileManager.fileExists(atPath: migrationLockURL.path) {
print("DEBUG SAFE: Found migration lock - likely debug interruption")
// Give extra time for file system to stabilize
Thread.sleep(forTimeInterval: 1.0)
// Try emergency recovery
emergencyImageRestore()
// Clean up lock
try? fileManager.removeItem(at: migrationLockURL)
return true
}
#endif
// Check if primary storage is empty but backup exists
let primaryEmpty =
(try? fileManager.contentsOfDirectory(atPath: imagesDirectory.path).isEmpty) ?? true
let backupHasFiles =
((try? fileManager.contentsOfDirectory(atPath: backupDirectory.path)) ?? []).count > 0
if primaryEmpty && backupHasFiles {
print("DEBUG SAFE: Primary empty but backup exists - restoring")
emergencyImageRestore()
return true
}
// Check if primary storage is empty but previous Application Support images exist
if primaryEmpty, let previousAppSupportImages = findPreviousAppSupportImages() {
print("DEBUG SAFE: Primary empty but found previous Application Support images")
migratePreviousAppSupportImages(from: previousAppSupportImages)
return true
}
return false
}
func validateStorageIntegrity() -> Bool {
let primaryFiles = Set(
(try? fileManager.contentsOfDirectory(atPath: imagesDirectory.path)) ?? [])
let backupFiles = Set(
(try? fileManager.contentsOfDirectory(atPath: backupDirectory.path)) ?? [])
// Check if we have more backups than primary files (sign of corruption)
if backupFiles.count > primaryFiles.count + 5 {
print(
"WARNING INTEGRITY: Backup count significantly exceeds primary - potential corruption"
)
return false
}
// Check if primary is completely empty but we have data elsewhere
if primaryFiles.isEmpty && !backupFiles.isEmpty {
print("WARNING INTEGRITY: Primary storage empty but backups exist")
return false
}
return true
}
func findPreviousAppSupportImages() -> URL? {
// Get the Application Support base directory
guard
let appSupportBase = fileManager.urls(
for: .applicationSupportDirectory, in: .userDomainMask
).first
else {
print("ERROR: Could not access Application Support directory")
return nil
}
// Look for OpenClimb directories in Application Support
do {
let contents = try fileManager.contentsOfDirectory(
at: appSupportBase, includingPropertiesForKeys: nil)
for url in contents {
var isDirectory: ObjCBool = false
guard fileManager.fileExists(atPath: url.path, isDirectory: &isDirectory),
isDirectory.boolValue
else {
continue
}
// Check if it's an OpenClimb or Ascently directory but not the current one
if (url.lastPathComponent.contains("OpenClimb")
|| url.lastPathComponent.contains("Ascently"))
&& url.path != appSupportDirectory.path
{
let imagesDir = url.appendingPathComponent(imagesDirectoryName)
if fileManager.fileExists(atPath: imagesDir.path) {
let imageFiles =
(try? fileManager.contentsOfDirectory(atPath: imagesDir.path)) ?? []
if !imageFiles.isEmpty {
return imagesDir
}
}
}
}
} catch {
print("ERROR: Error scanning for previous Application Support directories: \(error)")
}
return nil
}
private func migratePreviousAppSupportImages(from sourceDirectory: URL) {
print("Migrating images from previous Application Support directory")
do {
let imageFiles = try fileManager.contentsOfDirectory(atPath: sourceDirectory.path)
for fileName in imageFiles {
autoreleasepool {
let sourcePath = sourceDirectory.appendingPathComponent(fileName)
let destinationPath = imagesDirectory.appendingPathComponent(fileName)
let backupPath = backupDirectory.appendingPathComponent(fileName)
// Skip if already exists in destination
if fileManager.fileExists(atPath: destinationPath.path) {
return
}
do {
// Copy to main directory
try fileManager.copyItem(at: sourcePath, to: destinationPath)
// Create backup
try? fileManager.copyItem(at: sourcePath, to: backupPath)
print("Migrated: \(fileName)")
} catch {
print("ERROR: Failed to migrate \(fileName): \(error)")
}
}
}
print("Completed migration from previous Application Support directory")
} catch {
print("ERROR: Failed to migrate from previous Application Support: \(error)")
}
}
}

View File

@@ -0,0 +1,160 @@
//
// ImageNamingUtils.swift
import CryptoKit
import Foundation
/// Utility for creating consistent image filenames across platforms
class ImageNamingUtils {
private static let imageExtension = ".jpg"
private static let hashLength = 12
/// Generates a deterministic filename for a problem image
static func generateImageFilename(problemId: String, imageIndex: Int) -> String {
let input = "\(problemId)_\(imageIndex)"
let hash = createHash(from: input)
return "problem_\(hash)_\(imageIndex)\(imageExtension)"
}
/// Legacy method for backward compatibility
static func generateImageFilename(problemId: String, timestamp: String, imageIndex: Int)
-> String
{
return generateImageFilename(problemId: problemId, imageIndex: imageIndex)
}
/// Extracts problem ID from an image filename
static func extractProblemIdFromFilename(_ filename: String) -> String? {
guard filename.hasPrefix("problem_") && filename.hasSuffix(imageExtension) else {
return nil
}
let nameWithoutExtension = String(filename.dropLast(imageExtension.count))
let parts = nameWithoutExtension.components(separatedBy: "_")
guard parts.count == 3 && parts[0] == "problem" else {
return nil
}
return parts[1]
}
/// Validates if a filename follows our naming convention
static func isValidImageFilename(_ filename: String) -> Bool {
guard filename.hasPrefix("problem_") && filename.hasSuffix(imageExtension) else {
return false
}
let nameWithoutExtension = String(filename.dropLast(imageExtension.count))
let parts = nameWithoutExtension.components(separatedBy: "_")
return parts.count == 3 && parts[0] == "problem" && parts[1].count == hashLength
&& Int(parts[2]) != nil
}
/// Migrates an existing filename to our naming convention
static func migrateFilename(oldFilename: String, problemId: String, imageIndex: Int) -> String {
if isValidImageFilename(oldFilename) {
return oldFilename
}
return generateImageFilename(problemId: problemId, imageIndex: imageIndex)
}
/// Creates a deterministic hash from input string
private static func createHash(from input: String) -> String {
let inputData = Data(input.utf8)
let hashed = SHA256.hash(data: inputData)
let hashString = hashed.compactMap { String(format: "%02x", $0) }.joined()
return String(hashString.prefix(hashLength))
}
/// Batch renames images for a problem to use our naming convention
static func batchRenameForProblem(problemId: String, existingFilenames: [String]) -> [String:
String]
{
var renameMap: [String: String] = [:]
for (index, oldFilename) in existingFilenames.enumerated() {
let newFilename = generateImageFilename(problemId: problemId, imageIndex: index)
if newFilename != oldFilename {
renameMap[oldFilename] = newFilename
}
}
return renameMap
}
/// Validates that a collection of filenames follow our naming convention
static func validateFilenames(_ filenames: [String]) -> ImageValidationResult {
var validImages: [String] = []
var invalidImages: [String] = []
for filename in filenames {
if isValidImageFilename(filename) {
validImages.append(filename)
} else {
invalidImages.append(filename)
}
}
return ImageValidationResult(
totalImages: filenames.count,
validImages: validImages,
invalidImages: invalidImages
)
}
/// Generates the canonical filename that should be used for a problem image
static func getCanonicalImageFilename(problemId: String, imageIndex: Int) -> String {
return generateImageFilename(problemId: problemId, imageIndex: imageIndex)
}
/// Creates a mapping of existing server filenames to canonical filenames
static func createServerMigrationMap(
problemId: String,
serverImageFilenames: [String],
localImageCount: Int
) -> [String: String] {
var migrationMap: [String: String] = [:]
for imageIndex in 0..<localImageCount {
let canonicalName = getCanonicalImageFilename(
problemId: problemId, imageIndex: imageIndex)
if serverImageFilenames.contains(canonicalName) {
continue
}
for serverFilename in serverImageFilenames {
if isValidImageFilename(serverFilename)
&& !migrationMap.values.contains(serverFilename)
{
migrationMap[serverFilename] = canonicalName
break
}
}
}
return migrationMap
}
}
// Result of image filename validation
struct ImageValidationResult {
let totalImages: Int
let validImages: [String]
let invalidImages: [String]
var isAllValid: Bool {
return invalidImages.isEmpty
}
var validPercentage: Double {
guard totalImages > 0 else { return 100.0 }
return (Double(validImages.count) / Double(totalImages)) * 100.0
}
}

View File

@@ -0,0 +1,147 @@
import SwiftUI
import UIKit
struct OrientationAwareImage: View {
let imagePath: String
let contentMode: ContentMode
@State private var uiImage: UIImage?
@State private var isLoading = true
@State private var hasFailed = false
init(imagePath: String, contentMode: ContentMode = .fit) {
self.imagePath = imagePath
self.contentMode = contentMode
}
var body: some View {
Group {
if let uiImage = uiImage {
Image(uiImage: uiImage)
.resizable()
.aspectRatio(contentMode: contentMode)
} else if hasFailed {
Image(systemName: "photo")
.foregroundColor(.gray)
} else {
ProgressView()
.progressViewStyle(CircularProgressViewStyle())
}
}
.onAppear {
loadImageWithCorrectOrientation()
}
.onChange(of: imagePath) { _ in
loadImageWithCorrectOrientation()
}
}
private func loadImageWithCorrectOrientation() {
Task.detached(priority: .userInitiated) {
let correctedImage = await loadAndCorrectImage()
await MainActor.run {
self.uiImage = correctedImage
self.isLoading = false
self.hasFailed = correctedImage == nil
}
}
}
private func loadAndCorrectImage() async -> UIImage? {
guard let data = ImageManager.shared.loadImageData(fromPath: imagePath) else { return nil }
guard let originalImage = UIImage(data: data) else { return nil }
return correctImageOrientation(originalImage)
}
/// Corrects the orientation of a UIImage based on its EXIF data
private func correctImageOrientation(_ image: UIImage) -> UIImage {
// If the image is already in the correct orientation, return as-is
if image.imageOrientation == .up {
return image
}
// Calculate the proper transformation matrix
var transform = CGAffineTransform.identity
switch image.imageOrientation {
case .down, .downMirrored:
transform = transform.translatedBy(x: image.size.width, y: image.size.height)
transform = transform.rotated(by: .pi)
case .left, .leftMirrored:
transform = transform.translatedBy(x: image.size.width, y: 0)
transform = transform.rotated(by: .pi / 2)
case .right, .rightMirrored:
transform = transform.translatedBy(x: 0, y: image.size.height)
transform = transform.rotated(by: -.pi / 2)
case .up, .upMirrored:
break
@unknown default:
break
}
switch image.imageOrientation {
case .upMirrored, .downMirrored:
transform = transform.translatedBy(x: image.size.width, y: 0)
transform = transform.scaledBy(x: -1, y: 1)
case .leftMirrored, .rightMirrored:
transform = transform.translatedBy(x: image.size.height, y: 0)
transform = transform.scaledBy(x: -1, y: 1)
case .up, .down, .left, .right:
break
@unknown default:
break
}
// Create a new image context and apply the transformation
guard let cgImage = image.cgImage else { return image }
let context = CGContext(
data: nil,
width: Int(image.size.width),
height: Int(image.size.height),
bitsPerComponent: cgImage.bitsPerComponent,
bytesPerRow: 0,
space: cgImage.colorSpace ?? CGColorSpaceCreateDeviceRGB(),
bitmapInfo: cgImage.bitmapInfo.rawValue
)
guard let ctx = context else { return image }
ctx.concatenate(transform)
switch image.imageOrientation {
case .left, .leftMirrored, .right, .rightMirrored:
ctx.draw(
cgImage, in: CGRect(x: 0, y: 0, width: image.size.height, height: image.size.width))
default:
ctx.draw(
cgImage, in: CGRect(x: 0, y: 0, width: image.size.width, height: image.size.height))
}
guard let newCGImage = ctx.makeImage() else { return image }
return UIImage(cgImage: newCGImage)
}
}
// MARK: - Convenience Extensions
extension OrientationAwareImage {
/// Creates an orientation-aware image with fill content mode
static func fill(imagePath: String) -> OrientationAwareImage {
OrientationAwareImage(imagePath: imagePath, contentMode: .fill)
}
/// Creates an orientation-aware image with fit content mode
static func fit(imagePath: String) -> OrientationAwareImage {
OrientationAwareImage(imagePath: imagePath, contentMode: .fit)
}
}

View File

@@ -0,0 +1,632 @@
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: ClimbDataBackup,
referencedImagePaths: Set<String>
) throws -> Data {
var zipData = Data()
var centralDirectory = Data()
var fileEntries: [(name: String, data: Data, offset: UInt32)] = []
var currentOffset: UInt32 = 0
let metadata = createMetadata(
exportData: exportData, referencedImagePaths: referencedImagePaths)
let metadataData = metadata.data(using: .utf8) ?? Data()
try addFileToZip(
filename: METADATA_FILENAME,
fileData: metadataData,
zipData: &zipData,
fileEntries: &fileEntries,
currentOffset: &currentOffset
)
let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted
encoder.dateEncodingStrategy = .custom { date, encoder in
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS"
var container = encoder.singleValueContainer()
try container.encode(formatter.string(from: date))
}
let jsonData = try encoder.encode(exportData)
try addFileToZip(
filename: DATA_JSON_FILENAME,
fileData: jsonData,
zipData: &zipData,
fileEntries: &fileEntries,
currentOffset: &currentOffset
)
print("Processing \(referencedImagePaths.count) referenced image paths")
var successfulImages = 0
for imagePath in referencedImagePaths {
print("Processing image path: \(imagePath)")
let imageURL = URL(fileURLWithPath: imagePath)
let imageName = imageURL.lastPathComponent
print("Image name: \(imageName)")
if FileManager.default.fileExists(atPath: imagePath) {
print("Image file exists at: \(imagePath)")
do {
let imageData = try Data(contentsOf: imageURL)
print("Image data size: \(imageData.count) bytes")
if imageData.count > 0 {
let imageEntryName = "\(IMAGES_DIR_NAME)/\(imageName)"
try addFileToZip(
filename: imageEntryName,
fileData: imageData,
zipData: &zipData,
fileEntries: &fileEntries,
currentOffset: &currentOffset
)
successfulImages += 1
print("Successfully added image to ZIP: \(imageEntryName)")
} else {
print("Image data is empty for: \(imagePath)")
}
} catch {
print("Failed to read image data for \(imagePath): \(error)")
}
} else {
print("Image file does not exist at: \(imagePath)")
}
}
print("Export completed: \(successfulImages)/\(referencedImagePaths.count) images included")
for entry in fileEntries {
let centralDirEntry = createCentralDirectoryEntry(
filename: entry.name,
fileData: entry.data,
localHeaderOffset: entry.offset
)
centralDirectory.append(centralDirEntry)
}
let centralDirOffset = UInt32(zipData.count)
zipData.append(centralDirectory)
let endOfCentralDir = createEndOfCentralDirectory(
numEntries: UInt16(fileEntries.count),
centralDirSize: UInt32(centralDirectory.count),
centralDirOffset: centralDirOffset
)
zipData.append(endOfCentralDir)
return zipData
}
static func extractImportZip(data: Data) throws -> ImportResult {
print("Starting ZIP extraction - data size: \(data.count) bytes")
return try extractUsingCustomParser(data: data)
}
private static func extractUsingCustomParser(data: Data) throws -> ImportResult {
var jsonContent = ""
var metadataContent = ""
var importedImagePaths: [String: String] = [:]
let zipEntries: [ZipEntry]
do {
zipEntries = try parseZipFile(data: data)
print("Successfully parsed ZIP file with \(zipEntries.count) entries")
} catch {
print("Failed to parse ZIP file: \(error)")
print(
"ZIP data header: \(data.prefix(20).map { String(format: "%02X", $0) }.joined(separator: " "))"
)
throw NSError(
domain: "ImportError", code: 1,
userInfo: [
NSLocalizedDescriptionKey:
"Failed to parse ZIP file: \(error.localizedDescription). This may be due to incompatibility with the ZIP format."
]
)
}
print("Found \(zipEntries.count) entries in ZIP file:")
for entry in zipEntries {
print(" - \(entry.filename) (size: \(entry.data.count) bytes)")
}
for entry in zipEntries {
switch entry.filename {
case METADATA_FILENAME:
metadataContent = String(data: entry.data, encoding: .utf8) ?? ""
print("Found metadata: \(metadataContent.prefix(100))...")
case DATA_JSON_FILENAME:
jsonContent = String(data: entry.data, encoding: .utf8) ?? ""
print("Found data.json with \(jsonContent.count) characters")
if jsonContent.isEmpty {
print("WARNING: data.json is empty!")
} else {
print("data.json preview: \(jsonContent.prefix(200))...")
}
default:
if entry.filename.hasPrefix("\(IMAGES_DIR_NAME)/") && !entry.filename.hasSuffix("/")
{
let originalFilename = String(
entry.filename.dropFirst("\(IMAGES_DIR_NAME)/".count))
do {
let filename = try ImageManager.shared.saveImportedImage(
entry.data, filename: originalFilename)
importedImagePaths[originalFilename] = filename
} 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: ClimbDataBackup,
referencedImagePaths: Set<String>
) -> String {
return """
Ascently 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 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]
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,272 @@
import ActivityKit
import Foundation
extension Notification.Name {
static let liveActivityDismissed = Notification.Name("liveActivityDismissed")
}
@MainActor
final class LiveActivityManager {
static let shared = LiveActivityManager()
private init() {}
nonisolated(unsafe) private var currentActivity: Activity<SessionActivityAttributes>?
private var healthCheckTimer: Timer?
private var lastHealthCheck: Date = Date()
/// Check if there's an active session and restart Live Activity if needed
func restartLiveActivityIfNeeded(activeSession: ClimbSession?, gymName: String?) async {
// If we have an active session but no Live Activity, restart it
guard let activeSession = activeSession,
let gymName = gymName,
activeSession.status == .active
else {
return
}
// Check if we have a tracked Live Activity that's still actually running
if let currentActivity = currentActivity {
let activities = Activity<SessionActivityAttributes>.activities
let isStillActive = activities.contains { $0.id == currentActivity.id }
if isStillActive {
print("Live Activity still running: \(currentActivity.id)")
return
} else {
print(
"WARNING: Tracked Live Activity \(currentActivity.id) was dismissed, clearing reference"
)
self.currentActivity = nil
}
}
// Check if there are ANY active Live Activities for this session
let existingActivities = Activity<SessionActivityAttributes>.activities
if let existingActivity = existingActivities.first {
print("Found existing Live Activity: \(existingActivity.id), using it")
self.currentActivity = existingActivity
return
}
print("No Live Activity found, restarting for existing session")
await startLiveActivity(for: activeSession, gymName: gymName)
}
/// Call this when a ClimbSession starts to begin a Live Activity
func startLiveActivity(for session: ClimbSession, gymName: String) async {
print("Starting Live Activity for gym: \(gymName)")
await endLiveActivity()
// Start health checks once we have an active session
startHealthChecks()
// Calculate elapsed time if session already started
let startTime = session.startTime ?? session.date
let elapsed = Date().timeIntervalSince(startTime)
let attributes = SessionActivityAttributes(
gymName: gymName, startTime: startTime)
let initialContentState = SessionActivityAttributes.ContentState(
elapsed: elapsed,
totalAttempts: 0,
completedProblems: 0
)
do {
let activity = try Activity<SessionActivityAttributes>.request(
attributes: attributes,
content: .init(state: initialContentState, staleDate: nil),
pushType: nil
)
self.currentActivity = activity
print("Live Activity started successfully: \(activity.id)")
} catch {
print("ERROR: Failed to start live activity: \(error)")
print("Error details: \(error.localizedDescription)")
// Check specific error types
if error.localizedDescription.contains("authorization") {
print("Authorization error - check Live Activity permissions in Settings")
} else if error.localizedDescription.contains("content") {
print("Content error - check ActivityAttributes structure")
} else if error.localizedDescription.contains("frequencyLimited") {
print("Frequency limited - too many Live Activities started recently")
}
}
}
/// Call this to update the Live Activity with new session progress
func updateLiveActivity(elapsed: TimeInterval, totalAttempts: Int, completedProblems: Int) async
{
guard let currentActivity = currentActivity else {
print("WARNING: No current activity to update")
return
}
// Verify the activity is still valid before updating
let activities = Activity<SessionActivityAttributes>.activities
let isStillActive = activities.contains { $0.id == currentActivity.id }
if !isStillActive {
print(
"WARNING: Tracked Live Activity \(currentActivity.id) is no longer active, clearing reference"
)
self.currentActivity = nil
return
}
print(
"Updating Live Activity - Attempts: \(totalAttempts), Completed: \(completedProblems)"
)
let updatedContentState = SessionActivityAttributes.ContentState(
elapsed: elapsed,
totalAttempts: totalAttempts,
completedProblems: completedProblems
)
nonisolated(unsafe) let activity = currentActivity
await activity.update(.init(state: updatedContentState, staleDate: nil))
}
/// Call this when a ClimbSession ends to end the Live Activity
func endLiveActivity() async {
// Stop health checks first
stopHealthChecks()
// First end the tracked activity if it exists
if let currentActivity {
print("Ending tracked Live Activity: \(currentActivity.id)")
nonisolated(unsafe) let activity = currentActivity
await activity.end(nil, dismissalPolicy: .immediate)
self.currentActivity = nil
print("Tracked Live Activity ended successfully")
}
// Force end ALL active activities of our type to ensure cleanup
print("Checking for any remaining active activities...")
let activities = Activity<SessionActivityAttributes>.activities
if activities.isEmpty {
print("No additional activities found")
} else {
print("Found \(activities.count) additional active activities, ending them...")
for activity in activities {
print("Force ending activity: \(activity.id)")
await activity.end(nil, dismissalPolicy: .immediate)
}
print("All Live Activities ended successfully")
}
}
/// Check if Live Activities are available and authorized
func checkLiveActivityAvailability() -> String {
let authorizationInfo = ActivityAuthorizationInfo()
let status = authorizationInfo.areActivitiesEnabled
let allActivities = Activity<SessionActivityAttributes>.activities
let message = """
Live Activity Status:
• Enabled: \(status)
• Authorization: \(authorizationInfo.areActivitiesEnabled ? "Granted" : "Denied/Unknown")
• Tracked Activity: \(currentActivity?.id.description ?? "None")
• All Active Activities: \(allActivities.count)
"""
print(message)
return message
}
/// Force check and cleanup dismissed Live Activities
func cleanupDismissedActivities() async {
let activities = Activity<SessionActivityAttributes>.activities
if let currentActivity = currentActivity {
let isStillActive = activities.contains { $0.id == currentActivity.id }
if !isStillActive {
print("Cleaning up dismissed Live Activity: \(currentActivity.id)")
self.currentActivity = nil
}
}
}
/// Start periodic health checks for Live Activity
func startHealthChecks() {
stopHealthChecks() // Stop any existing timer
print("🩺 Starting Live Activity health checks")
healthCheckTimer = Timer.scheduledTimer(withTimeInterval: 30.0, repeats: true) {
[weak self] _ in
Task { @MainActor [weak self] in
await self?.performHealthCheck()
}
}
}
/// Stop periodic health checks
func stopHealthChecks() {
healthCheckTimer?.invalidate()
healthCheckTimer = nil
print("Stopped Live Activity health checks")
}
/// Perform a health check on the current Live Activity
private func performHealthCheck() async {
guard let currentActivity = currentActivity else { return }
let now = Date()
let timeSinceLastCheck = now.timeIntervalSince(lastHealthCheck)
// Only perform health check if it's been at least 25 seconds
guard timeSinceLastCheck >= 25 else { return }
print("🩺 Performing Live Activity health check")
lastHealthCheck = now
let activities = Activity<SessionActivityAttributes>.activities
let isStillActive = activities.contains { $0.id == currentActivity.id }
if !isStillActive {
print("Health check failed - Live Activity was dismissed")
self.currentActivity = nil
// Notify that we need to restart
NotificationCenter.default.post(
name: .liveActivityDismissed,
object: nil
)
} else {
print("Live Activity health check passed")
}
}
/// Get the current activity status for debugging
func getCurrentActivityStatus() -> String {
let activities = Activity<SessionActivityAttributes>.activities
let trackedStatus = currentActivity != nil ? "Tracked" : "None"
let actualCount = activities.count
return "Status: \(trackedStatus) | Active Count: \(actualCount)"
}
/// Start periodic updates for Live Activity
func startPeriodicUpdates(for session: ClimbSession, totalAttempts: Int, completedProblems: Int)
{
guard currentActivity != nil else { return }
Task { @MainActor in
while currentActivity != nil {
let elapsed = Date().timeIntervalSince(session.startTime ?? session.date)
await updateLiveActivity(
elapsed: elapsed,
totalAttempts: totalAttempts,
completedProblems: completedProblems
)
// Wait 30 seconds before next update
try? await Task.sleep(nanoseconds: 30_000_000_000)
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,209 @@
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 {
NavigationStack {
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) {
updateAvailableDifficultySystems()
}
}
@ViewBuilder
private func BasicInfoSection() -> some View {
Section("Basic Information") {
TextField("Gym Name", text: $name)
TextField("Location (Optional)", text: $location)
}
}
@ViewBuilder
private func ClimbTypesSection() -> some View {
Section("Supported Climb Types") {
ForEach(ClimbType.allCases, id: \.self) { climbType in
HStack {
Text(climbType.displayName)
Spacer()
if selectedClimbTypes.contains(climbType) {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.blue)
} else {
Image(systemName: "circle")
.foregroundColor(.gray)
}
}
.contentShape(Rectangle())
.onTapGesture {
if selectedClimbTypes.contains(climbType) {
selectedClimbTypes.remove(climbType)
} else {
selectedClimbTypes.insert(climbType)
}
}
}
}
}
@ViewBuilder
private func DifficultySystemsSection() -> some View {
Section("Difficulty Systems") {
if selectedClimbTypes.isEmpty {
Text("Select climb types first to see available difficulty systems")
.foregroundColor(.secondary)
.font(.caption)
} else {
ForEach(availableDifficultySystems, id: \.self) { system in
HStack {
Text(system.displayName)
Spacer()
if selectedDifficultySystems.contains(system) {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.blue)
} else {
Image(systemName: "circle")
.foregroundColor(.gray)
}
}
.contentShape(Rectangle())
.onTapGesture {
if selectedDifficultySystems.contains(system) {
selectedDifficultySystems.remove(system)
} else {
selectedDifficultySystems.insert(system)
}
}
}
}
}
}
@ViewBuilder
private func NotesSection() -> some View {
Section("Notes (Optional)") {
TextEditor(text: $notes)
.frame(minHeight: 100)
}
}
private var canSave: Bool {
!name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !selectedClimbTypes.isEmpty
&& !selectedDifficultySystems.isEmpty
}
private func loadExistingGym() {
if let gym = existingGym {
isEditing = true
name = gym.name
location = gym.location ?? ""
notes = gym.notes ?? ""
selectedClimbTypes = Set(gym.supportedClimbTypes)
selectedDifficultySystems = Set(gym.difficultySystems)
customDifficultyGrades = gym.customDifficultyGrades
}
}
private func updateAvailableDifficultySystems() {
// Remove selected systems that are no longer available
let availableSet = Set(availableDifficultySystems)
selectedDifficultySystems = selectedDifficultySystems.intersection(availableSet)
}
private func saveGym() {
let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedLocation = location.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedNotes = notes.trimmingCharacters(in: .whitespacesAndNewlines)
if isEditing, let gym = existingGym {
let updatedGym = gym.updated(
name: trimmedName,
location: trimmedLocation.isEmpty ? nil : trimmedLocation,
supportedClimbTypes: Array(selectedClimbTypes),
difficultySystems: Array(selectedDifficultySystems),
customDifficultyGrades: customDifficultyGrades,
notes: trimmedNotes.isEmpty ? nil : trimmedNotes
)
dataManager.updateGym(updatedGym)
} else {
let newGym = Gym(
name: trimmedName,
location: trimmedLocation.isEmpty ? nil : trimmedLocation,
supportedClimbTypes: Array(selectedClimbTypes),
difficultySystems: Array(selectedDifficultySystems),
customDifficultyGrades: customDifficultyGrades,
notes: trimmedNotes.isEmpty ? nil : trimmedNotes
)
dataManager.addGym(newGym)
}
dismiss()
}
}
extension Array where Element: Hashable {
func removingDuplicates() -> [Element] {
var seen = Set<Element>()
return filter { seen.insert($0).inserted }
}
}
#Preview {
AddEditGymView()
.environmentObject(ClimbingDataManager.preview)
}

View File

@@ -0,0 +1,637 @@
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 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
enum SheetType: Identifiable {
case photoOptions
case camera
var id: Int {
switch self {
case .photoOptions: return 0
case .camera: return 1
}
}
}
@State private var activeSheet: SheetType?
@State private var showPhotoPicker = false
@State private var isPhotoPickerActionPending = 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 {
NavigationStack {
Form {
GymSelectionSection()
BasicInfoSection()
PhotosSection()
ClimbTypeSection()
DifficultySection()
LocationSection()
TagsSection()
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: dataManager.gyms) {
// Ensure a gym is selected when gyms are loaded or changed
if selectedGym == nil && !dataManager.gyms.isEmpty {
selectedGym = dataManager.gyms.first
}
}
.onChange(of: selectedGym) {
updateAvailableOptions()
}
.onChange(of: selectedClimbType) {
updateDifficultySystem()
}
.onChange(of: selectedDifficultySystem) {
resetGradeIfNeeded()
}
.sheet(
item: $activeSheet,
onDismiss: {
if isPhotoPickerActionPending {
showPhotoPicker = true
isPhotoPickerActionPending = false
}
}
) { sheetType in
switch sheetType {
case .photoOptions:
PhotoOptionSheet(
selectedPhotos: $selectedPhotos,
imageData: $imageData,
maxImages: 5,
onCameraSelected: {
activeSheet = .camera
},
onPhotoLibrarySelected: {
isPhotoPickerActionPending = true
},
onDismiss: {
activeSheet = nil
}
)
case .camera:
CameraImagePicker(
isPresented: Binding(
get: { activeSheet == .camera },
set: { if !$0 { activeSheet = nil } }
)
) { capturedImage in
if let jpegData = capturedImage.jpegData(compressionQuality: 0.8) {
imageData.append(jpegData)
}
}
}
}
.photosPicker(
isPresented: $showPhotoPicker,
selection: $selectedPhotos,
maxSelectionCount: 5 - imageData.count,
matching: .images
)
.onChange(of: selectedPhotos) {
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)
)
}
}
}
@ViewBuilder
private func ClimbTypeSection() -> some View {
if selectedGym != nil {
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 (numbers only)", text: $difficultyGrade)
.textFieldStyle(.roundedBorder)
.keyboardType(.numberPad)
.onChange(of: difficultyGrade) {
// Filter out non-numeric characters
difficultyGrade = difficultyGrade.filter { $0.isNumber }
}
} 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 LocationSection() -> 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 (Optional)") {
Button(action: {
activeSheet = .photoOptions
}) {
HStack {
Image(systemName: "camera.fill")
.foregroundColor(.blue)
.font(.title2)
VStack(alignment: .leading, spacing: 2) {
Text("Add Photos")
.font(.headline)
.foregroundColor(.blue)
Text("\(imageData.count) of 5 photos added")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.foregroundColor(.secondary)
.font(.caption)
}
.padding(.vertical, 4)
}
.disabled(imageData.count >= 5)
if !imageData.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) {
ForEach(imageData.indices, id: \.self) { index in
if let uiImage = UIImage(data: imageData[index]) {
ZStack(alignment: .topTrailing) {
Image(uiImage: uiImage)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 80, height: 80)
.clipped()
.cornerRadius(8)
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))
.font(.system(size: 18))
}
.offset(x: 4, y: -4)
}
.frame(width: 88, height: 88) // Extra space for button
} else {
RoundedRectangle(cornerRadius: 8)
.fill(.gray.opacity(0.3))
.frame(width: 80, height: 80)
.overlay {
Image(systemName: "photo")
.foregroundColor(.gray)
}
}
}
}
.padding(.horizontal, 1)
.padding(.vertical, 8)
}
}
}
}
@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 = dataManager.gym(withId: gymId)
}
// Always ensure a gym is selected if available and none is currently selected
if selectedGym == nil && !dataManager.gyms.isEmpty {
selectedGym = dataManager.gyms.first
}
}
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
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 = ImageManager.shared.loadImageData(fromPath: 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) {
imageData.append(data)
}
}
selectedPhotos.removeAll()
}
private func saveProblem() {
guard let gym = selectedGym, canSave else { return }
let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedDescription = description.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 {
var allImagePaths = imagePaths
let newImagesStartIndex = imagePaths.count
if imageData.count > newImagesStartIndex {
for i in newImagesStartIndex..<imageData.count {
let data = imageData[i]
let imageIndex = allImagePaths.count
let deterministicName = ImageNamingUtils.generateImageFilename(
problemId: problem.id.uuidString, imageIndex: imageIndex)
if let relativePath = ImageManager.shared.saveImageData(
data, withName: deterministicName)
{
allImagePaths.append(relativePath)
}
}
}
let updatedProblem = problem.updated(
name: trimmedName.isEmpty ? nil : trimmedName,
description: trimmedDescription.isEmpty ? nil : trimmedDescription,
climbType: selectedClimbType,
difficulty: difficulty,
tags: trimmedTags,
location: trimmedLocation.isEmpty ? nil : trimmedLocation,
imagePaths: allImagePaths,
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,
tags: trimmedTags,
location: trimmedLocation.isEmpty ? nil : trimmedLocation,
imagePaths: [],
dateSet: dateSet,
notes: trimmedNotes.isEmpty ? nil : trimmedNotes
)
dataManager.addProblem(newProblem)
if !imageData.isEmpty {
var imagePaths: [String] = []
for (index, data) in imageData.enumerated() {
let deterministicName = ImageNamingUtils.generateImageFilename(
problemId: newProblem.id.uuidString, imageIndex: index)
if let relativePath = ImageManager.shared.saveImageData(
data, withName: deterministicName)
{
imagePaths.append(relativePath)
}
}
if !imagePaths.isEmpty {
let updatedProblem = newProblem.updated(imagePaths: imagePaths)
dataManager.updateProblem(updatedProblem)
}
}
}
dismiss()
}
}
#Preview {
AddEditProblemView()
.environmentObject(ClimbingDataManager.preview)
}

View File

@@ -0,0 +1,136 @@
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 {
NavigationStack {
Form {
GymSelectionSection()
SessionDetailsSection()
}
.navigationTitle(isEditing ? "Edit Session" : "New Session")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Save") {
saveSession()
}
.disabled(selectedGym == nil)
}
}
}
.onAppear {
loadExistingSession()
}
}
@ViewBuilder
private func GymSelectionSection() -> some View {
Section("Select Gym") {
if dataManager.gyms.isEmpty {
Text("No gyms available. Add a gym first.")
.foregroundColor(.secondary)
} else {
ForEach(dataManager.gyms, id: \.id) { gym in
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(gym.name)
.font(.headline)
if let location = gym.location, !location.isEmpty {
Text(location)
.font(.caption)
.foregroundColor(.secondary)
}
}
Spacer()
if selectedGym?.id == gym.id {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.blue)
}
}
.contentShape(Rectangle())
.onTapGesture {
selectedGym = gym
}
}
}
}
}
@ViewBuilder
private func SessionDetailsSection() -> some View {
Section("Session Details") {
DatePicker(
"Date",
selection: $sessionDate,
displayedComponents: [.date]
)
VStack(alignment: .leading, spacing: 8) {
Text("Notes (Optional)")
.font(.headline)
TextEditor(text: $notes)
.frame(minHeight: 100)
.padding(8)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(.quaternary)
)
}
}
}
private func loadExistingSession() {
if let session = existingSession {
isEditing = true
selectedGym = dataManager.gym(withId: session.gymId)
sessionDate = session.date
notes = session.notes ?? ""
}
}
private func saveSession() {
guard let gym = selectedGym else { return }
if isEditing, let session = existingSession {
let updatedSession = session.updated(notes: notes.isEmpty ? nil : notes)
dataManager.updateSession(updatedSession)
} else {
dataManager.startSession(gymId: gym.id, notes: notes.isEmpty ? nil : notes)
}
dismiss()
}
}
#Preview {
AddEditSessionView()
.environmentObject(ClimbingDataManager.preview)
}

View File

@@ -0,0 +1,547 @@
import SwiftUI
struct AnalyticsView: View {
@EnvironmentObject var dataManager: ClimbingDataManager
var body: some View {
NavigationStack {
ScrollView {
LazyVStack(spacing: 20) {
OverallStatsSection()
ProgressChartSection()
HStack(spacing: 16) {
FavoriteGymSection()
RecentActivitySection()
}
}
.padding()
}
.navigationTitle("Analytics")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
if dataManager.isSyncing {
HStack(spacing: 2) {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .blue))
.scaleEffect(0.6)
}
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(
Circle()
.fill(.regularMaterial)
)
.transition(.scale.combined(with: .opacity))
.animation(
.easeInOut(duration: 0.2), value: dataManager.isSyncing
)
}
}
}
}
}
}
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
@State private var showAllTime: Bool = true
@State private var cachedGradeCountData: [GradeCount] = []
@State private var lastCalculationDate: Date = Date.distantPast
@State private var lastDataHash: Int = 0
private var gradeCountData: [GradeCount] {
let currentHash =
dataManager.problems.count + dataManager.attempts.count + (showAllTime ? 1 : 0)
let now = Date()
// Recalculate only if data changed or cache is older than 30 seconds
if currentHash != lastDataHash || now.timeIntervalSince(lastCalculationDate) > 30 {
let newData = calculateGradeCounts()
DispatchQueue.main.async {
self.cachedGradeCountData = newData
self.lastCalculationDate = now
self.lastDataHash = currentHash
}
}
return cachedGradeCountData.isEmpty ? calculateGradeCounts() : cachedGradeCountData
}
private var usedSystems: [DifficultySystem] {
let uniqueSystems = Set(gradeCountData.map { $0.difficultySystem })
return uniqueSystems.sorted {
let order: [DifficultySystem] = [.vScale, .font, .yds, .custom]
let firstIndex = order.firstIndex(of: $0) ?? order.count
let secondIndex = order.firstIndex(of: $1) ?? order.count
return firstIndex < secondIndex
}
}
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Grade Distribution")
.font(.title2)
.fontWeight(.bold)
// Toggles section
HStack {
// Time period toggle
HStack(spacing: 8) {
Button(action: {
showAllTime = true
}) {
Text("All Time")
.font(.caption)
.fontWeight(.medium)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(showAllTime ? .blue : .clear)
.stroke(.blue.opacity(0.3), lineWidth: 1)
)
.foregroundColor(showAllTime ? .white : .blue)
}
Button(action: {
showAllTime = false
}) {
Text("7 Days")
.font(.caption)
.fontWeight(.medium)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(!showAllTime ? .blue : .clear)
.stroke(.blue.opacity(0.3), lineWidth: 1)
)
.foregroundColor(!showAllTime ? .white : .blue)
}
}
Spacer()
// Scale selector (only show if multiple systems)
if usedSystems.count > 1 {
Menu {
ForEach(usedSystems, id: \.self) { system in
Button(action: {
selectedSystem = system
}) {
HStack {
Text(system.displayName)
if selectedSystem == system {
Spacer()
Image(systemName: "checkmark")
.foregroundColor(.blue)
}
}
}
}
} label: {
HStack(spacing: 4) {
Text(selectedSystem.displayName)
.font(.subheadline)
.fontWeight(.medium)
Image(systemName: "chevron.down")
.font(.caption)
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(.blue.opacity(0.1))
.stroke(.blue.opacity(0.3), lineWidth: 1)
)
.foregroundColor(.blue)
}
}
}
let filteredData = gradeCountData.filter { $0.difficultySystem == selectedSystem }
if !filteredData.isEmpty {
BarChartView(data: filteredData)
.frame(height: 200)
Text("Successful climbs by grade")
.font(.caption)
.foregroundColor(.secondary)
} else {
VStack(spacing: 8) {
Image(systemName: "chart.bar")
.font(.title)
.foregroundColor(.secondary)
Text("No 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 calculateGradeCounts() -> [GradeCount] {
let problems = dataManager.problems
let attempts = dataManager.attempts
// Filter attempts by time period
let filteredAttempts: [Attempt]
if showAllTime {
filteredAttempts = attempts.filter { $0.result.isSuccessful }
} else {
let sevenDaysAgo =
Calendar.current.date(byAdding: .day, value: -7, to: Date()) ?? Date()
filteredAttempts = attempts.filter {
$0.result.isSuccessful && $0.timestamp >= sevenDaysAgo
}
}
// Get attempted problems
let attemptedProblemIds = filteredAttempts.map { $0.problemId }
let attemptedProblems = problems.filter { attemptedProblemIds.contains($0.id) }
// Group by difficulty system and grade
var gradeCounts: [String: GradeCount] = [:]
for problem in attemptedProblems {
let successfulAttemptsForProblem = filteredAttempts.filter {
$0.problemId == problem.id
}
let count = successfulAttemptsForProblem.count
let key = "\(problem.difficulty.system.rawValue)-\(problem.difficulty.grade)"
if let existing = gradeCounts[key] {
gradeCounts[key] = GradeCount(
grade: existing.grade,
count: existing.count + count,
gradeNumeric: existing.gradeNumeric,
difficultySystem: existing.difficultySystem
)
} else {
gradeCounts[key] = GradeCount(
grade: problem.difficulty.grade,
count: count,
gradeNumeric: problem.difficulty.numericValue,
difficultySystem: problem.difficulty.system
)
}
}
return Array(gradeCounts.values)
}
}
struct GradeCount {
let grade: String
let count: Int
let gradeNumeric: Int
let difficultySystem: DifficultySystem
}
struct BarChartView: View {
let data: [GradeCount]
private var sortedData: [GradeCount] {
data.sorted { $0.gradeNumeric < $1.gradeNumeric }
}
private var maxCount: Int {
data.map { $0.count }.max() ?? 1
}
var body: some View {
GeometryReader { geometry in
let chartWidth = geometry.size.width - 40
let chartHeight = geometry.size.height - 40
let barWidth = chartWidth / CGFloat(max(sortedData.count, 1)) * 0.8
let spacing = chartWidth / CGFloat(max(sortedData.count, 1)) * 0.2
if sortedData.isEmpty {
Rectangle()
.fill(.clear)
.overlay(
Text("No data")
.foregroundColor(.secondary)
)
} else {
VStack(alignment: .leading) {
// Chart area
HStack(alignment: .bottom, spacing: spacing / CGFloat(sortedData.count)) {
ForEach(Array(sortedData.enumerated()), id: \.offset) { index, gradeCount in
VStack(spacing: 4) {
// Bar
RoundedRectangle(cornerRadius: 4)
.fill(.blue)
.frame(
width: barWidth,
height: CGFloat(gradeCount.count) / CGFloat(maxCount)
* chartHeight * 0.8
)
.overlay(
Text("\(gradeCount.count)")
.font(.caption2)
.fontWeight(.medium)
.foregroundColor(.white)
.opacity(gradeCount.count > 0 ? 1 : 0)
)
// Grade label
Text(gradeCount.grade)
.font(.caption2)
.foregroundColor(.secondary)
.lineLimit(1)
}
}
}
.frame(height: chartHeight)
}
.frame(maxWidth: .infinity, alignment: .center)
}
}
}
}
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: 16) {
HStack {
Image(systemName: "location.fill")
.font(.title2)
.foregroundColor(.purple)
Text("Favorite Gym")
.font(.title2)
.fontWeight(.bold)
Spacer()
}
if let info = favoriteGymInfo {
VStack(alignment: .leading, spacing: 12) {
Text(info.gym.name)
.font(.title3)
.fontWeight(.semibold)
.foregroundColor(.primary)
HStack {
Image(systemName: "calendar")
.font(.subheadline)
.foregroundColor(.purple)
Text("\(info.sessionCount) sessions")
.font(.subheadline)
.foregroundColor(.secondary)
}
Spacer()
}
} else {
VStack(alignment: .leading, spacing: 8) {
Text("No sessions yet")
.font(.subheadline)
.foregroundColor(.secondary)
Text("Start climbing to see your favorite gym!")
.font(.caption)
.foregroundColor(.secondary)
Spacer()
}
}
}
.frame(maxWidth: .infinity, minHeight: 120, alignment: .topLeading)
.padding()
.background(
RoundedRectangle(cornerRadius: 16)
.fill(.regularMaterial)
)
}
}
struct RecentActivitySection: View {
@EnvironmentObject var dataManager: ClimbingDataManager
private var recentSessionsCount: Int {
dataManager.sessions.count
}
private var totalAttempts: Int {
dataManager.attempts.count
}
var body: some View {
VStack(alignment: .leading, spacing: 16) {
HStack {
Image(systemName: "clock.fill")
.font(.title2)
.foregroundColor(.blue)
Text("Recent Activity")
.font(.title2)
.fontWeight(.bold)
Spacer()
}
if recentSessionsCount > 0 {
VStack(alignment: .leading, spacing: 12) {
HStack {
Image(systemName: "play.circle")
.font(.subheadline)
.foregroundColor(.blue)
Text("\(recentSessionsCount) sessions")
.font(.subheadline)
.foregroundColor(.secondary)
}
HStack {
Image(systemName: "hand.raised")
.font(.subheadline)
.foregroundColor(.green)
Text("\(totalAttempts) attempts")
.font(.subheadline)
.foregroundColor(.secondary)
}
Spacer()
}
} else {
VStack(alignment: .leading, spacing: 8) {
Text("No recent activity")
.font(.subheadline)
.foregroundColor(.secondary)
Text("Start your first session!")
.font(.caption)
.foregroundColor(.secondary)
Spacer()
}
}
}
.frame(maxWidth: .infinity, minHeight: 120, alignment: .topLeading)
.padding()
.background(
RoundedRectangle(cornerRadius: 16)
.fill(.regularMaterial)
)
}
}
#Preview {
AnalyticsView()
.environmentObject(ClimbingDataManager.preview)
}

View File

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

View File

@@ -0,0 +1,468 @@
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 {
showingEditProblem = true
} label: {
Label("Edit Problem", systemImage: "pencil")
}
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 !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
ProblemDetailImageView(imagePath: imagePaths[index])
.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 {
NavigationStack {
TabView(selection: $currentIndex) {
ForEach(imagePaths.indices, id: \.self) { index in
ProblemDetailImageFullView(imagePath: imagePaths[index])
.tag(index)
}
}
.tabViewStyle(.page(indexDisplayMode: .always))
.navigationTitle("Photo \(currentIndex + 1) of \(imagePaths.count)")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") {
dismiss()
}
}
}
}
}
}
struct ProblemDetailImageView: View {
let imagePath: String
var body: some View {
OrientationAwareImage.fill(imagePath: imagePath)
.frame(width: 120, height: 120)
.clipped()
.cornerRadius(12)
}
}
struct ProblemDetailImageFullView: View {
let imagePath: String
var body: some View {
OrientationAwareImage.fit(imagePath: imagePath)
}
}
#Preview {
NavigationView {
ProblemDetailView(problemId: UUID())
.environmentObject(ClimbingDataManager.preview)
}
}

View File

@@ -0,0 +1,452 @@
import Combine
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?
@State private var attemptToDelete: 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,
attemptToDelete: $attemptToDelete,
editingAttempt: $editingAttempt)
} 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")
}
}
}
}
}
.alert(
"Delete Attempt",
isPresented: Binding<Bool>(
get: { attemptToDelete != nil },
set: { if !$0 { attemptToDelete = nil } }
)
) {
Button("Cancel", role: .cancel) {
attemptToDelete = nil
}
Button("Delete", role: .destructive) {
if let attempt = attemptToDelete {
dataManager.deleteAttempt(attempt)
attemptToDelete = nil
}
}
} message: {
if let attempt = attemptToDelete,
let problem = dataManager.problem(withId: attempt.problemId)
{
Text(
"Are you sure you want to delete this attempt on \"\(problem.name ?? "Unknown Problem")\"? This action cannot be undone."
)
} else {
Text("Are you sure you want to delete this attempt? This action cannot be undone.")
}
}
.overlay(alignment: .bottomTrailing) {
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 })
return SessionStats(
totalAttempts: attempts.count,
successfulAttempts: successfulAttempts.count,
uniqueProblemsAttempted: uniqueProblems.count,
uniqueProblemsCompleted: completedProblems.count
)
}
}
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 session.status == .active {
if let startTime = session.startTime {
Text("Duration: ")
.font(.subheadline)
.foregroundColor(.secondary)
+ Text(timerInterval: startTime...Date.distantFuture, countsDown: false)
.font(.subheadline)
.foregroundColor(.secondary)
.monospacedDigit()
}
} else 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: "Completed", value: "\(stats.uniqueProblemsCompleted)")
}
}
}
.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)]
@Binding var attemptToDelete: Attempt?
@Binding var editingAttempt: Attempt?
@EnvironmentObject var dataManager: ClimbingDataManager
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 {
List {
ForEach(attemptsWithProblems.indices, id: \.self) { index in
let (attempt, problem) = attemptsWithProblems[index]
AttemptCard(attempt: attempt, problem: problem)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 6, leading: 0, bottom: 6, trailing: 0))
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) {
// Add haptic feedback for delete action
let impactFeedback = UIImpactFeedbackGenerator(style: .medium)
impactFeedback.impactOccurred()
attemptToDelete = attempt
} label: {
Label("Delete", systemImage: "trash")
}
.accessibilityLabel("Delete attempt")
.accessibilityHint("Removes this attempt from the session")
Button {
editingAttempt = attempt
} label: {
Label("Edit", systemImage: "pencil")
}
.tint(.blue)
.accessibilityLabel("Edit attempt")
.accessibilityHint("Modify the details of this attempt")
}
.onTapGesture {
editingAttempt = attempt
}
}
}
.listStyle(.plain)
.scrollDisabled(true)
.frame(height: CGFloat(attemptsWithProblems.count) * 120)
}
}
}
}
struct AttemptCard: View {
let attempt: Attempt
let problem: Problem
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)
}
}
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(.regularMaterial)
.cornerRadius(12)
.shadow(radius: 2)
}
}
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
}
#Preview {
NavigationView {
SessionDetailView(sessionId: UUID())
.environmentObject(ClimbingDataManager.preview)
}
}

View File

@@ -0,0 +1,218 @@
import SwiftUI
struct GymsView: View {
@EnvironmentObject var dataManager: ClimbingDataManager
@State private var showingAddGym = false
var body: some View {
NavigationStack {
VStack {
if dataManager.gyms.isEmpty {
EmptyGymsView()
} else {
GymsList()
}
}
.navigationTitle("Gyms")
.toolbar {
ToolbarItemGroup(placement: .navigationBarTrailing) {
if dataManager.isSyncing {
HStack(spacing: 2) {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .blue))
.scaleEffect(0.6)
}
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(
Circle()
.fill(.regularMaterial)
)
.transition(.scale.combined(with: .opacity))
.animation(
.easeInOut(duration: 0.2), value: dataManager.isSyncing
)
}
Button("Add") {
showingAddGym = true
}
}
}
.sheet(isPresented: $showingAddGym) {
AddEditGymView()
}
}
}
}
struct GymsList: View {
@EnvironmentObject var dataManager: ClimbingDataManager
@State private var gymToDelete: Gym?
@State private var gymToEdit: Gym?
var body: some View {
List(dataManager.gyms, id: \.id) { gym in
NavigationLink(destination: GymDetailView(gymId: gym.id)) {
GymRow(gym: gym)
}
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
Button(role: .destructive) {
gymToDelete = gym
} label: {
Label("Delete", systemImage: "trash")
}
Button {
gymToEdit = gym
} label: {
HStack {
Image(systemName: "pencil")
Text("Edit")
}
}
.tint(.blue)
}
}
.alert("Delete Gym", isPresented: .constant(gymToDelete != nil)) {
Button("Cancel", role: .cancel) {
gymToDelete = nil
}
Button("Delete", role: .destructive) {
if let gym = gymToDelete {
dataManager.deleteGym(gym)
gymToDelete = nil
}
}
} message: {
Text(
"Are you sure you want to delete this gym? This will also delete all associated problems and sessions."
)
}
.sheet(item: $gymToEdit) { gym in
AddEditGymView(gymId: gym.id)
}
}
}
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, 8)
}
}
struct EmptyGymsView: View {
@State private var showingAddGym = false
var body: some View {
VStack(spacing: 20) {
Spacer()
Image(systemName: "location.fill")
.font(.system(size: 60))
.foregroundColor(.secondary)
VStack(spacing: 8) {
Text("No Gyms Added")
.font(.title2)
.fontWeight(.bold)
Text("Add your favorite climbing gyms to start tracking your progress!")
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
}
Button("Add Gym") {
showingAddGym = true
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
Spacer()
}
.sheet(isPresented: $showingAddGym) {
AddEditGymView()
}
}
}
#Preview {
GymsView()
.environmentObject(ClimbingDataManager.preview)
}

View File

@@ -0,0 +1,278 @@
//
// LiveActivityDebugView.swift
import SwiftUI
struct LiveActivityDebugView: View {
@EnvironmentObject var dataManager: ClimbingDataManager
@State private var debugOutput: String = ""
@State private var isTestRunning = false
var body: some View {
NavigationStack {
VStack(alignment: .leading, spacing: 20) {
// Header
VStack(alignment: .leading, spacing: 8) {
Text("Live Activity Debug")
.font(.title)
.fontWeight(.bold)
Text("Test and debug Live Activities for climbing sessions")
.font(.subheadline)
.foregroundColor(.secondary)
}
// Status Section
GroupBox("Current Status") {
VStack(alignment: .leading, spacing: 12) {
HStack {
Image(systemName: "circle.fill")
.foregroundColor(dataManager.activeSession != nil ? .green : .red)
Text(
"Active Session: \(dataManager.activeSession != nil ? "Yes" : "No")"
)
}
HStack {
Image(systemName: "building.2")
Text("Total Gyms: \(dataManager.gyms.count)")
}
if let activeSession = dataManager.activeSession,
let gym = dataManager.gym(withId: activeSession.gymId)
{
HStack {
Image(systemName: "location")
Text("Current Gym: \(gym.name)")
}
}
}
}
// Test Buttons
GroupBox("Live Activity Tests") {
VStack(spacing: 16) {
Button(action: checkStatus) {
HStack {
Image(systemName: "checkmark.circle")
Text("Check Live Activity Status")
}
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.disabled(isTestRunning)
Button(action: testLiveActivity) {
HStack {
Image(systemName: isTestRunning ? "hourglass" : "play.circle")
Text(
isTestRunning
? "Running Test..." : "Run Full Live Activity Test")
}
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.disabled(isTestRunning || dataManager.gyms.isEmpty)
Button(action: forceLiveActivityUpdate) {
HStack {
Image(systemName: "arrow.clockwise")
Text("Force Live Activity Update")
}
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.disabled(dataManager.activeSession == nil)
if dataManager.gyms.isEmpty {
Text("WARNING: Add at least one gym to test Live Activities")
.font(.caption)
.foregroundColor(.orange)
}
if dataManager.activeSession != nil {
Button(action: endCurrentSession) {
HStack {
Image(systemName: "stop.circle")
Text("End Current Session")
}
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.disabled(isTestRunning)
}
}
}
// Debug Output
GroupBox("Debug Output") {
ScrollView {
ScrollViewReader { proxy in
VStack(alignment: .leading, spacing: 4) {
if debugOutput.isEmpty {
Text("No debug output yet. Run a test to see details.")
.foregroundColor(.secondary)
.italic()
} else {
Text(debugOutput)
.font(.system(.caption, design: .monospaced))
.textSelection(.enabled)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(8)
.id("bottom")
.onChange(of: debugOutput) {
withAnimation {
proxy.scrollTo("bottom", anchor: .bottom)
}
}
}
}
.frame(maxHeight: 200)
.background(Color(UIColor.systemGray6))
.cornerRadius(8)
}
// Clear button
HStack {
Spacer()
Button("Clear Output") {
debugOutput = ""
}
.buttonStyle(.bordered)
}
Spacer()
}
.padding()
}
.navigationTitle("Live Activity Debug")
.navigationBarTitleDisplayMode(.inline)
}
private func appendDebugOutput(_ message: String) {
let timestamp = DateFormatter.timeFormatter.string(from: Date())
let newLine = "[\(timestamp)] \(message)"
DispatchQueue.main.async {
if debugOutput.isEmpty {
debugOutput = newLine
} else {
debugOutput += "\n" + newLine
}
}
}
private func checkStatus() {
appendDebugOutput("Checking Live Activity status...")
let status = LiveActivityManager.shared.checkLiveActivityAvailability()
appendDebugOutput("Status: \(status)")
// Check iOS version
if #available(iOS 16.1, *) {
appendDebugOutput("iOS version supports Live Activities")
} else {
appendDebugOutput(
"ERROR: iOS version does not support Live Activities (requires 16.1+)")
}
// Check if we're on simulator
#if targetEnvironment(simulator)
appendDebugOutput(
"WARNING: Running on Simulator - Live Activities have limited functionality")
#else
appendDebugOutput("Running on device - Live Activities should work fully")
#endif
}
private func testLiveActivity() {
guard !dataManager.gyms.isEmpty else {
appendDebugOutput("ERROR: No gyms available for testing")
return
}
isTestRunning = true
appendDebugOutput("🧪 Starting Live Activity test...")
Task {
defer {
DispatchQueue.main.async {
isTestRunning = false
}
}
// Test with first gym
let testGym = dataManager.gyms[0]
appendDebugOutput("Using gym: \(testGym.name)")
// Create test session
let testSession = ClimbSession(
gymId: testGym.id, notes: "Test session for Live Activity")
appendDebugOutput("Created test session")
// Start Live Activity
await LiveActivityManager.shared.startLiveActivity(
for: testSession, gymName: testGym.name)
appendDebugOutput("Live Activity start request sent")
// Wait and update
try? await Task.sleep(nanoseconds: 3_000_000_000) // 3 seconds
appendDebugOutput("Updating Live Activity with test data...")
await LiveActivityManager.shared.updateLiveActivity(
elapsed: 180,
totalAttempts: 8,
completedProblems: 2
)
// Another update
try? await Task.sleep(nanoseconds: 3_000_000_000) // 3 seconds
appendDebugOutput("Second update...")
await LiveActivityManager.shared.updateLiveActivity(
elapsed: 360,
totalAttempts: 15,
completedProblems: 4
)
// End after delay
try? await Task.sleep(nanoseconds: 5_000_000_000) // 5 seconds
appendDebugOutput("Ending Live Activity...")
await LiveActivityManager.shared.endLiveActivity()
appendDebugOutput("Live Activity test completed!")
}
}
private func endCurrentSession() {
guard let activeSession = dataManager.activeSession else {
appendDebugOutput("ERROR: No active session to end")
return
}
appendDebugOutput("Ending current session: \(activeSession.id)")
dataManager.endSession(activeSession.id)
appendDebugOutput("Session ended")
}
private func forceLiveActivityUpdate() {
appendDebugOutput("Forcing Live Activity update...")
dataManager.forceLiveActivityUpdate()
appendDebugOutput("Live Activity update sent")
}
}
extension DateFormatter {
static let timeFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm:ss"
return formatter
}()
}
#Preview {
LiveActivityDebugView()
.environmentObject(ClimbingDataManager.preview)
}

View File

@@ -0,0 +1,530 @@
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 = ""
@State private var showingSearch = false
@FocusState private var isSearchFocused: Bool
@State private var cachedFilteredProblems: [Problem] = []
private func updateFilteredProblems() {
Task(priority: .userInitiated) {
let result = await computeFilteredProblems()
// Switch back to the main thread to update the UI
await MainActor.run {
cachedFilteredProblems = result
}
}
}
private func computeFilteredProblems() async -> [Problem] {
// Capture dependencies for safe background processing
let problems = dataManager.problems
let searchText = self.searchText
let selectedClimbType = self.selectedClimbType
let selectedGym = self.selectedGym
var filtered = problems
// Apply search filter
if !searchText.isEmpty {
filtered = filtered.filter { problem in
return problem.name?.localizedCaseInsensitiveContains(searchText) ?? false
|| (problem.description?.localizedCaseInsensitiveContains(searchText) ?? false)
|| (problem.location?.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 }
}
// Separate active and inactive problems with stable sorting
let active = filtered.filter { $0.isActive }.sorted {
if $0.updatedAt == $1.updatedAt {
return $0.id.uuidString < $1.id.uuidString // Stable fallback
}
return $0.updatedAt > $1.updatedAt
}
let inactive = filtered.filter { !$0.isActive }.sorted {
if $0.updatedAt == $1.updatedAt {
return $0.id.uuidString < $1.id.uuidString // Stable fallback
}
return $0.updatedAt > $1.updatedAt
}
return active + inactive
}
var body: some View {
NavigationStack {
Group {
VStack(spacing: 0) {
if showingSearch {
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.foregroundColor(.secondary)
.font(.system(size: 16, weight: .medium))
TextField("Search problems...", text: $searchText)
.textFieldStyle(.plain)
.font(.system(size: 16))
.focused($isSearchFocused)
.submitLabel(.search)
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background {
if #available(iOS 18.0, *) {
RoundedRectangle(cornerRadius: 12)
.fill(.regularMaterial)
.overlay {
RoundedRectangle(cornerRadius: 12)
.stroke(.quaternary, lineWidth: 0.5)
}
} else {
RoundedRectangle(cornerRadius: 10)
.fill(Color(.systemGray6))
.overlay {
RoundedRectangle(cornerRadius: 10)
.stroke(Color(.systemGray4), lineWidth: 0.5)
}
}
}
.padding(.horizontal)
.padding(.top, 8)
.animation(.easeInOut(duration: 0.3), value: showingSearch)
}
if !dataManager.problems.isEmpty && !showingSearch {
FilterSection(
selectedClimbType: $selectedClimbType,
selectedGym: $selectedGym,
filteredProblems: cachedFilteredProblems
)
.padding()
.background(.regularMaterial)
}
if cachedFilteredProblems.isEmpty {
EmptyProblemsView(
isEmpty: dataManager.problems.isEmpty,
isFiltered: !dataManager.problems.isEmpty
)
} else {
ProblemsList(problems: cachedFilteredProblems)
}
}
}
.navigationTitle("Problems")
.navigationBarTitleDisplayMode(.automatic)
.toolbar {
ToolbarItemGroup(placement: .navigationBarTrailing) {
if dataManager.isSyncing {
HStack(spacing: 2) {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .blue))
.scaleEffect(0.6)
}
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(
Circle()
.fill(.regularMaterial)
)
.transition(.scale.combined(with: .opacity))
.animation(
.easeInOut(duration: 0.2), value: dataManager.isSyncing
)
}
Button(action: {
withAnimation(.easeInOut(duration: 0.3)) {
showingSearch.toggle()
if showingSearch {
isSearchFocused = true
} else {
searchText = ""
isSearchFocused = false
}
}
}) {
Image(systemName: showingSearch ? "xmark.circle.fill" : "magnifyingglass")
.font(.system(size: 16, weight: .medium))
.foregroundColor(showingSearch ? .secondary : .blue)
}
if !dataManager.gyms.isEmpty {
Button("Add") {
showingAddProblem = true
}
}
}
}
.sheet(isPresented: $showingAddProblem) {
AddEditProblemView()
}
}
.onAppear {
updateFilteredProblems()
}
.onChange(of: dataManager.problems) {
updateFilteredProblems()
}
.onChange(of: searchText) {
updateFilteredProblems()
}
.onChange(of: selectedClimbType) {
updateFilteredProblems()
}
.onChange(of: selectedGym) {
updateFilteredProblems()
}
}
}
struct FilterSection: View {
@EnvironmentObject var dataManager: ClimbingDataManager
@Binding var selectedClimbType: ClimbType?
@Binding var selectedGym: Gym?
let filteredProblems: [Problem]
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()
}
}
}
}
}
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
@State private var problemToDelete: Problem?
@State private var problemToEdit: Problem?
@State private var animationKey = 0
var body: some View {
List(problems, id: \.id) { problem in
NavigationLink(destination: ProblemDetailView(problemId: problem.id)) {
ProblemRow(problem: problem)
}
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
Button(role: .destructive) {
problemToDelete = problem
} label: {
Label("Delete", systemImage: "trash")
}
Button {
// Use a spring animation for more natural movement
withAnimation(.spring(response: 0.5, dampingFraction: 0.8, blendDuration: 0.1))
{
let updatedProblem = problem.updated(isActive: !problem.isActive)
dataManager.updateProblem(updatedProblem)
}
} label: {
Label(
problem.isActive ? "Mark as Reset" : "Mark as Active",
systemImage: problem.isActive ? "xmark.circle" : "checkmark.circle")
}
.tint(.orange)
Button {
problemToEdit = problem
} label: {
HStack {
Image(systemName: "pencil")
Text("Edit")
}
}
.tint(.blue)
}
}
.animation(
.spring(response: 0.5, dampingFraction: 0.8, blendDuration: 0.1),
value: animationKey
)
.onChange(of: problems) {
animationKey += 1
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.scrollIndicators(.hidden)
.clipped()
.alert("Delete Problem", isPresented: .constant(problemToDelete != nil)) {
Button("Cancel", role: .cancel) {
problemToDelete = nil
}
Button("Delete", role: .destructive) {
if let problem = problemToDelete {
dataManager.deleteProblem(problem)
problemToDelete = nil
}
}
} message: {
Text(
"Are you sure you want to delete this problem? This will also delete all associated attempts."
)
}
.sheet(item: $problemToEdit) { problem in
AddEditProblemView(problemId: problem.id)
}
}
}
struct ProblemRow: View {
let problem: Problem
@EnvironmentObject var dataManager: ClimbingDataManager
private var gym: Gym? {
dataManager.gym(withId: problem.gymId)
}
private var isCompleted: Bool {
dataManager.attempts.contains { attempt in
attempt.problemId == problem.id && attempt.result.isSuccessful
}
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(problem.name ?? "Unnamed Problem")
.font(.headline)
.fontWeight(.semibold)
.foregroundColor(problem.isActive ? .primary : .secondary)
Text(gym?.name ?? "Unknown Gym")
.font(.subheadline)
.foregroundColor(.secondary)
}
Spacer()
VStack(alignment: .trailing, spacing: 4) {
HStack(spacing: 8) {
if !problem.imagePaths.isEmpty {
Image(systemName: "photo")
.font(.system(size: 14, weight: .medium))
.foregroundColor(.blue)
}
if isCompleted {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 14, weight: .medium))
.foregroundColor(.green)
}
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.isActive {
Text("Reset / No Longer Set")
.font(.caption)
.foregroundColor(.orange)
.fontWeight(.medium)
}
}
.padding(.vertical, 8)
}
}
struct EmptyProblemsView: View {
let isEmpty: Bool
let isFiltered: Bool
@EnvironmentObject var dataManager: ClimbingDataManager
@State private var showingAddProblem = false
var body: some View {
VStack(spacing: 20) {
Spacer()
Image(systemName: "star.fill")
.font(.system(size: 60))
.foregroundColor(.secondary)
VStack(spacing: 8) {
Text(title)
.font(.title2)
.fontWeight(.bold)
Text(subtitle)
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
}
if isEmpty && !dataManager.gyms.isEmpty {
Button("Add Problem") {
showingAddProblem = true
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
}
Spacer()
}
.sheet(isPresented: $showingAddProblem) {
AddEditProblemView()
}
}
private var title: String {
if isEmpty {
return dataManager.gyms.isEmpty ? "No Gyms Available" : "No Problems Yet"
} else {
return "No Problems Match Filters"
}
}
private var subtitle: String {
if isEmpty {
return dataManager.gyms.isEmpty
? "Add a gym first to start tracking problems and routes!"
: "Start tracking your favorite problems and routes!"
} else {
return "Try adjusting your filters to see more problems."
}
}
}
#Preview {
ProblemsView()
.environmentObject(ClimbingDataManager.preview)
}

View File

@@ -0,0 +1,284 @@
import Combine
import SwiftUI
struct SessionsView: View {
@EnvironmentObject var dataManager: ClimbingDataManager
@State private var showingAddSession = false
var body: some View {
NavigationStack {
Group {
if dataManager.sessions.isEmpty && dataManager.activeSession == nil {
EmptySessionsView()
} else {
SessionsList()
}
}
.navigationTitle("Sessions")
.navigationBarTitleDisplayMode(.automatic)
.toolbar {
ToolbarItemGroup(placement: .navigationBarTrailing) {
if dataManager.isSyncing {
HStack(spacing: 2) {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .blue))
.scaleEffect(0.6)
}
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(
Circle()
.fill(.regularMaterial)
)
.transition(.scale.combined(with: .opacity))
.animation(
.easeInOut(duration: 0.2), value: dataManager.isSyncing
)
}
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 SessionsList: View {
@EnvironmentObject var dataManager: ClimbingDataManager
@State private var sessionToDelete: ClimbSession?
private var completedSessions: [ClimbSession] {
dataManager.sessions
.filter { $0.status == .completed }
.sorted { $0.date > $1.date }
}
var body: some View {
List {
// Active session banner section
if let activeSession = dataManager.activeSession,
let gym = dataManager.gym(withId: activeSession.gymId)
{
Section {
ActiveSessionBanner(session: activeSession, gym: gym)
.padding(.horizontal, 16)
.listRowInsets(EdgeInsets(top: 16, leading: 0, bottom: 24, trailing: 0))
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
}
}
// Completed sessions section
if !completedSessions.isEmpty {
Section {
ForEach(completedSessions) { session in
NavigationLink(destination: SessionDetailView(sessionId: session.id)) {
SessionRow(session: session)
}
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
Button(role: .destructive) {
sessionToDelete = session
} label: {
Label("Delete", systemImage: "trash")
}
}
}
} header: {
if dataManager.activeSession != nil {
Text("Previous Sessions")
.font(.headline)
.fontWeight(.semibold)
}
}
}
}
.listStyle(.insetGrouped)
.alert("Delete Session", isPresented: .constant(sessionToDelete != nil)) {
Button("Cancel", role: .cancel) {
sessionToDelete = nil
}
Button("Delete", role: .destructive) {
if let session = sessionToDelete {
dataManager.deleteSession(session)
sessionToDelete = nil
}
}
} message: {
Text(
"Are you sure you want to delete this session? This will also delete all attempts associated with this session."
)
}
}
}
struct ActiveSessionBanner: View {
let session: ClimbSession
let gym: Gym
@EnvironmentObject var dataManager: ClimbingDataManager
@State private var navigateToDetail = false
var body: some View {
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(timerInterval: startTime...Date.distantFuture, countsDown: false)
.font(.caption)
.foregroundColor(.secondary)
.monospacedDigit()
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
.onTapGesture {
navigateToDetail = true
}
Button(action: {
dataManager.endSession(session.id)
}) {
Image(systemName: "stop.fill")
.font(.system(size: 16, weight: .bold))
.foregroundColor(.white)
.frame(width: 32, height: 32)
.background(Color.red)
.clipShape(Circle())
}
.buttonStyle(PlainButtonStyle())
}
.padding()
.background(
RoundedRectangle(cornerRadius: 12)
.fill(.green.opacity(0.1))
.stroke(.green.opacity(0.3), lineWidth: 1)
)
.navigationDestination(isPresented: $navigateToDetail) {
SessionDetailView(sessionId: session.id)
}
}
}
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, 8)
}
private func formatDate(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.dateStyle = .medium
return formatter.string(from: date)
}
}
struct EmptySessionsView: View {
@EnvironmentObject var dataManager: ClimbingDataManager
@State private var showingAddSession = false
var body: some View {
VStack(spacing: 20) {
Spacer()
Image(systemName: "figure.climbing")
.font(.system(size: 60))
.foregroundColor(.secondary)
VStack(spacing: 8) {
Text(dataManager.gyms.isEmpty ? "No Gyms Available" : "No Sessions Yet")
.font(.title2)
.fontWeight(.bold)
Text(
dataManager.gyms.isEmpty
? "Add a gym first to start tracking your climbing sessions!"
: "Start your first climbing session!"
)
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
}
if !dataManager.gyms.isEmpty {
Button("Start Session") {
if dataManager.gyms.count == 1 {
dataManager.startSession(gymId: dataManager.gyms.first!.id)
} else {
showingAddSession = true
}
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
}
Spacer()
}
.sheet(isPresented: $showingAddSession) {
AddEditSessionView()
}
}
}
#Preview {
SessionsView()
.environmentObject(ClimbingDataManager.preview)
}

View File

@@ -0,0 +1,998 @@
import HealthKit
import SwiftUI
import UniformTypeIdentifiers
enum SheetType {
case export(Data)
case importData
}
struct SettingsView: View {
@EnvironmentObject var dataManager: ClimbingDataManager
@State private var activeSheet: SheetType?
var body: some View {
NavigationStack {
List {
SyncSection()
.environmentObject(dataManager.syncService)
HealthKitSection()
.environmentObject(dataManager.healthKitService)
DataManagementSection(
activeSheet: $activeSheet
)
AppInfoSection()
}
.navigationTitle("Settings")
.navigationBarTitleDisplayMode(.automatic)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
if dataManager.isSyncing {
HStack(spacing: 2) {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .blue))
.scaleEffect(0.6)
}
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(
Circle()
.fill(.regularMaterial)
)
.transition(.scale.combined(with: .opacity))
.animation(
.easeInOut(duration: 0.2), value: dataManager.isSyncing
)
}
}
}
.sheet(
item: Binding<SheetType?>(
get: { activeSheet },
set: { activeSheet = $0 }
)
) { sheetType in
switch sheetType {
case .export(let data):
ExportDataView(data: data)
case .importData:
ImportDataView()
}
}
}
}
}
extension SheetType: Identifiable {
var id: String {
switch self {
case .export: return "export"
case .importData: return "import"
}
}
}
struct DataManagementSection: View {
@EnvironmentObject var dataManager: ClimbingDataManager
@Binding var activeSheet: SheetType?
@State private var showingResetAlert = false
@State private var isExporting = false
@State private var isDeletingImages = false
@State private var showingDeleteImagesAlert = 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: {
activeSheet = .importData
}) {
HStack {
Image(systemName: "square.and.arrow.down")
.foregroundColor(.green)
Text("Import Data")
Spacer()
}
}
.foregroundColor(.primary)
// Delete All Images
Button(action: {
showingDeleteImagesAlert = true
}) {
HStack {
if isDeletingImages {
ProgressView()
.scaleEffect(0.8)
Text("Deleting Images...")
.foregroundColor(.secondary)
} else {
Image(systemName: "trash")
.foregroundColor(.red)
Text("Delete All Images")
}
Spacer()
}
}
.disabled(isDeletingImages)
.foregroundColor(.red)
// 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."
)
}
.alert("Delete All Images", isPresented: $showingDeleteImagesAlert) {
Button("Cancel", role: .cancel) {}
Button("Delete", role: .destructive) {
deleteAllImages()
}
} message: {
Text(
"This will permanently delete ALL image files from your device.\n\nProblems will keep their references but the actual image files will be removed. This cannot be undone.\n\nConsider exporting your data first if you want to keep your images."
)
}
}
private func exportDataAsync() {
isExporting = true
Task {
let data = await MainActor.run { dataManager.exportData() }
isExporting = false
if let data = data {
activeSheet = .export(data)
}
}
}
private func deleteAllImages() {
isDeletingImages = true
Task {
await MainActor.run {
deleteAllImageFiles()
isDeletingImages = false
dataManager.successMessage = "All images deleted successfully!"
}
}
}
private func deleteAllImageFiles() {
let imageManager = ImageManager.shared
let fileManager = FileManager.default
// Delete all images from the images directory
let imagesDir = imageManager.imagesDirectory
do {
let imageFiles = try fileManager.contentsOfDirectory(
at: imagesDir, includingPropertiesForKeys: nil)
var deletedCount = 0
for imageFile in imageFiles {
do {
try fileManager.removeItem(at: imageFile)
deletedCount += 1
} catch {
print("Failed to delete image: \(imageFile.lastPathComponent)")
}
}
print("Deleted \(deletedCount) image files")
} catch {
print("Failed to access images directory: \(error)")
}
// Delete all images from backup directory
let backupDir = imageManager.backupDirectory
do {
let backupFiles = try fileManager.contentsOfDirectory(
at: backupDir, includingPropertiesForKeys: nil)
for backupFile in backupFiles {
try? fileManager.removeItem(at: backupFile)
}
} catch {
print("Failed to access backup directory: \(error)")
}
// Clear image paths from all problems
let updatedProblems = dataManager.problems.map { problem in
problem.updated(imagePaths: [])
}
for problem in updatedProblems {
dataManager.updateProblem(problem)
}
}
}
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("AppLogo")
.resizable()
.frame(width: 24, height: 24)
VStack(alignment: .leading) {
Text("Ascently")
.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)
}
}
}
}
struct ExportDataView: View {
let data: Data
@Environment(\.dismiss) private var dismiss
@State private var tempFileURL: URL?
@State private var isCreatingFile = true
var body: some View {
NavigationStack {
VStack(spacing: 30) {
if isCreatingFile {
// Loading state - more prominent
VStack(spacing: 20) {
ProgressView()
.scaleEffect(1.5)
.tint(.blue)
Text("Preparing Your Export")
.font(.title2)
.fontWeight(.semibold)
Text("Creating ZIP file with your climbing data and images...")
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
// Ready state
VStack(spacing: 20) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 60))
.foregroundColor(.green)
Text("Export Ready!")
.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(
"Ascently Data Export",
image: Image("AppLogo"))
) {
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)
}
}
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 = "ascently_export_\(timestamp).zip"
guard
let documentsURL = FileManager.default.urls(
for: .documentDirectory, in: .userDomainMask
).first
else {
print("Could not access Documents directory")
DispatchQueue.main.async {
self.isCreatingFile = false
}
return
}
let fileURL = documentsURL.appendingPathComponent(filename)
// Write the ZIP data to the file
try data.write(to: fileURL)
DispatchQueue.main.async {
self.tempFileURL = fileURL
self.isCreatingFile = false
}
} catch {
print("Failed to create export file: \(error)")
DispatchQueue.main.async {
self.isCreatingFile = false
}
}
}
}
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 SyncSection: View {
@EnvironmentObject var syncService: SyncService
@EnvironmentObject var dataManager: ClimbingDataManager
@State private var showingSyncSettings = false
@State private var showingDisconnectAlert = false
var body: some View {
Section("Sync") {
// Sync Status
HStack {
Image(
systemName: syncService.isConnected
? "checkmark.circle.fill"
: syncService.isConfigured
? "exclamationmark.triangle.fill"
: "exclamationmark.circle.fill"
)
.foregroundColor(
syncService.isConnected
? .green
: syncService.isConfigured
? .orange
: .red
)
VStack(alignment: .leading) {
Text("Sync Server")
.font(.headline)
Text(
syncService.isConnected
? "Connected"
: syncService.isConfigured
? "Configured - Not tested"
: "Not configured"
)
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
}
// Configure Server
Button(action: {
showingSyncSettings = true
}) {
HStack {
Image(systemName: "gear")
.foregroundColor(.blue)
Text("Configure Server")
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundColor(.secondary)
}
}
.foregroundColor(.primary)
if syncService.isConfigured {
// Sync Now - only show if connected
if syncService.isConnected {
Button(action: {
performSync()
}) {
HStack {
if syncService.isSyncing {
ProgressView()
.scaleEffect(0.8)
Text("Syncing...")
.foregroundColor(.secondary)
} else {
Image(systemName: "arrow.triangle.2.circlepath")
.foregroundColor(.green)
Text("Sync Now")
Spacer()
if let lastSync = syncService.lastSyncTime {
Text(
RelativeDateTimeFormatter().localizedString(
for: lastSync, relativeTo: Date())
)
.font(.caption)
.foregroundColor(.secondary)
}
}
}
}
.disabled(syncService.isSyncing)
.foregroundColor(.primary)
}
// Auto-sync configuration - always visible for testing
HStack {
VStack(alignment: .leading) {
Text("Auto-sync")
Text("Sync automatically on app launch and data changes")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Toggle(
"",
isOn: Binding(
get: { syncService.isAutoSyncEnabled },
set: { syncService.isAutoSyncEnabled = $0 }
)
)
.disabled(!syncService.isConnected)
}
.foregroundColor(.primary)
// Disconnect option - only show if connected
if syncService.isConnected {
Button(action: {
showingDisconnectAlert = true
}) {
HStack {
Image(systemName: "power")
.foregroundColor(.orange)
Text("Disconnect")
Spacer()
}
}
.foregroundColor(.primary)
}
if let error = syncService.syncError {
Text(error)
.font(.caption)
.foregroundColor(.red)
.padding(.leading, 24)
}
}
}
.sheet(isPresented: $showingSyncSettings) {
SyncSettingsView()
.environmentObject(syncService)
}
.alert("Disconnect from Server", isPresented: $showingDisconnectAlert) {
Button("Cancel", role: .cancel) {}
Button("Disconnect", role: .destructive) {
syncService.disconnect()
}
} message: {
Text(
"This will sign you out but keep your server settings. You'll need to test the connection again to sync."
)
}
}
private func performSync() {
Task {
do {
try await syncService.syncWithServer(dataManager: dataManager)
} catch {
print("Sync failed: \(error)")
}
}
}
}
struct SyncSettingsView: View {
@EnvironmentObject var syncService: SyncService
@Environment(\.dismiss) private var dismiss
@State private var serverURL: String = ""
@State private var authToken: String = ""
@State private var showingDisconnectAlert = false
@State private var isTesting = false
@State private var showingTestResult = false
@State private var testResultMessage = ""
var body: some View {
NavigationStack {
Form {
Section {
TextField("Server URL", text: $serverURL)
.textFieldStyle(.roundedBorder)
.keyboardType(.URL)
.autocapitalization(.none)
.disableAutocorrection(true)
.placeholder(when: serverURL.isEmpty) {
Text("http://your-server:8080")
.foregroundColor(.secondary)
}
TextField("Auth Token", text: $authToken)
.textFieldStyle(.roundedBorder)
.autocapitalization(.none)
.disableAutocorrection(true)
.placeholder(when: authToken.isEmpty) {
Text("your-secret-token")
.foregroundColor(.secondary)
}
} header: {
Text("Server Configuration")
} footer: {
Text(
"Enter your sync server URL and authentication token. You must test the connection before syncing is available."
)
}
Section {
Button(action: {
testConnection()
}) {
HStack {
if isTesting {
ProgressView()
.scaleEffect(0.8)
Text("Testing...")
.foregroundColor(.secondary)
} else {
Image(systemName: "network")
.foregroundColor(.blue)
Text("Test Connection")
Spacer()
if syncService.isConnected {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
}
}
}
}
.disabled(
isTesting
|| serverURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|| authToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
)
.foregroundColor(.primary)
} header: {
Text("Connection")
} footer: {
Text("Test the connection to verify your server settings before saving.")
}
Section {
Button("Disconnect from Server") {
showingDisconnectAlert = true
}
.foregroundColor(.orange)
Button("Clear Configuration") {
syncService.clearConfiguration()
serverURL = ""
authToken = ""
}
.foregroundColor(.red)
} footer: {
Text(
"Disconnect will sign you out but keep settings. Clear Configuration removes all sync settings."
)
}
}
.navigationTitle("Sync Settings")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Save") {
let newURL = serverURL.trimmingCharacters(in: .whitespacesAndNewlines)
let newToken = authToken.trimmingCharacters(in: .whitespacesAndNewlines)
// Mark as disconnected if settings changed
if newURL != syncService.serverURL || newToken != syncService.authToken {
syncService.isConnected = false
UserDefaults.standard.set(false, forKey: "sync_is_connected")
}
syncService.serverURL = newURL
syncService.authToken = newToken
dismiss()
}
.fontWeight(.semibold)
}
}
}
.onAppear {
serverURL = syncService.serverURL
authToken = syncService.authToken
}
.alert("Disconnect from Server", isPresented: $showingDisconnectAlert) {
Button("Cancel", role: .cancel) {}
Button("Disconnect", role: .destructive) {
syncService.disconnect()
dismiss()
}
} message: {
Text(
"This will sign you out but keep your server settings. You'll need to test the connection again to sync."
)
}
.alert("Connection Test", isPresented: $showingTestResult) {
Button("OK") {}
} message: {
Text(testResultMessage)
}
}
private func testConnection() {
isTesting = true
let testURL = serverURL.trimmingCharacters(in: .whitespacesAndNewlines)
let testToken = authToken.trimmingCharacters(in: .whitespacesAndNewlines)
// Store original values in case test fails
let originalURL = syncService.serverURL
let originalToken = syncService.authToken
Task {
do {
// Temporarily set the values for testing
syncService.serverURL = testURL
syncService.authToken = testToken
try await syncService.testConnection()
await MainActor.run {
isTesting = false
testResultMessage =
"Connection successful! You can now save and sync your data."
showingTestResult = true
}
} catch {
// Restore original values if test failed
syncService.serverURL = originalURL
syncService.authToken = originalToken
await MainActor.run {
isTesting = false
testResultMessage = "Connection failed: \(error.localizedDescription)"
showingTestResult = true
}
}
}
}
}
// Removed AutoSyncSettingsView - now using simple toggle in main settings
extension View {
func placeholder<Content: View>(
when shouldShow: Bool,
alignment: Alignment = .leading,
@ViewBuilder placeholder: () -> Content
) -> some View {
ZStack(alignment: alignment) {
placeholder().opacity(shouldShow ? 1 : 0)
self
}
}
}
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 {
NavigationStack {
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("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
// Auto-close after successful import
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
dismiss()
}
}
} catch {
await MainActor.run {
isImporting = false
importError = "Import failed: \(error.localizedDescription)"
}
}
}
}
}
struct HealthKitSection: View {
@EnvironmentObject var healthKitService: HealthKitService
@State private var showingAuthorizationError = false
@State private var isRequestingAuthorization = false
var body: some View {
Section {
if !HKHealthStore.isHealthDataAvailable() {
HStack {
Image(systemName: "heart.slash")
.foregroundColor(.secondary)
Text("Apple Health not available")
.foregroundColor(.secondary)
}
} else {
Toggle(
isOn: Binding(
get: { healthKitService.isEnabled },
set: { newValue in
if newValue && !healthKitService.isAuthorized {
isRequestingAuthorization = true
Task {
do {
try await healthKitService.requestAuthorization()
await MainActor.run {
healthKitService.setEnabled(true)
isRequestingAuthorization = false
}
} catch {
await MainActor.run {
showingAuthorizationError = true
isRequestingAuthorization = false
}
}
}
} else if newValue {
healthKitService.setEnabled(true)
} else {
healthKitService.setEnabled(false)
}
}
)
) {
HStack {
Image(systemName: "heart.fill")
.foregroundColor(.red)
Text("Apple Health Integration")
}
}
.disabled(isRequestingAuthorization)
if healthKitService.isEnabled {
VStack(alignment: .leading, spacing: 4) {
Text(
"Climbing sessions will be recorded as workouts in Apple Health"
)
.font(.caption)
.foregroundColor(.secondary)
}
}
}
} header: {
Text("Health")
} footer: {
if healthKitService.isEnabled {
Text(
"Each climbing session will automatically be saved to Apple Health as a \"Climbing\" workout with the session duration."
)
}
}
.alert("Authorization Required", isPresented: $showingAuthorizationError) {
Button("OK", role: .cancel) {}
} message: {
Text(
"Please grant access to Apple Health in Settings to enable this feature."
)
}
}
}
#Preview {
SettingsView()
.environmentObject(ClimbingDataManager.preview)
}