import Foundation import SwiftUI import UIKit @MainActor final class FavoritesStore: ObservableObject { @Published private(set) var items: [MediaItem] = [] private var profileId: String? private let defaults = UserDefaults.standard private func cacheKey(for profileId: String) -> String { "vmc.favorites.cache.\(profileId)" } private func migratedKey(for profileId: String) -> String { "vmc.favorites.migrated.\(profileId)" } /// Oude lokale key (vóór server-sync) — eenmalig uploaden. private func legacyKey(for profileId: String) -> String { "vmc.favorites.\(profileId)" } func bind(profileId: String?) { self.profileId = profileId guard let profileId, !profileId.isEmpty else { items = [] return } items = loadCache(profileId: profileId) } func contains(type: String, id: String) -> Bool { let want = normalizedType(type) return items.contains { $0.id == id && normalizedType($0.type) == want } } private func normalizedType(_ type: String) -> String { let t = type.lowercased() return (t == "series" || t == "show") ? "series" : "movie" } func replaceAll(_ next: [MediaItem]) { items = next persistCache() } /// Optimistic + server sync. Retourneert of het item nu favoriet is. @discardableResult func toggle( type: String, id: String, title: String, year: Int?, posterUrl: String?, backdropUrl: String?, logoUrl: String?, api: ApiClient ) async -> Bool { let normalized = normalizedType(type) let wasOn = contains(type: normalized, id: id) if wasOn { items.removeAll { $0.id == id && normalizedType($0.type) == normalized } } else { let item = MediaItem( type: normalized.uppercased() == "SERIES" ? "SERIES" : "MOVIE", id: id, title: title, subtitle: nil, year: year, posterUrl: posterUrl, backdropUrl: backdropUrl, logoUrl: logoUrl, mediaFileId: nil, seriesId: nil, positionMs: nil, durationMs: nil, progressId: nil, badge: nil ) items.insert(item, at: 0) } persistCache() do { let result = try await api.toggleFavorite(type: normalized, id: id) if let remote = result.items { replaceAll(remote) } else { try await refresh(using: api) } return result.favorited } catch { // Rollback optimistic update if wasOn { let item = MediaItem( type: normalized.uppercased() == "SERIES" ? "SERIES" : "MOVIE", id: id, title: title, subtitle: nil, year: year, posterUrl: posterUrl, backdropUrl: backdropUrl, logoUrl: logoUrl, mediaFileId: nil, seriesId: nil, positionMs: nil, durationMs: nil, progressId: nil, badge: nil ) items.insert(item, at: 0) } else { items.removeAll { $0.id == id && normalizedType($0.type) == normalized } } persistCache() return wasOn } } func refresh(using api: ApiClient) async { guard let profileId, !profileId.isEmpty else { items = [] return } do { await migrateLegacyIfNeeded(profileId: profileId, api: api) let remote = try await api.listFavorites() replaceAll(remote) } catch { // Houd cache aan bij netwerkfout if items.isEmpty { items = loadCache(profileId: profileId) } } } var mediaItems: [MediaItem] { items } private func migrateLegacyIfNeeded(profileId: String, api: ApiClient) async { let flag = migratedKey(for: profileId) guard !defaults.bool(forKey: flag) else { return } defer { defaults.set(true, forKey: flag) } guard let data = defaults.data(forKey: legacyKey(for: profileId)), let legacy = try? JSONDecoder().decode([LegacyFavorite].self, from: data), !legacy.isEmpty else { return } for item in legacy { let t = item.type.lowercased() == "series" ? "series" : "movie" _ = try? await api.addFavorite(type: t, id: item.mediaId) } defaults.removeObject(forKey: legacyKey(for: profileId)) } private func loadCache(profileId: String) -> [MediaItem] { guard let data = defaults.data(forKey: cacheKey(for: profileId)), let decoded = try? JSONDecoder().decode([MediaItem].self, from: data) else { return [] } return decoded } private func persistCache() { guard let profileId, !profileId.isEmpty else { return } if let data = try? JSONEncoder().encode(items) { defaults.set(data, forKey: cacheKey(for: profileId)) } } } private struct LegacyFavorite: Codable { let type: String let mediaId: String } struct BookmarkToggleButton: View { let isOn: Bool let action: () -> Void var body: some View { Button { UIImpactFeedbackGenerator(style: .light).impactOccurred() withAnimation(.spring(response: 0.32, dampingFraction: 0.7)) { action() } } label: { Image(systemName: isOn ? "bookmark.fill" : "bookmark") .font(.system(size: 18, weight: .semibold)) .foregroundStyle(isOn ? McColors.accent : McColors.text) .frame(width: 52, height: 52) .background(McColors.surface, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) .overlay { RoundedRectangle(cornerRadius: 14, style: .continuous) .strokeBorder(isOn ? McColors.accent.opacity(0.45) : Color.white.opacity(0.08), lineWidth: 1) } } .buttonStyle(.plain) .accessibilityLabel(isOn ? "Verwijder uit favorieten" : "Bewaar in favorieten") } }