Yattee v2 rewrite

This commit is contained in:
Arkadiusz Fal
2026-02-08 18:31:16 +01:00
parent 20d0cfc0c7
commit 05f921d605
1043 changed files with 163875 additions and 68430 deletions

View 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