mirror of
https://github.com/yattee/yattee.git
synced 2026-10-24 13:42:54 +00:00
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.
428 lines
14 KiB
Swift
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
|