822 lines
29 KiB
Swift
822 lines
29 KiB
Swift
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)
|
|
|
|
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
|
|
|
|
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)
|
|
|
|
// 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."
|
|
)
|
|
}
|
|
}
|
|
|
|
private func exportDataAsync() {
|
|
isExporting = true
|
|
Task {
|
|
let data = await MainActor.run { dataManager.exportData() }
|
|
isExporting = false
|
|
if let data = data {
|
|
activeSheet = .export(data)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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("MountainsIcon")
|
|
.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("MountainsIcon"))
|
|
) {
|
|
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)"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
SettingsView()
|
|
.environmentObject(ClimbingDataManager.preview)
|
|
}
|