diff --git a/iOS/Views/Companion/ConflictModels.swift b/iOS/Views/Companion/ConflictModels.swift deleted file mode 100644 index d13e792..0000000 --- a/iOS/Views/Companion/ConflictModels.swift +++ /dev/null @@ -1,118 +0,0 @@ -import Foundation -import SwiftUI - -// MARK: - AnyCodable -// Wraps heterogeneous values in the fix_data dictionary (String, Int, or [String]). -// Must be defined before CompanionAPIService extension references it. - -struct AnyCodable: Codable { - let value: Any - - init(_ value: Any) { self.value = value } - - init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - if let v = try? container.decode([String].self) { value = v; return } - if let v = try? container.decode([String: String].self) { value = v; return } - if let v = try? container.decode(String.self) { value = v; return } - if let v = try? container.decode(Int.self) { value = v; return } - value = "" - } - - func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - switch value { - case let v as [String]: try container.encode(v) - case let v as [String: String]: try container.encode(v) - case let v as String: try container.encode(v) - case let v as Int: try container.encode(v) - default: try container.encode("") - } - } -} - -// MARK: - Conflict Models - -struct LibraryConflict: Codable, Identifiable { - let type: String - let severity: String - let title: String - let detail: String - let affected_paths: [String] - let fix_action: String? - let fix_data: [String: AnyCodable]? - - var id: String { "\(type)|\(title)" } -} - -// SwiftUI display helpers — in an extension so the base struct has no SwiftUI dependency -extension LibraryConflict { - var severityColor: Color { - severity == "error" ? Color(red: 1, green: 0.176, blue: 0.333) : .yellow - } - - var severityIcon: String { - severity == "error" ? "xmark.circle.fill" : "exclamationmark.triangle.fill" - } - - var typeIcon: String { - switch type { - case "duplicate_album": return "rectangle.on.rectangle.fill" - case "missing_files": return "doc.badge.questionmark" - case "picard_legacy_tags": return "tag.slash.fill" - case "orphaned_tracks": return "link.badge.plus" - case "duplicate_track": return "music.note.list" - case "stale_companion_paths": return "externaldrive.badge.xmark" - default: return "exclamationmark.circle" - } - } -} - -struct ConflictsResponse: Codable { - let total: Int - let errors: Int - let warnings: Int - let issues: [LibraryConflict] -} - -// MARK: - Conflict Manager - -@MainActor -class ConflictManager: ObservableObject { - static let shared = ConflictManager() - - @Published var conflicts: ConflictsResponse? - @Published var isLoading = false - @Published var lastError: String? - @Published var isFixing: String? = nil - - private let api = CompanionAPIService() - - var badgeCount: Int { conflicts?.errors ?? 0 } - var totalCount: Int { conflicts?.total ?? 0 } - - func refresh() async { - guard CompanionSettings.shared.isEnabled else { return } - isLoading = true - lastError = nil - do { - conflicts = try await api.fetchConflicts() - } catch { - lastError = error.localizedDescription - } - isLoading = false - } - - func fix(_ conflict: LibraryConflict) async { - guard let action = conflict.fix_action else { return } - isFixing = conflict.id - do { - try await api.fixConflict(action: action, fixData: conflict.fix_data) - try? await Task.sleep(for: .seconds(2)) - await refresh() - } catch { - lastError = error.localizedDescription - } - isFixing = nil - } -} diff --git a/iOS/Views/Companion/LibraryConflictsView.swift b/iOS/Views/Companion/LibraryConflictsView.swift deleted file mode 100644 index aa84fd1..0000000 --- a/iOS/Views/Companion/LibraryConflictsView.swift +++ /dev/null @@ -1,219 +0,0 @@ -import SwiftUI - -// MARK: - LibraryConflict Display Helpers (SwiftUI) - -extension LibraryConflict { - var severityColor: Color { - severity == "error" ? Color(red: 1, green: 0.176, blue: 0.333) : .yellow - } - var severityIcon: String { - severity == "error" ? "xmark.circle.fill" : "exclamationmark.triangle.fill" - } - var typeIcon: String { - switch type { - case "duplicate_album": return "rectangle.on.rectangle.fill" - case "missing_files": return "doc.badge.questionmark" - case "picard_legacy_tags": return "tag.slash.fill" - case "orphaned_tracks": return "link.badge.plus" - case "duplicate_track": return "music.note.list" - case "stale_companion_paths": return "externaldrive.badge.xmark" - default: return "exclamationmark.circle" - } - } -} - -// MARK: - Issues & Conflicts View - -struct LibraryConflictsView: View { - @StateObject private var manager = ConflictManager.shared - private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333) - - var body: some View { - List { - if manager.isLoading { - Section { - HStack { - ProgressView().tint(accentPink) - Text("Scanning library for issues…") - .font(.system(size: 14)) - .foregroundColor(.gray) - .padding(.leading, 8) - } - .padding(.vertical, 4) - } - } else if let error = manager.lastError { - Section { - Text(error) - .font(.system(size: 13)) - .foregroundColor(.red) - } - } else if let conflicts = manager.conflicts { - if conflicts.total == 0 { - Section { - HStack(spacing: 12) { - Image(systemName: "checkmark.seal.fill") - .font(.system(size: 28)) - .foregroundColor(.green) - VStack(alignment: .leading, spacing: 2) { - Text("No issues found") - .font(.system(size: 15, weight: .medium)) - Text("Your library looks clean.") - .font(.system(size: 13)) - .foregroundColor(.gray) - } - } - .padding(.vertical, 6) - } - } else { - Section { - HStack(spacing: 16) { - summaryBadge(count: conflicts.errors, label: "Errors", color: accentPink) - summaryBadge(count: conflicts.warnings, label: "Warnings", color: .yellow) - Spacer() - } - .padding(.vertical, 4) - } header: { - Text("Summary") - } - - let sorted = conflicts.issues.sorted { - ($0.severity == "error" ? 0 : 1) < ($1.severity == "error" ? 0 : 1) - } - - ForEach(sorted) { conflict in - conflictRow(conflict) - } - } - } else { - Section { - Text("Tap Scan to check your library.") - .font(.system(size: 14)) - .foregroundColor(.gray) - } - } - } - .navigationTitle("Issues & Conflicts") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - Button(action: { - Task { await manager.refresh() } - }) { - if manager.isLoading { - ProgressView().tint(accentPink).scaleEffect(0.8) - } else { - Image(systemName: "arrow.clockwise") - .foregroundColor(accentPink) - } - } - .disabled(manager.isLoading) - } - } - .task { - if manager.conflicts == nil { - await manager.refresh() - } - } - } - - // MARK: - Conflict Row - - @ViewBuilder - private func conflictRow(_ conflict: LibraryConflict) -> some View { - Section { - VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 8) { - Image(systemName: conflict.typeIcon) - .font(.system(size: 16)) - .foregroundColor(conflict.severityColor) - Text(conflict.title) - .font(.system(size: 14, weight: .semibold)) - .foregroundColor(.white) - Spacer() - Image(systemName: conflict.severityIcon) - .font(.system(size: 12)) - .foregroundColor(conflict.severityColor) - } - - Text(conflict.detail) - .font(.system(size: 12)) - .foregroundColor(.gray) - .fixedSize(horizontal: false, vertical: true) - - if !conflict.affected_paths.isEmpty { - VStack(alignment: .leading, spacing: 2) { - ForEach(conflict.affected_paths.prefix(3), id: \.self) { path in - Text(path) - .font(.system(size: 10, design: .monospaced)) - .foregroundColor(.white.opacity(0.45)) - .lineLimit(1) - } - if conflict.affected_paths.count > 3 { - Text("+ \(conflict.affected_paths.count - 3) more") - .font(.system(size: 10)) - .foregroundColor(.gray.opacity(0.6)) - } - } - .padding(6) - .background(Color.white.opacity(0.05)) - .cornerRadius(6) - } - - if let action = conflict.fix_action { - HStack { - Spacer() - Button(action: { - Task { await manager.fix(conflict) } - }) { - if manager.isFixing == conflict.id { - HStack(spacing: 6) { - ProgressView().tint(.white).scaleEffect(0.75) - Text("Fixing…").font(.system(size: 13)) - } - .padding(.horizontal, 16) - .padding(.vertical, 7) - .background(Color.gray.opacity(0.3)) - .cornerRadius(8) - } else { - Text(fixButtonLabel(action)) - .font(.system(size: 13, weight: .medium)) - .padding(.horizontal, 16) - .padding(.vertical, 7) - .background(conflict.severityColor.opacity(0.25)) - .foregroundColor(conflict.severityColor) - .cornerRadius(8) - } - } - .buttonStyle(.plain) - .disabled(manager.isFixing != nil) - } - } - } - .padding(.vertical, 4) - } - } - - // MARK: - Helpers - - private func summaryBadge(count: Int, label: String, color: Color) -> some View { - VStack(spacing: 2) { - Text("\(count)") - .font(.system(size: 24, weight: .bold)) - .foregroundColor(count > 0 ? color : .gray) - Text(label) - .font(.system(size: 11)) - .foregroundColor(.gray) - } - } - - private func fixButtonLabel(_ action: String) -> String { - switch action { - case "fix_duplicate_album": return "Merge Duplicate" - case "fix_missing_files": return "Remove from DB" - case "fix_picard_tags": return "Fix Tags" - case "fix_stale_paths": return "Rescan Library" - case "fix_orphaned_tracks": return "Trigger Rescan" - default: return "Fix" - } - } -} diff --git a/iOS/Views/Library/DownloadsSettingsView.swift b/iOS/Views/Library/DownloadsSettingsView.swift index 3234a9f..5c936e9 100644 --- a/iOS/Views/Library/DownloadsSettingsView.swift +++ b/iOS/Views/Library/DownloadsSettingsView.swift @@ -1026,10 +1026,50 @@ extension LibraryConflict { struct LibraryConflictsView: View { @StateObject private var manager = ConflictManager.shared + @State private var selectedTab = 0 // 0 = Active, 1 = Ignored + @AppStorage("ignoredConflictIDs") private var ignoredIDsRaw = "" private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333) + private var ignoredIDs: Set { + Set(ignoredIDsRaw.split(separator: "|||").map(String.init)) + } + + private func ignore(_ conflict: LibraryConflict) { + var ids = ignoredIDs + ids.insert(conflict.id) + ignoredIDsRaw = ids.joined(separator: "|||") + } + + private func restore(_ conflict: LibraryConflict) { + var ids = ignoredIDs + ids.remove(conflict.id) + ignoredIDsRaw = ids.joined(separator: "|||") + } + + private var activeIssues: [LibraryConflict] { + guard let all = manager.conflicts?.issues else { return [] } + return all + .filter { !ignoredIDs.contains($0.id) } + .sorted { ($0.severity == "error" ? 0 : 1) < ($1.severity == "error" ? 0 : 1) } + } + + private var ignoredIssues: [LibraryConflict] { + guard let all = manager.conflicts?.issues else { return [] } + return all.filter { ignoredIDs.contains($0.id) } + } + var body: some View { List { + // Tab picker + Section { + Picker("", selection: $selectedTab) { + Text("Active (\(activeIssues.count))").tag(0) + Text("Ignored (\(ignoredIssues.count))").tag(1) + } + .pickerStyle(.segmented) + .padding(.vertical, 4) + } + if manager.isLoading { Section { HStack { @@ -1047,15 +1087,16 @@ struct LibraryConflictsView: View { .font(.system(size: 13)) .foregroundColor(.red) } - } else if let conflicts = manager.conflicts { - if conflicts.total == 0 { + } else if selectedTab == 0 { + // ── Active tab ────────────────────────────────────────────── + if activeIssues.isEmpty { Section { HStack(spacing: 12) { Image(systemName: "checkmark.seal.fill") .font(.system(size: 28)) .foregroundColor(.green) VStack(alignment: .leading, spacing: 2) { - Text("No issues found") + Text("No active issues") .font(.system(size: 15, weight: .medium)) Text("Your library looks clean.") .font(.system(size: 13)) @@ -1065,26 +1106,54 @@ struct LibraryConflictsView: View { .padding(.vertical, 6) } } else { + let errors = activeIssues.filter { $0.severity == "error" }.count + let warnings = activeIssues.filter { $0.severity == "warning" }.count Section { HStack(spacing: 16) { - summaryBadge(count: conflicts.errors, label: "Errors", color: accentPink) - summaryBadge(count: conflicts.warnings, label: "Warnings", color: .yellow) + summaryBadge(count: errors, label: "Errors", color: accentPink) + summaryBadge(count: warnings, label: "Warnings", color: .yellow) Spacer() } .padding(.vertical, 4) } header: { Text("Summary") } - ForEach(conflicts.issues.sorted { - ($0.severity == "error" ? 0 : 1) < ($1.severity == "error" ? 0 : 1) - }) { conflict in + ForEach(activeIssues) { conflict in conflictRow(conflict) + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button { + withAnimation { ignore(conflict) } + } label: { + Label("Ignore", systemImage: "eye.slash") + } + .tint(.gray) + } } } } else { - Section { - Text("Tap Scan to check your library.") - .font(.system(size: 14)) - .foregroundColor(.gray) + // ── Ignored tab ───────────────────────────────────────────── + if ignoredIssues.isEmpty { + Section { + Text("No ignored issues.") + .font(.system(size: 14)) + .foregroundColor(.gray) + } + } else { + Section { + Text("Swipe left to restore an issue to the Active list.") + .font(.system(size: 12)) + .foregroundColor(.gray) + } + ForEach(ignoredIssues) { conflict in + conflictRow(conflict, dimmed: true) + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button { + withAnimation { restore(conflict) } + } label: { + Label("Restore", systemImage: "arrow.uturn.left") + } + .tint(accentPink) + } + } } } } @@ -1107,21 +1176,23 @@ struct LibraryConflictsView: View { } } + // MARK: - Conflict Row + @ViewBuilder - private func conflictRow(_ conflict: LibraryConflict) -> some View { + private func conflictRow(_ conflict: LibraryConflict, dimmed: Bool = false) -> some View { Section { VStack(alignment: .leading, spacing: 8) { HStack(spacing: 8) { Image(systemName: conflict.typeIcon) .font(.system(size: 16)) - .foregroundColor(conflict.severityColor) + .foregroundColor(dimmed ? .gray : conflict.severityColor) Text(conflict.title) .font(.system(size: 14, weight: .semibold)) - .foregroundColor(.white) + .foregroundColor(dimmed ? .gray : .white) Spacer() Image(systemName: conflict.severityIcon) .font(.system(size: 12)) - .foregroundColor(conflict.severityColor) + .foregroundColor(dimmed ? .gray : conflict.severityColor) } Text(conflict.detail) .font(.system(size: 12)) @@ -1145,7 +1216,7 @@ struct LibraryConflictsView: View { .background(Color.white.opacity(0.05)) .cornerRadius(6) } - if let action = conflict.fix_action { + if !dimmed, let action = conflict.fix_action { HStack { Spacer() Button(action: { Task { await manager.fix(conflict) } }) { @@ -1175,6 +1246,8 @@ struct LibraryConflictsView: View { } } + // MARK: - Helpers + private func summaryBadge(count: Int, label: String, color: Color) -> some View { VStack(spacing: 2) { Text("\(count)") @@ -1197,3 +1270,4 @@ struct LibraryConflictsView: View { } } } +