Files
Ascently/ios/OpenClimb/Views/SettingsView.swift

422 lines
14 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 {
List {
DataManagementSection(
activeSheet: $activeSheet
)
AppInfoSection()
}
.navigationTitle("Settings")
.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?
var body: some View {
NavigationView {
VStack(spacing: 20) {
Image(systemName: "square.and.arrow.up")
.font(.system(size: 60))
.foregroundColor(.blue)
Text("Export Data")
.font(.title)
.fontWeight(.bold)
Text(
"Your climbing data has been prepared for export. Use the share button below to save or send your data."
)
.multilineTextAlignment(.center)
.padding(.horizontal)
if let fileURL = tempFileURL {
ShareLink(
item: fileURL,
preview: SharePreview(
"OpenClimb Data Export",
image: Image("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)
} else {
Button(action: {}) {
Label("Preparing Export...", systemImage: "hourglass")
.font(.headline)
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding()
.background(
RoundedRectangle(cornerRadius: 12)
.fill(.gray)
)
}
.disabled(true)
.padding(.horizontal)
}
Spacer()
}
.padding()
.navigationTitle("Export")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") {
dismiss()
}
}
}
.onAppear {
if tempFileURL == nil {
createTempFile()
}
}
.onDisappear {
// Delay cleanup to ensure sharing is complete
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
cleanupTempFile()
}
}
}
}
private func createTempFile() {
DispatchQueue.global(qos: .userInitiated).async {
do {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
let isoString = formatter.string(from: Date())
let timestamp = isoString.replacingOccurrences(of: ":", with: "-")
.replacingOccurrences(of: ".", with: "-")
let filename = "openclimb_export_\(timestamp).zip"
guard
let documentsURL = FileManager.default.urls(
for: .documentDirectory, in: .userDomainMask
).first
else {
print("Could not access Documents directory")
return
}
let fileURL = documentsURL.appendingPathComponent(filename)
// Write the ZIP data to the file
try data.write(to: fileURL)
DispatchQueue.main.async {
self.tempFileURL = fileURL
}
} catch {
print("Failed to create export file: \(error)")
}
}
}
private func cleanupTempFile() {
if let fileURL = tempFileURL {
// Clean up after a delay to ensure sharing is complete
DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) {
try? FileManager.default.removeItem(at: fileURL)
print("Cleaned up export file: \(fileURL.lastPathComponent)")
}
}
}
}
struct ImportDataView: View {
@EnvironmentObject var dataManager: ClimbingDataManager
@Environment(\.dismiss) private var dismiss
@State private var isImporting = false
@State private var importError: String?
@State private var showingDocumentPicker = false
var body: some View {
NavigationView {
VStack(spacing: 20) {
Image(systemName: "square.and.arrow.down")
.font(.system(size: 60))
.foregroundColor(.green)
Text("Import Data")
.font(.title)
.fontWeight(.bold)
VStack(spacing: 12) {
Text("Import climbing data from a previously exported ZIP file.")
.multilineTextAlignment(.center)
Text("⚠️ 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)
}