422 lines
14 KiB
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)
|
|
}
|