import SwiftData import SwiftUI /// Settings tab — skin selection, sync, about. struct SettingsView: View { @EnvironmentObject private var theme: AppTheme @EnvironmentObject private var syncManager: SyncManager @EnvironmentObject private var libraryManager: LibraryManager @Environment(PlaylistViewModel.self) private var playlistVM @Environment(\.modelContext) private var modelContext @Query(sort: \Playlist.dateModified, order: .reverse) private var playlists: [Playlist] @Query private var tracks: [Track] @State private var showSyncImporter = false @State private var showSyncExportConfirm = false @State private var syncResult: String? @State private var showResetConfirm = false // Playback settings @AppStorage("trackTapAction") private var trackTapAction = "playNow" // Chad Music settings @State private var chadServerURL: String = UserDefaults.standard.string(forKey: "chadMusic.serverURL") ?? "" @State private var chadAPIKey: String = KeychainService.loadAPIKey() ?? "" @State private var chadTestResult: ChadTestState = .idle private enum ChadTestState { case idle case testing case success(ChadStats) case failed(String) } private static let mixColors: [Color] = [ Color(red: 0.95, green: 0.3, blue: 0.3), Color(red: 0.3, green: 0.75, blue: 0.95), Color(red: 0.95, green: 0.75, blue: 0.2), ] var body: some View { NavigationStack { List { // MARK: - Mix Targets Section { ForEach(0..<3, id: \.self) { slot in Menu { ForEach(playlists) { playlist in Button(playlist.name) { playlistVM.setMixTarget(slot, playlist: playlist) } } if playlistVM.mixTargets[slot] != nil { Divider() Button("Clear", role: .destructive) { playlistVM.setMixTarget(slot, playlist: nil) } } } label: { HStack(spacing: 12) { Text("\(slot + 1)") .font(.system(size: 14, weight: .bold, design: .rounded)) .frame(width: 28, height: 28) .foregroundStyle(Self.mixColors[slot]) .background(Self.mixColors[slot].opacity(0.15)) .clipShape(RoundedRectangle(cornerRadius: 6)) if let target = playlistVM.mixTargets[slot] { Text(target.name) .foregroundStyle(theme.primaryText) } else { Text("Not set") .foregroundStyle(theme.tertiaryText) } Spacer() Image(systemName: "chevron.up.chevron.down") .font(.caption) .foregroundStyle(theme.tertiaryText) } .contentShape(Rectangle()) } } } header: { Text("Mix Targets") } footer: { Text("Assign playlists to the 3 mix buttons shown on each track. Tap a number on a track to quick-add it.") } // MARK: - App Icon Color Section("App Icon") { let iconOptions: [(name: String, color: Color, iconName: String?)] = [ ("Default", .green, nil), ("Green", Color(red: 0.35, green: 0.85, blue: 0.25), "AppIcon-Green"), ("Lime", Color(red: 0.55, green: 0.95, blue: 0.15), "AppIcon-Lime"), ("Cyan", Color(red: 0.15, green: 0.85, blue: 0.85), "AppIcon-Cyan"), ("Blue", Color(red: 0.25, green: 0.45, blue: 0.95), "AppIcon-Blue"), ("Purple", Color(red: 0.6, green: 0.3, blue: 0.9), "AppIcon-Purple"), ("Pink", Color(red: 0.95, green: 0.3, blue: 0.6), "AppIcon-Pink"), ("Red", Color(red: 0.95, green: 0.25, blue: 0.25), "AppIcon-Red"), ("Orange", Color(red: 0.95, green: 0.55, blue: 0.15), "AppIcon-Orange"), ("Gold", Color(red: 0.95, green: 0.8, blue: 0.15), "AppIcon-Gold"), ("White", Color(red: 0.9, green: 0.9, blue: 0.92), "AppIcon-White"), ] ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 12) { ForEach(iconOptions, id: \.name) { option in Button { UIApplication.shared.setAlternateIconName(option.iconName) { error in if let error { print("Icon change failed: \(error)") } } } label: { VStack(spacing: 6) { RoundedRectangle(cornerRadius: 12) .fill(option.color) .frame(width: 50, height: 50) .overlay( RoundedRectangle(cornerRadius: 12) .stroke(Color.white.opacity(0.2), lineWidth: 1) ) Text(option.name) .font(.caption2) .foregroundStyle(theme.secondaryText) } } .buttonStyle(.plain) } } .padding(.vertical, 8) } .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) } // MARK: - Playback Section("Playback") { Picker("Track Tap Action", selection: $trackTapAction) { Text("Play Now").tag("playNow") Text("Add to Queue").tag("addToQueue") } } // MARK: - Skin Selection Section("Skin") { ForEach(AppTheme.Skin.allCases) { skin in Button { withAnimation(.easeInOut(duration: 0.3)) { theme.currentSkin = skin } } label: { HStack(spacing: 12) { Image(systemName: skin.icon) .font(.title3) .foregroundStyle(theme.currentSkin == skin ? theme.accent : theme.secondaryText) .frame(width: 32) VStack(alignment: .leading, spacing: 2) { Text(skin.rawValue) .font(.headline) .foregroundStyle(theme.primaryText) Text(skin.description) .font(.caption) .foregroundStyle(theme.secondaryText) } Spacer() if theme.currentSkin == skin { Image(systemName: "checkmark.circle.fill") .foregroundStyle(theme.accent) } } } } } // MARK: - Chad Music Section { HStack { Text("Server URL") .foregroundStyle(theme.secondaryText) TextField("https://music.example.com", text: $chadServerURL) .textContentType(.URL) .keyboardType(.URL) .autocapitalization(.none) .disableAutocorrection(true) .multilineTextAlignment(.trailing) .onChange(of: chadServerURL) { _, newValue in ChadMusicAPIClient.shared.serverURL = newValue } } HStack { Text("API Key") .foregroundStyle(theme.secondaryText) SecureField("Enter API key", text: $chadAPIKey) .multilineTextAlignment(.trailing) .onChange(of: chadAPIKey) { _, newValue in if newValue.isEmpty { KeychainService.deleteAPIKey() } else { try? KeychainService.saveAPIKey(newValue) } } } Button { testChadConnection() } label: { HStack { Label("Test Connection", systemImage: "antenna.radiowaves.left.and.right") Spacer() switch chadTestResult { case .idle: EmptyView() case .testing: ProgressView() case .success: Image(systemName: "checkmark.circle.fill") .foregroundStyle(.green) case .failed: Image(systemName: "xmark.circle.fill") .foregroundStyle(.red) } } } .disabled(chadServerURL.isEmpty || chadAPIKey.isEmpty) switch chadTestResult { case .success(let stats): HStack(spacing: 16) { if let tracks = stats.tracks { VStack { Text("\(tracks)") .font(.headline) Text("Tracks") .font(.caption2) .foregroundStyle(theme.secondaryText) } } if let albums = stats.albums { VStack { Text("\(albums)") .font(.headline) Text("Albums") .font(.caption2) .foregroundStyle(theme.secondaryText) } } if let artists = stats.artists { VStack { Text("\(artists)") .font(.headline) Text("Artists") .font(.caption2) .foregroundStyle(theme.secondaryText) } } } .frame(maxWidth: .infinity) case .failed(let msg): Text(msg) .font(.caption) .foregroundStyle(.red) default: EmptyView() } } header: { Text("Chad Music") } footer: { Text("Connect to your Chad Music server to stream cloud music. The server URL and API key are stored securely in the Keychain.") } // MARK: - Sync Section { Button { syncManager.exportPlaylists(playlists) showSyncExportConfirm = true } label: { Label("Export Playlists for Mac", systemImage: "square.and.arrow.up") } .disabled(playlists.isEmpty) Button { showSyncImporter = true } label: { Label("Import Playlists from Mac", systemImage: "square.and.arrow.down") } if let date = syncManager.lastSyncDate { HStack { Text("Last export") .foregroundStyle(theme.secondaryText) Spacer() Text(date.formatted(date: .abbreviated, time: .shortened)) .font(.caption) .foregroundStyle(theme.tertiaryText) } } if let result = syncResult { Text(result) .font(.caption) .foregroundStyle(theme.accent) } } header: { Text("Sync") } footer: { Text("Export creates a JSON file in Documents/Sync/ that you can share with the Mac app via AirDrop, iCloud Drive, or USB.") } // MARK: - Library Stats Section("Library") { HStack { Text("Tracks") Spacer() Text("\(tracks.count)") .foregroundStyle(theme.secondaryText) } HStack { Text("Playlists") Spacer() Text("\(playlists.count)") .foregroundStyle(theme.secondaryText) } HStack { Text("Analyzed") Spacer() Text("\(tracks.filter(\.isAnalyzed).count) / \(tracks.count)") .foregroundStyle(theme.secondaryText) } Button { Task { await libraryManager.rescanMetadata() } } label: { Label("Rescan Metadata", systemImage: "arrow.clockwise") } .disabled(libraryManager.isScanning) Button(role: .destructive) { showResetConfirm = true } label: { Label("Reset Library & Rescan", systemImage: "arrow.counterclockwise") .foregroundStyle(.red) } } // MARK: - About Section("About") { HStack { Text("MixBoard iOS") Spacer() Text("1.0.0") .foregroundStyle(theme.tertiaryText) } HStack { Text("Supported Formats") Spacer() Text("MP3, FLAC, WAV, AIFF, M4A, AAC, OGG, Opus") .font(.caption) .foregroundStyle(theme.tertiaryText) } } } .navigationTitle("Settings") .accessibilityIdentifier("settingsView") .fileImporter( isPresented: $showSyncImporter, allowedContentTypes: [.json], allowsMultipleSelection: false ) { result in switch result { case .success(let urls): guard let url = urls.first else { return } let accessing = url.startAccessingSecurityScopedResource() defer { if accessing { url.stopAccessingSecurityScopedResource() } } do { let imported = try syncManager.importPlaylists(from: url, context: modelContext) let result = syncManager.mergeImportedPlaylists(imported, existingTracks: tracks, context: modelContext) syncResult = "Imported \(result.created) playlists, \(result.matched) tracks matched, \(result.unmatched) not found" } catch { syncResult = "Import failed: \(error.localizedDescription)" } case .failure(let error): syncResult = "Error: \(error.localizedDescription)" } } .alert("Exported", isPresented: $showSyncExportConfirm) { Button("OK") {} } message: { Text("Playlists exported to Documents/Sync/mixboard-playlists.json\n\nShare this file with your Mac via AirDrop, iCloud Drive, or Files app.") } .alert("Reset Library?", isPresented: $showResetConfirm) { Button("Cancel", role: .cancel) {} Button("Reset & Rescan", role: .destructive) { // Delete all tracks from database for track in tracks { modelContext.delete(track) } try? modelContext.save() // Rescan Task { await libraryManager.scanMusicDirectory() } } } message: { Text("This will remove all tracks from the database and re-scan your music folder. Your music files are NOT deleted.") } } } private func testChadConnection() { chadTestResult = .testing Task { let result = await ChadMusicAPIClient.shared.testConnection() switch result { case .success(let stats): chadTestResult = .success(stats) case .failure(let error): chadTestResult = .failed(error.localizedDescription) } } } }