diff --git a/iOS/Views/Companion/ConflictModels.swift b/iOS/Views/Companion/ConflictModels.swift new file mode 100644 index 0000000..6887a9f --- /dev/null +++ b/iOS/Views/Companion/ConflictModels.swift @@ -0,0 +1,114 @@ +import SwiftUI + +// MARK: - Conflict Models +// Kept in a separate file so both CompanionAPIService and LibraryConflictsView can reference these types. + +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] +} + +// MARK: - AnyCodable +// Wraps heterogeneous values in the fix_data dictionary (String, Int, or [String]). + +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 + + 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 index 0ffb62c..d1a71ca 100644 --- a/iOS/Views/Companion/LibraryConflictsView.swift +++ b/iOS/Views/Companion/LibraryConflictsView.swift @@ -1,116 +1,5 @@ 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 { @@ -165,7 +54,6 @@ struct LibraryConflictsView: View { Text("Summary") } - // Group by severity — errors first let sorted = conflicts.issues.sorted { ($0.severity == "error" ? 0 : 1) < ($1.severity == "error" ? 0 : 1) } @@ -212,7 +100,6 @@ struct LibraryConflictsView: View { 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)) @@ -231,7 +118,6 @@ struct LibraryConflictsView: View { .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 @@ -251,7 +137,6 @@ struct LibraryConflictsView: View { .cornerRadius(6) } - // Fix button if let action = conflict.fix_action { HStack { Spacer()