Files
yattee/Yattee/Views/Settings/SubscriptionsSettingsView.swift
Arkadiusz Fal 175b98097a Put the account switcher where the data is, and retire the old setting
Switching accounts was buried in Subscriptions settings behind a picker
and a confirmation dialog, and it wrote two stores that could disagree.
One shared switcher now sits in the Subscriptions and Playlists
toolbars, the macOS sidebar footer and the Settings root, and every path
goes through AppEnvironment.switchDataContext(to:) so a switch behaves
the same wherever it is made. Switching is one tap: it is reversible, so
there is nothing to confirm.

Rows come from DataContextValidator.availableContexts, name themselves
through displayName(for:), and carry a status suffix when the account is
not signed in — plus the instance host when two accounts share a name.
tvOS gets the same rows as a full-screen picker.

Subscriptions also explains itself when the selected account cannot
answer: a thin banner names the cause and says the data below is local.
This is the first consumer of the validator's unavailability, and
correctIfContextRemoved() now runs on foreground activation, where
SubscriptionAccountValidator used to auto-correct any selection it could
not immediately verify.

With the switcher reading the context directly, the legacy
subscriptionAccount setting has no readers left: the setting, its
validator, the mirror that kept it in step and the feed cache's
instance-keyed fallbacks all go. The cache invalidation the mirror also
carried stays, under its own name. AccountsManager still reads the raw
legacy key for the one-way context migration.
2026-09-03 23:59:32 +02:00

428 lines
14 KiB
Swift

//
// 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
// 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 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 currentContext: DataContext {
appEnvironment?.accountsManager.currentContext ?? .local
}
var body: some View {
List {
accountSection
importSection
exportSection
localDataSection
}
.task(id: currentContext) {
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.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 {
DataContextSwitcherSettingsRow()
} footer: {
Text(String(localized: "settings.subscriptions.account.footer"))
}
}
// 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 !currentContext.isLocal && 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 currentContext.isLocal {
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.currentContext.isLocal {
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