diff --git a/iOS/Views/Companion/CompanionAPIService.swift b/iOS/Views/Companion/CompanionAPIService.swift index b5315cd..973b497 100644 --- a/iOS/Views/Companion/CompanionAPIService.swift +++ b/iOS/Views/Companion/CompanionAPIService.swift @@ -594,6 +594,54 @@ extension Notification.Name { static let companionLibraryChanged = Notification.Name("companionLibraryChanged") } +// MARK: - Conflict Models +// Defined here so CompanionAPIService can reference them without a separate file. + +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("") + } + } +} + +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)" } +} + +struct ConflictsResponse: Codable { + let total: Int + let errors: Int + let warnings: Int + let issues: [LibraryConflict] +} + // MARK: - Library Fetch (Phase 1) extension CompanionAPIService { diff --git a/iOS/Views/Companion/LibraryConflictsView.swift b/iOS/Views/Companion/LibraryConflictsView.swift index d1a71ca..d7de424 100644 --- a/iOS/Views/Companion/LibraryConflictsView.swift +++ b/iOS/Views/Companion/LibraryConflictsView.swift @@ -1,5 +1,69 @@ 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: - 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 + } +} + // MARK: - Issues & Conflicts View struct LibraryConflictsView: View {