1.5.0 Initial run as iOS in a monorepo
This commit is contained in:
441
ios/OpenClimb/Views/SettingsView.swift
Normal file
441
ios/OpenClimb/Views/SettingsView.swift
Normal file
@@ -0,0 +1,441 @@
|
||||
//
|
||||
// SettingsView.swift
|
||||
// OpenClimb
|
||||
//
|
||||
// Created by OpenClimb on 2025-01-17.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
struct SettingsView: View {
|
||||
@EnvironmentObject var dataManager: ClimbingDataManager
|
||||
@State private var showingResetAlert = false
|
||||
@State private var showingExportSheet = false
|
||||
@State private var showingImportSheet = false
|
||||
@State private var exportData: Data?
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
List {
|
||||
DataManagementSection()
|
||||
|
||||
AppInfoSection()
|
||||
}
|
||||
.navigationTitle("Settings")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct DataManagementSection: View {
|
||||
@EnvironmentObject var dataManager: ClimbingDataManager
|
||||
@State private var showingResetAlert = false
|
||||
@State private var showingExportSheet = false
|
||||
@State private var showingImportSheet = false
|
||||
@State private var exportData: Data?
|
||||
@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: {
|
||||
showingImportSheet = true
|
||||
}) {
|
||||
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."
|
||||
)
|
||||
}
|
||||
.sheet(isPresented: $showingExportSheet) {
|
||||
if let data = exportData {
|
||||
ExportDataView(data: data)
|
||||
} else {
|
||||
Text("No export data available")
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingImportSheet) {
|
||||
ImportDataView()
|
||||
}
|
||||
}
|
||||
|
||||
private func exportDataAsync() {
|
||||
isExporting = true
|
||||
|
||||
Task {
|
||||
let data = await withCheckedContinuation { continuation in
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
let result = dataManager.exportData()
|
||||
continuation.resume(returning: result)
|
||||
}
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
isExporting = false
|
||||
if let data = data {
|
||||
exportData = data
|
||||
showingExportSheet = true
|
||||
} else {
|
||||
// Error message should already be set by dataManager
|
||||
exportData = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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(systemName: "mountain.2.fill")
|
||||
.foregroundColor(.blue)
|
||||
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)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Image(systemName: "person.fill")
|
||||
.foregroundColor(.blue)
|
||||
Text("Developer")
|
||||
Spacer()
|
||||
Text("OpenClimb Team")
|
||||
.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(systemName: "mountain.2.fill"))
|
||||
) {
|
||||
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(
|
||||
"Fully compatible with Android exports - identical ZIP format with images."
|
||||
)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.blue)
|
||||
.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
|
||||
dismiss()
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
isImporting = false
|
||||
importError = "Import failed: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
SettingsView()
|
||||
.environmentObject(ClimbingDataManager.preview)
|
||||
}
|
||||
Reference in New Issue
Block a user