import AppKit import IPodSyncKit import SwiftUI import UniformTypeIdentifiers struct ExportView: View { @State private var model = ExportModel() @State private var isChoosingFiles = false @State private var isChoosingDestination = false var body: some View { Form { Section { Text( "Prepares contacts and calendars for a click-wheel iPod, which reads " + "dropped .vcf and .ics files but rejects the timezone, alarm and " + "embedded-image data a normal export contains." ) .font(.callout) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } contactsSection calendarsSection filesSection destinationSection resultsSection } .formStyle(.grouped) .frame(minWidth: 520, idealWidth: 580, minHeight: 560) .safeAreaInset(edge: .bottom) { footer } .task { await model.refreshAccessOnAppear() } .fileImporter( isPresented: $isChoosingFiles, allowedContentTypes: Self.importableTypes, allowsMultipleSelection: true ) { result in if case .success(let urls) = result { model.addInputFiles(urls) } } .fileImporter( isPresented: $isChoosingDestination, allowedContentTypes: [.folder] ) { result in if case .success(let url) = result { model.destination = url } } .alert( "Something went wrong", isPresented: Binding( get: { model.errorMessage != nil }, set: { if !$0 { model.errorMessage = nil } } ) ) { Button("OK") { model.errorMessage = nil } } message: { Text(model.errorMessage ?? "") } } private static var importableTypes: [UTType] { [.vCard, UTType(filenameExtension: "ics") ?? .calendarEvent] } // MARK: - Sections private var contactsSection: some View { Section("Contacts") { Toggle("Export contacts", isOn: $model.includeContacts) if model.includeContacts { if model.contactsGranted { Label("Access granted — one .vcf per contact", systemImage: "checkmark.circle") .foregroundStyle(.secondary) .font(.callout) } else { HStack { Text("Contacts access is needed to read your address book.") .font(.callout) .foregroundStyle(.secondary) Spacer() Button("Grant Access…") { Task { await model.requestContactsAccess() } } } } } } } private var calendarsSection: some View { Section("Calendars") { Toggle("Export calendars", isOn: $model.includeCalendars) if model.includeCalendars { if model.calendarsGranted { DatePicker("From", selection: $model.rangeStart, displayedComponents: .date) DatePicker("To", selection: $model.rangeEnd, displayedComponents: .date) if model.calendars.isEmpty { Text("No calendars found.") .font(.callout) .foregroundStyle(.secondary) } else { ForEach(model.calendars) { calendar in Toggle(isOn: Binding( get: { model.isSelected(calendar) }, set: { model.setSelected(calendar, $0) } )) { VStack(alignment: .leading, spacing: 1) { Text(calendar.title) if !calendar.accountName.isEmpty { Text(calendar.accountName) .font(.caption) .foregroundStyle(.secondary) } } } } } Text("Repeating events are written out one occurrence at a time, so the " + "iPod never has to interpret a recurrence rule.") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } else { HStack { Text("Calendar access is needed to read your events.") .font(.callout) .foregroundStyle(.secondary) Spacer() Button("Grant Access…") { Task { await model.requestCalendarAccess() } } } } } } } private var filesSection: some View { Section("Existing files") { Toggle("Sanitize .vcf / .ics files I already have", isOn: $model.includeFiles) if model.includeFiles { HStack { Text(model.inputFiles.isEmpty ? "No files chosen." : "\(model.inputFiles.count) file(s) chosen.") .font(.callout) .foregroundStyle(.secondary) Spacer() if !model.inputFiles.isEmpty { Button("Clear") { model.inputFiles.removeAll() } } Button("Choose Files…") { isChoosingFiles = true } } ForEach(model.inputFiles, id: \.self) { url in Text(url.lastPathComponent) .font(.callout) .foregroundStyle(.secondary) } } } } private var destinationSection: some View { Section("Output folder") { HStack { Text(model.destination?.path(percentEncoded: false) ?? "Not chosen.") .font(.callout) .foregroundStyle(model.destination == nil ? .secondary : .primary) .lineLimit(2) .truncationMode(.middle) Spacer() Button("Choose…") { isChoosingDestination = true } } Text("Files are written into Contacts and Calendars subfolders, matching the " + "folders on the iPod, so you can drag them straight across in Finder.") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } } @ViewBuilder private var resultsSection: some View { if model.phase == .finished { Section("Result") { Text(model.summary) ForEach(Array(model.warnings.enumerated()), id: \.offset) { _, warning in Label(warning, systemImage: "exclamationmark.triangle") .font(.callout) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } if let first = model.writtenFiles.first { Button("Reveal in Finder") { NSWorkspace.shared.activateFileViewerSelecting([first.path]) } } } } } private var footer: some View { HStack { if case .working(let message) = model.phase { ProgressView().controlSize(.small) Text(message).font(.callout).foregroundStyle(.secondary) } Spacer() Button("Export") { Task { await model.export() } } .keyboardShortcut(.defaultAction) .disabled(!model.canExport) } .padding(.horizontal) .padding(.vertical, 10) .background(.bar) } }