1168 lines
42 KiB
Swift
1168 lines
42 KiB
Swift
import HealthKit
|
|
import MusicKit
|
|
import SwiftUI
|
|
import UniformTypeIdentifiers
|
|
|
|
enum SheetType {
|
|
case export(Data)
|
|
case importData
|
|
}
|
|
|
|
struct SettingsView: View {
|
|
@EnvironmentObject var dataManager: ClimbingDataManager
|
|
@EnvironmentObject var themeManager: ThemeManager
|
|
@State private var activeSheet: SheetType?
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
List {
|
|
SyncSection()
|
|
.environmentObject(dataManager.syncService)
|
|
|
|
HealthKitSection()
|
|
.environmentObject(dataManager.healthKitService)
|
|
|
|
MusicSection()
|
|
.environmentObject(dataManager.musicService)
|
|
|
|
AppearanceSection()
|
|
|
|
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 AppearanceSection: View {
|
|
@EnvironmentObject var themeManager: ThemeManager
|
|
|
|
let columns = [
|
|
GridItem(.adaptive(minimum: 44))
|
|
]
|
|
|
|
var body: some View {
|
|
Section("Appearance") {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Text("Accent Color")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
.textCase(.uppercase)
|
|
|
|
LazyVGrid(columns: columns, spacing: 12) {
|
|
ForEach(ThemeManager.presetColors, id: \.self) { color in
|
|
Circle()
|
|
.fill(color)
|
|
.frame(width: 44, height: 44)
|
|
.overlay(
|
|
ZStack {
|
|
if isSelected(color) {
|
|
Image(systemName: "checkmark")
|
|
.font(.headline)
|
|
.foregroundColor(.white)
|
|
.shadow(radius: 1)
|
|
}
|
|
}
|
|
)
|
|
.onTapGesture {
|
|
withAnimation {
|
|
themeManager.accentColor = color
|
|
}
|
|
}
|
|
.accessibilityLabel(colorDescription(for: color))
|
|
.accessibilityAddTraits(isSelected(color) ? .isSelected : [])
|
|
}
|
|
}
|
|
.padding(.vertical, 8)
|
|
}
|
|
|
|
if !isSelected(.blue) {
|
|
Button("Reset to Default") {
|
|
withAnimation {
|
|
themeManager.resetToDefault()
|
|
}
|
|
}
|
|
.foregroundColor(.red)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func isSelected(_ color: Color) -> Bool {
|
|
// Compare using UIColor to handle different Color initializers
|
|
let selectedUIColor = UIColor(themeManager.accentColor)
|
|
let targetUIColor = UIColor(color)
|
|
|
|
// Simple equality check might fail for some system colors, so we check components if needed
|
|
// But usually UIColor equality is robust enough for system colors
|
|
return selectedUIColor == targetUIColor
|
|
}
|
|
|
|
private func colorDescription(for color: Color) -> String {
|
|
switch color {
|
|
case .blue: return "Blue"
|
|
case .purple: return "Purple"
|
|
case .pink: return "Pink"
|
|
case .red: return "Red"
|
|
case .orange: return "Orange"
|
|
case .green: return "Green"
|
|
case .teal: return "Teal"
|
|
case .indigo: return "Indigo"
|
|
case .mint: return "Mint"
|
|
case Color(uiColor: .systemBrown): return "Brown"
|
|
case Color(uiColor: .systemCyan): return "Cyan"
|
|
default: return "Color"
|
|
}
|
|
}
|
|
}
|
|
|
|
struct DataManagementSection: View {
|
|
@EnvironmentObject var dataManager: ClimbingDataManager
|
|
@EnvironmentObject var themeManager: ThemeManager
|
|
@Binding var activeSheet: SheetType?
|
|
@State private var showingResetAlert = false
|
|
@State private var isExporting = false
|
|
|
|
@State private var isDeletingImages = false
|
|
@State private var showingDeleteImagesAlert = false
|
|
|
|
private static let logTag = "DataManagementSection"
|
|
|
|
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(themeManager.accentColor)
|
|
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:
|
|
|
|
• All gyms and their information
|
|
• All problems and their images
|
|
• All climbing sessions
|
|
• All attempts and progress data
|
|
|
|
This 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.
|
|
|
|
Problems will keep their references but the actual image files will be removed. \
|
|
This cannot be undone.
|
|
|
|
Consider exporting your data first if you want to keep your images.
|
|
""")
|
|
}
|
|
}
|
|
|
|
private func exportDataAsync() {
|
|
isExporting = true
|
|
Task {
|
|
let data = await dataManager.exportData()
|
|
await MainActor.run {
|
|
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 {
|
|
AppLogger.error(
|
|
"Failed to delete image: \(imageFile.lastPathComponent)", tag: Self.logTag)
|
|
}
|
|
}
|
|
|
|
AppLogger.info("Deleted \(deletedCount) image files", tag: Self.logTag)
|
|
} catch {
|
|
AppLogger.error("Failed to access images directory: \(error)", tag: Self.logTag)
|
|
}
|
|
|
|
// 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 {
|
|
AppLogger.error("Failed to access backup directory: \(error)", tag: Self.logTag)
|
|
}
|
|
|
|
// 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 {
|
|
@EnvironmentObject var themeManager: ThemeManager
|
|
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: "info.circle")
|
|
.foregroundColor(themeManager.accentColor)
|
|
Text("Version")
|
|
Spacer()
|
|
Text("\(appVersion) (\(buildNumber))")
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct ExportDataView: View {
|
|
let data: Data
|
|
@Environment(\.dismiss) private var dismiss
|
|
@EnvironmentObject var themeManager: ThemeManager
|
|
@State private var tempFileURL: URL?
|
|
@State private var isCreatingFile = true
|
|
|
|
private static let logTag = "ExportDataView"
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
VStack(spacing: 30) {
|
|
if isCreatingFile {
|
|
// Loading state
|
|
VStack(spacing: 20) {
|
|
ProgressView()
|
|
.scaleEffect(1.5)
|
|
.tint(themeManager.accentColor)
|
|
|
|
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(
|
|
"Ascently Data Export",
|
|
image: Image("AppLogo"))
|
|
) {
|
|
Label("Share Data", systemImage: "square.and.arrow.up")
|
|
.font(.headline)
|
|
.foregroundColor(themeManager.contrastingTextColor)
|
|
.frame(maxWidth: .infinity)
|
|
.padding()
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 12)
|
|
.fill(themeManager.accentColor)
|
|
)
|
|
}
|
|
.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() {
|
|
let logTag = Self.logTag // Capture before entering background queue
|
|
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 = "ascently_export_\(timestamp).zip"
|
|
|
|
guard
|
|
let documentsURL = FileManager.default.urls(
|
|
for: .documentDirectory, in: .userDomainMask
|
|
).first
|
|
else {
|
|
Task { @MainActor in
|
|
AppLogger.error("Could not access Documents directory", tag: logTag)
|
|
}
|
|
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 {
|
|
Task { @MainActor in
|
|
AppLogger.error("Failed to create export file: \(error)", tag: logTag)
|
|
}
|
|
DispatchQueue.main.async {
|
|
self.isCreatingFile = false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func cleanupTempFile() {
|
|
if let fileURL = tempFileURL {
|
|
let logTag = Self.logTag // Capture before entering async closure
|
|
// Clean up after a delay to ensure sharing is complete
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) {
|
|
try? FileManager.default.removeItem(at: fileURL)
|
|
AppLogger.debug(
|
|
"Cleaned up export file: \(fileURL.lastPathComponent)", tag: logTag)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct SyncSection: View {
|
|
@EnvironmentObject var syncService: SyncService
|
|
@EnvironmentObject var dataManager: ClimbingDataManager
|
|
@EnvironmentObject var themeManager: ThemeManager
|
|
@State private var showingSyncSettings = false
|
|
@State private var showingDisconnectAlert = false
|
|
|
|
private static let logTag = "SyncSection"
|
|
|
|
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(themeManager.accentColor)
|
|
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() {
|
|
let logTag = Self.logTag // Capture before entering async context
|
|
Task {
|
|
do {
|
|
try await syncService.syncWithServer(dataManager: dataManager)
|
|
} catch {
|
|
await MainActor.run {
|
|
AppLogger.error("Sync failed: \(error)", tag: logTag)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct SyncSettingsView: View {
|
|
@EnvironmentObject var syncService: SyncService
|
|
@EnvironmentObject var themeManager: ThemeManager
|
|
@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(themeManager.accentColor)
|
|
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
|
|
|
|
// Ensure provider type is set to server
|
|
if syncService.providerType != .server {
|
|
syncService.providerType = .server
|
|
}
|
|
|
|
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 {
|
|
// Ensure we are using the server provider
|
|
await MainActor.run {
|
|
if syncService.providerType != .server {
|
|
syncService.providerType = .server
|
|
}
|
|
}
|
|
|
|
// 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."
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
struct MusicSection: View {
|
|
@EnvironmentObject var musicService: MusicService
|
|
|
|
var body: some View {
|
|
Section {
|
|
Toggle(isOn: Binding(
|
|
get: { musicService.isMusicEnabled },
|
|
set: { musicService.toggleMusicEnabled($0) }
|
|
)) {
|
|
HStack {
|
|
Image(systemName: "music.note")
|
|
.foregroundColor(.pink)
|
|
Text("Apple Music Integration")
|
|
}
|
|
}
|
|
|
|
if musicService.isMusicEnabled {
|
|
if !musicService.isAuthorized {
|
|
Button("Connect Apple Music") {
|
|
Task {
|
|
await musicService.checkAuthorizationStatus()
|
|
}
|
|
}
|
|
} else {
|
|
Toggle("Auto-Play on Session Start", isOn: $musicService.isAutoPlayEnabled)
|
|
Toggle("Stop Music on Session End", isOn: $musicService.isAutoStopEnabled)
|
|
|
|
Picker("Playlist", selection: $musicService.selectedPlaylistId) {
|
|
Text("None").tag(nil as String?)
|
|
ForEach(musicService.playlists, id: \.id) { playlist in
|
|
Text(playlist.name).tag(playlist.id.rawValue as String?)
|
|
}
|
|
}
|
|
|
|
if musicService.isAutoPlayEnabled {
|
|
Text("Music will only auto-play if headphones are connected when you start a session.")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
}
|
|
} header: {
|
|
Text("Music")
|
|
}
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
SettingsView()
|
|
.environmentObject(ClimbingDataManager.preview)
|
|
}
|