// // SubscriptionsSettingsView.swift // Yattee // // Settings for importing and exporting subscriptions. // import SwiftUI import UniformTypeIdentifiers #if !os(tvOS) // MARK: - Export File Wrapper private struct ExportFile: Identifiable { let id = UUID() let url: URL } struct SubscriptionsSettingsView: View { @Environment(\.appEnvironment) private var appEnvironment // Account selection state @State private var pendingAccountChange: SubscriptionAccount? @State private var showingAccountSwitchConfirmation = false // Import state @State private var showingImportPicker = false @State private var isImporting = false @State private var importResult: (imported: Int, skipped: Int, format: String)? @State private var showingImportResult = false @State private var importError: String? @State private var showingImportError = false // Export state @State private var selectedExportFormat: SubscriptionExportFormat = .json @State private var exportFile: ExportFile? @State private var isExporting = false // Account subscription count (provider-scoped, fetched for server accounts) @State private var accountSubscriptionCount: Int? // Local data deletion state @State private var showingDeleteLocalConfirmation = false private var dataManager: DataManager? { appEnvironment?.dataManager } private var settingsManager: SettingsManager? { appEnvironment?.settingsManager } private var validator: SubscriptionAccountValidator? { appEnvironment?.subscriptionAccountValidator } private var subscriptionService: SubscriptionService? { appEnvironment?.subscriptionService } /// Subscription count of the active account (local store or server). private var subscriptionCount: Int { accountSubscriptionCount ?? subscriptionService?.cachedSubscriptionCount ?? 0 } /// Count of subscriptions in the local store, shown in the local-data section. private var localSubscriptionCount: Int { dataManager?.subscriptionCount ?? 0 } private var currentAccount: SubscriptionAccount { settingsManager?.subscriptionAccount ?? .local } var body: some View { List { accountSection if validator?.hasAvailableAccounts == true { importSection exportSection localDataSection } } .task(id: currentAccount) { await refreshAccountSubscriptionCount() } .navigationTitle(String(localized: "settings.subscriptions.title")) #if os(iOS) .navigationBarTitleDisplayMode(.inline) .sheet(isPresented: $showingImportPicker) { SubscriptionFilePickerView { url in handleImportedFile(url) } } .sheet(item: $exportFile) { file in ShareSheet(items: [file.url]) } #endif .alert( String(localized: "settings.subscriptions.import.success.title"), isPresented: $showingImportResult ) { Button(String(localized: "common.ok")) {} } message: { if let result = importResult { Text(String(localized: "settings.subscriptions.import.success.message \(result.imported) \(result.skipped) \(result.format)")) } } .alert( String(localized: "settings.subscriptions.import.error.title"), isPresented: $showingImportError ) { Button(String(localized: "common.ok")) {} } message: { if let error = importError { Text(error) } } .confirmationDialog( String(localized: "settings.subscriptions.account.switch.title"), isPresented: $showingAccountSwitchConfirmation, titleVisibility: .visible ) { Button(String(localized: "settings.subscriptions.account.switch.action")) { confirmAccountSwitch() } Button(String(localized: "common.cancel"), role: .cancel) { cancelAccountSwitch() } } message: { Text(String(localized: "settings.subscriptions.account.switch.message")) } .confirmationDialog( String(localized: "settings.subscriptions.localData.delete.confirmation.title \(localSubscriptionCount)"), isPresented: $showingDeleteLocalConfirmation, titleVisibility: .visible ) { Button(String(localized: "settings.subscriptions.localData.delete.confirmation.action"), role: .destructive) { deleteLocalSubscriptions() } } message: { Text(String(localized: "settings.subscriptions.localData.delete.confirmation.message")) } .presentationCompactAdaptation(.sheet) } // MARK: - Account Section @ViewBuilder private var accountSection: some View { Section { if let validator, validator.hasAvailableAccounts { Picker( String(localized: "settings.subscriptions.account.label"), selection: Binding( get: { currentAccount }, set: { newAccount in if newAccount != currentAccount { pendingAccountChange = newAccount showingAccountSwitchConfirmation = true } } ) ) { ForEach(validator.availableAccounts, id: \.self) { account in Text(validator.displayName(for: account)) .tag(account) } } } else { // No accounts available - show setup prompt VStack(alignment: .leading, spacing: 8) { Label( String(localized: "settings.subscriptions.account.noAccounts.title"), systemImage: "exclamationmark.triangle" ) .foregroundStyle(.secondary) Text(String(localized: "settings.subscriptions.account.noAccounts.message")) .font(.caption) .foregroundStyle(.secondary) Button(String(localized: "settings.subscriptions.account.noAccounts.action")) { appEnvironment?.navigationCoordinator.navigate(to: .settings) } .font(.caption) .padding(.top, 4) } .padding(.vertical, 4) } } footer: { if validator?.hasAvailableAccounts == true { Text(String(localized: "settings.subscriptions.account.footer")) } } } // MARK: - Account Switch Actions private func confirmAccountSwitch() { guard let newAccount = pendingAccountChange else { return } settingsManager?.subscriptionAccount = newAccount SubscriptionFeedCache.shared.handleAccountChange() // Trigger feed refresh in background Task { guard let appEnvironment else { return } await SubscriptionFeedCache.shared.refresh(using: appEnvironment) } pendingAccountChange = nil LoggingService.shared.logSubscriptions("Switched subscription account to: \(String(describing: newAccount.type))") } private func cancelAccountSwitch() { pendingAccountChange = nil } // MARK: - Import Section @ViewBuilder private var importSection: some View { Section { Button { showImportPicker() } label: { HStack { Label(String(localized: "settings.subscriptions.import.button"), systemImage: "square.and.arrow.down") Spacer() if isImporting { ProgressView() } } } .disabled(isImporting) } header: { Text(String(localized: "settings.subscriptions.import.title")) } footer: { Text(String(localized: "settings.subscriptions.import.footer")) } } // MARK: - Export Section @ViewBuilder private var exportSection: some View { Section { Picker(String(localized: "settings.subscriptions.export.format"), selection: $selectedExportFormat) { ForEach(SubscriptionExportFormat.allCases) { format in Text(format.rawValue).tag(format) } } Button { exportSubscriptions() } label: { HStack { Label(String(localized: "settings.subscriptions.export.button"), systemImage: "square.and.arrow.up") Spacer() if isExporting { ProgressView() } } } .disabled(subscriptionCount == 0 || isExporting) } header: { Text(String(localized: "settings.subscriptions.export.title")) } footer: { Text(String(localized: "settings.subscriptions.export.footer \(subscriptionCount)")) } } // MARK: - Local Data Section /// Lets the user delete locally stored subscriptions while a server account /// is active. Hidden in local mode, where the account IS the local store. @ViewBuilder private var localDataSection: some View { if currentAccount.type != .local && localSubscriptionCount > 0 { Section { Button(role: .destructive) { showingDeleteLocalConfirmation = true } label: { Label(String(localized: "settings.subscriptions.localData.delete.button"), systemImage: "trash") } } header: { Text(String(localized: "settings.subscriptions.localData.title")) } footer: { Text(String(localized: "settings.subscriptions.localData.footer \(localSubscriptionCount)")) } } } // MARK: - Actions private func showImportPicker() { #if os(iOS) showingImportPicker = true #elseif os(macOS) showMacOSImportPanel() #endif } #if os(macOS) private func showMacOSImportPanel() { let panel = NSOpenPanel() panel.allowedContentTypes = [ UTType.commaSeparatedText, UTType(filenameExtension: "opml") ?? .xml, UTType.xml ] panel.canChooseFiles = true panel.canChooseDirectories = false panel.allowsMultipleSelection = false panel.message = String(localized: "settings.subscriptions.import.panel.message") if panel.runModal() == .OK, let url = panel.url { handleImportedFile(url) } } #endif private func handleImportedFile(_ url: URL) { isImporting = true Task { do { // Read file data let data: Data if url.startAccessingSecurityScopedResource() { defer { url.stopAccessingSecurityScopedResource() } data = try Data(contentsOf: url) } else { data = try Data(contentsOf: url) } // Parse subscriptions let parseResult = try SubscriptionImportExport.parseAuto(data) // Import into the active account (local store or server) guard let subscriptionService else { throw SubscriptionImportError.invalidData } let importStats = await subscriptionService.importSubscriptions(parseResult.channels) await MainActor.run { isImporting = false importResult = (imported: importStats.imported, skipped: importStats.skipped, format: parseResult.format) showingImportResult = true LoggingService.shared.logSubscriptions("Import completed: \(importStats.imported) imported, \(importStats.skipped) skipped") } await refreshAccountSubscriptionCount() } catch { await MainActor.run { isImporting = false importError = error.localizedDescription showingImportError = true LoggingService.shared.logSubscriptionsError("Import failed", error: error) } } } } private func exportSubscriptions() { isExporting = true Task { await performExport() isExporting = false } } /// Exports subscriptions of the active account: the local store in local /// mode, or the server's list for Invidious/Piped accounts. private func performExport() async { let subscriptions: [Subscription] if currentAccount.type == .local { guard let dataManager else { return } subscriptions = dataManager.allSubscriptions } else { guard let subscriptionService else { return } do { let channels = try await subscriptionService.fetchSubscriptions() accountSubscriptionCount = channels.count // Detached model instances used purely as export carriers subscriptions = channels.map { Subscription.from(channel: $0) } } catch { LoggingService.shared.logSubscriptionsError("Failed to fetch account subscriptions for export", error: error) appEnvironment?.toastManager.showError( String(localized: "settings.subscriptions.export.error.title"), subtitle: error.localizedDescription ) return } } let data: Data? switch selectedExportFormat { case .json: data = SubscriptionImportExport.exportToJSON(subscriptions) case .opml: data = SubscriptionImportExport.exportToOPML(subscriptions) } guard let exportData = data else { LoggingService.shared.logSubscriptionsError("Failed to generate export data") return } let filename = SubscriptionImportExport.generateExportFilename(format: selectedExportFormat) #if os(iOS) // Write to temp file and share let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(filename) do { try exportData.write(to: tempURL) exportFile = ExportFile(url: tempURL) } catch { LoggingService.shared.logSubscriptionsError("Failed to write export file", error: error) } #elseif os(macOS) showMacOSSavePanel(data: exportData, filename: filename) #endif } /// Refreshes the account-scoped subscription count shown in the export footer. private func refreshAccountSubscriptionCount() async { guard let subscriptionService else { return } if let cached = subscriptionService.cachedSubscriptionCount { accountSubscriptionCount = cached } if subscriptionService.currentAccountType != .local { if let channels = try? await subscriptionService.fetchSubscriptions() { accountSubscriptionCount = channels.count } } } private func deleteLocalSubscriptions() { dataManager?.deleteAllSubscriptions() appEnvironment?.toastManager.showSuccess(String(localized: "settings.subscriptions.localData.deleted.title")) Task { await refreshAccountSubscriptionCount() } } #if os(macOS) private func showMacOSSavePanel(data: Data, filename: String) { let panel = NSSavePanel() panel.nameFieldStringValue = filename panel.allowedContentTypes = selectedExportFormat == .json ? [.json] : [UTType(filenameExtension: "opml") ?? .xml] if panel.runModal() == .OK, let url = panel.url { do { try data.write(to: url) LoggingService.shared.logSubscriptions("Exported subscriptions to \(url.path)") } catch { LoggingService.shared.logSubscriptionsError("Failed to save export", error: error) } } } #endif } // MARK: - File Picker (iOS) #if os(iOS) struct SubscriptionFilePickerView: UIViewControllerRepresentable { let onSelect: (URL) -> Void func makeUIViewController(context: Context) -> UIDocumentPickerViewController { let supportedTypes: [UTType] = [ .commaSeparatedText, UTType(filenameExtension: "opml") ?? .xml, .xml, .text ] let picker = UIDocumentPickerViewController(forOpeningContentTypes: supportedTypes) picker.delegate = context.coordinator picker.allowsMultipleSelection = false return picker } func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) {} func makeCoordinator() -> Coordinator { Coordinator(onSelect: onSelect) } class Coordinator: NSObject, UIDocumentPickerDelegate { let onSelect: (URL) -> Void init(onSelect: @escaping (URL) -> Void) { self.onSelect = onSelect } func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { guard let url = urls.first else { return } onSelect(url) } } } #endif // MARK: - Preview #Preview { NavigationStack { SubscriptionsSettingsView() } .appEnvironment(.preview) } #endif