All checks were successful
OpenClimb Docker Deploy / build-and-push (push) Successful in 2m29s
999 lines
36 KiB
Swift
999 lines
36 KiB
Swift
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("OpenClimb")
|
|
.font(.headline)
|
|
Text("Track your climbing progress")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
Spacer()
|
|
}
|
|
|
|
HStack {
|
|
Image(systemName: "info.circle")
|
|
.foregroundColor(.blue)
|
|
Text("Version")
|
|
Spacer()
|
|
Text("\(appVersion) (\(buildNumber))")
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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(
|
|
"OpenClimb 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 = "openclimb_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)
|
|
}
|