Moved to Ascently
All checks were successful
Ascently Docker Deploy / build-and-push (push) Successful in 2m31s
14
ios/Ascently/Ascently.entitlements
Normal 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>
|
||||
10
ios/Ascently/AscentlyApp.swift
Normal file
@@ -0,0 +1,10 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct AscentlyApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
@@ -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 |
@@ -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 |
@@ -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 |
56
ios/Ascently/Assets.xcassets/AppLogo.imageset/Contents.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
BIN
ios/Ascently/Assets.xcassets/AppLogo.imageset/app_logo_256.png
vendored
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
ios/Ascently/Assets.xcassets/AppLogo.imageset/app_logo_256_dark.png
vendored
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
6
ios/Ascently/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
39
ios/Ascently/Components/AsyncImageView.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
54
ios/Ascently/Components/CameraImagePicker.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
85
ios/Ascently/Components/PhotoOptionSheet.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
176
ios/Ascently/ContentView.swift
Normal 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
@@ -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>
|
||||
19
ios/Ascently/Models/ActivityAttributes.swift
Normal 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())
|
||||
}
|
||||
}
|
||||
460
ios/Ascently/Models/BackupFormat.swift
Normal 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)) }
|
||||
}
|
||||
}
|
||||
563
ios/Ascently/Models/DataModels.swift
Normal 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
|
||||
}
|
||||
}
|
||||
236
ios/Ascently/Services/HealthKitService.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
1224
ios/Ascently/Services/SyncService.swift
Normal file
115
ios/Ascently/Utils/AppIconHelper.swift
Normal 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
|
||||
83
ios/Ascently/Utils/DataStateManager.swift
Normal 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()))"
|
||||
}
|
||||
}
|
||||
578
ios/Ascently/Utils/IconTestView.swift
Normal 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
|
||||
955
ios/Ascently/Utils/ImageManager.swift
Normal 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)")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
160
ios/Ascently/Utils/ImageNamingUtils.swift
Normal 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
|
||||
}
|
||||
}
|
||||
147
ios/Ascently/Utils/OrientationAwareImage.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
632
ios/Ascently/Utils/ZipUtils.swift
Normal 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: ¤tOffset
|
||||
)
|
||||
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = .prettyPrinted
|
||||
encoder.dateEncodingStrategy = .custom { date, encoder in
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS"
|
||||
var container = encoder.singleValueContainer()
|
||||
try container.encode(formatter.string(from: date))
|
||||
}
|
||||
let jsonData = try encoder.encode(exportData)
|
||||
try addFileToZip(
|
||||
filename: DATA_JSON_FILENAME,
|
||||
fileData: jsonData,
|
||||
zipData: &zipData,
|
||||
fileEntries: &fileEntries,
|
||||
currentOffset: ¤tOffset
|
||||
)
|
||||
|
||||
print("Processing \(referencedImagePaths.count) referenced image paths")
|
||||
var successfulImages = 0
|
||||
|
||||
for imagePath in referencedImagePaths {
|
||||
print("Processing image path: \(imagePath)")
|
||||
let imageURL = URL(fileURLWithPath: imagePath)
|
||||
let imageName = imageURL.lastPathComponent
|
||||
print("Image name: \(imageName)")
|
||||
|
||||
if FileManager.default.fileExists(atPath: imagePath) {
|
||||
print("Image file exists at: \(imagePath)")
|
||||
do {
|
||||
let imageData = try Data(contentsOf: imageURL)
|
||||
print("Image data size: \(imageData.count) bytes")
|
||||
if imageData.count > 0 {
|
||||
let imageEntryName = "\(IMAGES_DIR_NAME)/\(imageName)"
|
||||
try addFileToZip(
|
||||
filename: imageEntryName,
|
||||
fileData: imageData,
|
||||
zipData: &zipData,
|
||||
fileEntries: &fileEntries,
|
||||
currentOffset: ¤tOffset
|
||||
)
|
||||
successfulImages += 1
|
||||
print("Successfully added image to ZIP: \(imageEntryName)")
|
||||
} else {
|
||||
print("Image data is empty for: \(imagePath)")
|
||||
}
|
||||
} catch {
|
||||
print("Failed to read image data for \(imagePath): \(error)")
|
||||
}
|
||||
} else {
|
||||
print("Image file does not exist at: \(imagePath)")
|
||||
}
|
||||
}
|
||||
|
||||
print("Export completed: \(successfulImages)/\(referencedImagePaths.count) images included")
|
||||
|
||||
for entry in fileEntries {
|
||||
let centralDirEntry = createCentralDirectoryEntry(
|
||||
filename: entry.name,
|
||||
fileData: entry.data,
|
||||
localHeaderOffset: entry.offset
|
||||
)
|
||||
centralDirectory.append(centralDirEntry)
|
||||
}
|
||||
|
||||
let centralDirOffset = UInt32(zipData.count)
|
||||
zipData.append(centralDirectory)
|
||||
|
||||
let endOfCentralDir = createEndOfCentralDirectory(
|
||||
numEntries: UInt16(fileEntries.count),
|
||||
centralDirSize: UInt32(centralDirectory.count),
|
||||
centralDirOffset: centralDirOffset
|
||||
)
|
||||
zipData.append(endOfCentralDir)
|
||||
|
||||
return zipData
|
||||
}
|
||||
|
||||
static func extractImportZip(data: Data) throws -> ImportResult {
|
||||
print("Starting ZIP extraction - data size: \(data.count) bytes")
|
||||
|
||||
return try extractUsingCustomParser(data: data)
|
||||
}
|
||||
|
||||
private static func extractUsingCustomParser(data: Data) throws -> ImportResult {
|
||||
var jsonContent = ""
|
||||
var metadataContent = ""
|
||||
var importedImagePaths: [String: String] = [:]
|
||||
|
||||
let zipEntries: [ZipEntry]
|
||||
do {
|
||||
zipEntries = try parseZipFile(data: data)
|
||||
print("Successfully parsed ZIP file with \(zipEntries.count) entries")
|
||||
} catch {
|
||||
print("Failed to parse ZIP file: \(error)")
|
||||
print(
|
||||
"ZIP data header: \(data.prefix(20).map { String(format: "%02X", $0) }.joined(separator: " "))"
|
||||
)
|
||||
throw NSError(
|
||||
domain: "ImportError", code: 1,
|
||||
userInfo: [
|
||||
NSLocalizedDescriptionKey:
|
||||
"Failed to parse ZIP file: \(error.localizedDescription). This may be due to incompatibility with the ZIP format."
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
print("Found \(zipEntries.count) entries in ZIP file:")
|
||||
for entry in zipEntries {
|
||||
print(" - \(entry.filename) (size: \(entry.data.count) bytes)")
|
||||
}
|
||||
|
||||
for entry in zipEntries {
|
||||
switch entry.filename {
|
||||
case METADATA_FILENAME:
|
||||
metadataContent = String(data: entry.data, encoding: .utf8) ?? ""
|
||||
print("Found metadata: \(metadataContent.prefix(100))...")
|
||||
|
||||
case DATA_JSON_FILENAME:
|
||||
jsonContent = String(data: entry.data, encoding: .utf8) ?? ""
|
||||
print("Found data.json with \(jsonContent.count) characters")
|
||||
if jsonContent.isEmpty {
|
||||
print("WARNING: data.json is empty!")
|
||||
} else {
|
||||
print("data.json preview: \(jsonContent.prefix(200))...")
|
||||
}
|
||||
|
||||
default:
|
||||
if entry.filename.hasPrefix("\(IMAGES_DIR_NAME)/") && !entry.filename.hasSuffix("/")
|
||||
{
|
||||
let originalFilename = String(
|
||||
entry.filename.dropFirst("\(IMAGES_DIR_NAME)/".count))
|
||||
|
||||
do {
|
||||
let 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]
|
||||
}
|
||||
1429
ios/Ascently/ViewModels/ClimbingDataManager.swift
Normal file
272
ios/Ascently/ViewModels/LiveActivityManager.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1326
ios/Ascently/Views/AddEdit/AddAttemptView.swift
Normal file
209
ios/Ascently/Views/AddEdit/AddEditGymView.swift
Normal 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)
|
||||
}
|
||||
637
ios/Ascently/Views/AddEdit/AddEditProblemView.swift
Normal 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)
|
||||
}
|
||||
136
ios/Ascently/Views/AddEdit/AddEditSessionView.swift
Normal 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)
|
||||
}
|
||||
547
ios/Ascently/Views/AnalyticsView.swift
Normal 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)
|
||||
}
|
||||
425
ios/Ascently/Views/Detail/GymDetailView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
468
ios/Ascently/Views/Detail/ProblemDetailView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
452
ios/Ascently/Views/Detail/SessionDetailView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
218
ios/Ascently/Views/GymsView.swift
Normal 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)
|
||||
}
|
||||
278
ios/Ascently/Views/LiveActivityDebugView.swift
Normal 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)
|
||||
}
|
||||
530
ios/Ascently/Views/ProblemsView.swift
Normal 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)
|
||||
}
|
||||
284
ios/Ascently/Views/SessionsView.swift
Normal 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)
|
||||
}
|
||||
998
ios/Ascently/Views/SettingsView.swift
Normal 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)
|
||||
}
|
||||