Files
Ascently/ios/Ascently/Views/SettingsView.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)
}