mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 01:39:46 +00:00
Yattee v2 rewrite
This commit is contained in:
408
Yattee/Views/Settings/SubscriptionsSettingsView.swift
Normal file
408
Yattee/Views/Settings/SubscriptionsSettingsView.swift
Normal file
@@ -0,0 +1,408 @@
|
||||
//
|
||||
// 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?
|
||||
|
||||
|
||||
private var dataManager: DataManager? {
|
||||
appEnvironment?.dataManager
|
||||
}
|
||||
|
||||
private var settingsManager: SettingsManager? {
|
||||
appEnvironment?.settingsManager
|
||||
}
|
||||
|
||||
private var validator: SubscriptionAccountValidator? {
|
||||
appEnvironment?.subscriptionAccountValidator
|
||||
}
|
||||
|
||||
private var subscriptionCount: Int {
|
||||
dataManager?.subscriptionCount ?? 0
|
||||
}
|
||||
|
||||
private var currentAccount: SubscriptionAccount {
|
||||
settingsManager?.subscriptionAccount ?? .local
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
accountSection
|
||||
if validator?.hasAvailableAccounts == true {
|
||||
importSection
|
||||
exportSection
|
||||
}
|
||||
}
|
||||
.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"))
|
||||
}
|
||||
}
|
||||
|
||||
// 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: {
|
||||
Label(String(localized: "settings.subscriptions.export.button"), systemImage: "square.and.arrow.up")
|
||||
}
|
||||
.disabled(subscriptionCount == 0)
|
||||
} header: {
|
||||
Text(String(localized: "settings.subscriptions.export.title"))
|
||||
} footer: {
|
||||
Text(String(localized: "settings.subscriptions.export.footer \(subscriptionCount)"))
|
||||
}
|
||||
}
|
||||
|
||||
// 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 to database
|
||||
guard let dataManager else {
|
||||
throw SubscriptionImportError.invalidData
|
||||
}
|
||||
|
||||
let importStats = dataManager.importSubscriptionsFromExternal(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")
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
isImporting = false
|
||||
importError = error.localizedDescription
|
||||
showingImportError = true
|
||||
LoggingService.shared.logSubscriptionsError("Import failed", error: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func exportSubscriptions() {
|
||||
guard let dataManager else { return }
|
||||
|
||||
let subscriptions = dataManager.allSubscriptions
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
#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
|
||||
Reference in New Issue
Block a user