Files
Ascently/ios/OpenClimb/Views/SettingsView.swift
Atridad Lahiji 30d2b3938e
All checks were successful
OpenClimb Docker Deploy / build-and-push (push) Successful in 2m29s
[Android] 1.9.2
2025-10-12 20:41:39 -06:00

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)
}