import SwiftUI // 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)" } 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] } // AnyCodable wrapper for heterogeneous fix_data dict 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 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 // currently fixing conflict id 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) // Refresh after fix try? await Task.sleep(for: .seconds(2)) await refresh() } catch { lastError = error.localizedDescription } isFixing = nil } } // 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") } // Group by severity — errors first 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) { // Header 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) // Affected paths (collapsed, show first 3) 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) } // Fix button 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" } } }