mirror of
https://github.com/Ranchero-Software/NetNewsWire
synced 2025-08-12 06:26:36 +00:00
Remove Multiplatform targets
This commit is contained in:
@@ -1,418 +0,0 @@
|
||||
//
|
||||
// AppDelegate.swift
|
||||
// Multiplatform iOS
|
||||
//
|
||||
// Created by Maurice Parker on 6/28/20.
|
||||
// Copyright © 2020 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import RSCore
|
||||
import RSWeb
|
||||
import Account
|
||||
import BackgroundTasks
|
||||
import os.log
|
||||
import Secrets
|
||||
|
||||
var appDelegate: AppDelegate!
|
||||
|
||||
class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate, UnreadCountProvider {
|
||||
|
||||
private var bgTaskDispatchQueue = DispatchQueue.init(label: "BGTaskScheduler")
|
||||
|
||||
private var waitBackgroundUpdateTask = UIBackgroundTaskIdentifier.invalid
|
||||
private var syncBackgroundUpdateTask = UIBackgroundTaskIdentifier.invalid
|
||||
|
||||
var syncTimer: ArticleStatusSyncTimer?
|
||||
|
||||
var shuttingDown = false {
|
||||
didSet {
|
||||
if shuttingDown {
|
||||
syncTimer?.shuttingDown = shuttingDown
|
||||
syncTimer?.invalidate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "Application")
|
||||
|
||||
var userNotificationManager: UserNotificationManager!
|
||||
var faviconDownloader: FaviconDownloader!
|
||||
var imageDownloader: ImageDownloader!
|
||||
var authorAvatarDownloader: AuthorAvatarDownloader!
|
||||
var webFeedIconDownloader: WebFeedIconDownloader!
|
||||
// TODO: Add Extension back in
|
||||
// var extensionContainersFile: ExtensionContainersFile!
|
||||
// var extensionFeedAddRequestFile: ExtensionFeedAddRequestFile!
|
||||
|
||||
var unreadCount = 0 {
|
||||
didSet {
|
||||
if unreadCount != oldValue {
|
||||
postUnreadCountDidChangeNotification()
|
||||
UIApplication.shared.applicationIconBadgeNumber = unreadCount
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var isSyncArticleStatusRunning = false
|
||||
var isWaitingForSyncTasks = false
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
appDelegate = self
|
||||
|
||||
SecretsManager.provider = Secrets()
|
||||
|
||||
let documentFolder = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
|
||||
let documentAccountsFolder = documentFolder.appendingPathComponent("Accounts").absoluteString
|
||||
let documentAccountsFolderPath = String(documentAccountsFolder.suffix(from: documentAccountsFolder.index(documentAccountsFolder.startIndex, offsetBy: 7)))
|
||||
AccountManager.shared = AccountManager(accountsFolder: documentAccountsFolderPath)
|
||||
|
||||
let documentThemesFolder = documentFolder.appendingPathComponent("Themes").absoluteString
|
||||
let documentThemesFolderPath = String(documentThemesFolder.suffix(from: documentAccountsFolder.index(documentThemesFolder.startIndex, offsetBy: 7)))
|
||||
ArticleThemesManager.shared = ArticleThemesManager(folderPath: documentThemesFolderPath)
|
||||
|
||||
FeedProviderManager.shared.delegate = ExtensionPointManager.shared
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(unreadCountDidChange(_:)), name: .UnreadCountDidChange, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(accountRefreshDidFinish(_:)), name: .AccountRefreshDidFinish, object: nil)
|
||||
}
|
||||
|
||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||
AppDefaults.registerDefaults()
|
||||
|
||||
let isFirstRun = AppDefaults.shared.isFirstRun()
|
||||
if isFirstRun {
|
||||
os_log("Is first run.", log: log, type: .info)
|
||||
}
|
||||
|
||||
if isFirstRun && !AccountManager.shared.anyAccountHasAtLeastOneFeed() {
|
||||
let localAccount = AccountManager.shared.defaultAccount
|
||||
DefaultFeedsImporter.importDefaultFeeds(account: localAccount)
|
||||
}
|
||||
|
||||
registerBackgroundTasks()
|
||||
CacheCleaner.purgeIfNecessary()
|
||||
initializeDownloaders()
|
||||
initializeHomeScreenQuickActions()
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.unreadCount = AccountManager.shared.unreadCount
|
||||
}
|
||||
|
||||
UNUserNotificationCenter.current().getNotificationSettings { (settings) in
|
||||
if settings.authorizationStatus == .authorized {
|
||||
DispatchQueue.main.async {
|
||||
UIApplication.shared.registerForRemoteNotifications()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
UNUserNotificationCenter.current().delegate = self
|
||||
userNotificationManager = UserNotificationManager()
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(userDefaultsDidChange), name: UserDefaults.didChangeNotification, object: nil)
|
||||
|
||||
|
||||
// extensionContainersFile = ExtensionContainersFile()
|
||||
// extensionFeedAddRequestFile = ExtensionFeedAddRequestFile()
|
||||
|
||||
syncTimer = ArticleStatusSyncTimer()
|
||||
|
||||
#if DEBUG
|
||||
syncTimer!.update()
|
||||
#endif
|
||||
|
||||
return true
|
||||
|
||||
}
|
||||
|
||||
func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable : Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
|
||||
DispatchQueue.main.async {
|
||||
self.resumeDatabaseProcessingIfNecessary()
|
||||
AccountManager.shared.receiveRemoteNotification(userInfo: userInfo) {
|
||||
self.suspendApplication()
|
||||
completionHandler(.newData)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_ application: UIApplication) {
|
||||
shuttingDown = true
|
||||
}
|
||||
|
||||
// MARK: Notifications
|
||||
|
||||
@objc func unreadCountDidChange(_ note: Notification) {
|
||||
if note.object is AccountManager {
|
||||
unreadCount = AccountManager.shared.unreadCount
|
||||
}
|
||||
}
|
||||
|
||||
@objc func accountRefreshDidFinish(_ note: Notification) {
|
||||
AppDefaults.shared.lastRefresh = Date()
|
||||
}
|
||||
|
||||
// MARK: - API
|
||||
|
||||
func resumeDatabaseProcessingIfNecessary() {
|
||||
if AccountManager.shared.isSuspended {
|
||||
AccountManager.shared.resumeAll()
|
||||
os_log("Application processing resumed.", log: self.log, type: .info)
|
||||
}
|
||||
}
|
||||
|
||||
func prepareAccountsForBackground() {
|
||||
// extensionFeedAddRequestFile.suspend()
|
||||
syncTimer?.invalidate()
|
||||
scheduleBackgroundFeedRefresh()
|
||||
syncArticleStatus()
|
||||
waitForSyncTasksToFinish()
|
||||
}
|
||||
|
||||
func prepareAccountsForForeground() {
|
||||
// extensionFeedAddRequestFile.resume()
|
||||
syncTimer?.update()
|
||||
|
||||
if let lastRefresh = AppDefaults.shared.lastRefresh {
|
||||
if Date() > lastRefresh.addingTimeInterval(15 * 60) {
|
||||
AccountManager.shared.refreshAll(errorHandler: ErrorHandler.log)
|
||||
} else {
|
||||
AccountManager.shared.syncArticleStatusAll()
|
||||
}
|
||||
} else {
|
||||
AccountManager.shared.refreshAll(errorHandler: ErrorHandler.log)
|
||||
}
|
||||
}
|
||||
|
||||
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
|
||||
completionHandler([.banner, .badge, .sound])
|
||||
}
|
||||
|
||||
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
|
||||
defer { completionHandler() }
|
||||
|
||||
// TODO: Add back in User Notification handling
|
||||
// if let sceneDelegate = response.targetScene?.delegate as? SceneDelegate {
|
||||
// sceneDelegate.handle(response)
|
||||
// }
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: App Initialization
|
||||
|
||||
private extension AppDelegate {
|
||||
|
||||
private func initializeDownloaders() {
|
||||
let tempDir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!
|
||||
let faviconsFolderURL = tempDir.appendingPathComponent("Favicons")
|
||||
let imagesFolderURL = tempDir.appendingPathComponent("Images")
|
||||
|
||||
try! FileManager.default.createDirectory(at: faviconsFolderURL, withIntermediateDirectories: true, attributes: nil)
|
||||
let faviconsFolder = faviconsFolderURL.absoluteString
|
||||
let faviconsFolderPath = faviconsFolder.suffix(from: faviconsFolder.index(faviconsFolder.startIndex, offsetBy: 7))
|
||||
faviconDownloader = FaviconDownloader(folder: String(faviconsFolderPath))
|
||||
|
||||
let imagesFolder = imagesFolderURL.absoluteString
|
||||
let imagesFolderPath = imagesFolder.suffix(from: imagesFolder.index(imagesFolder.startIndex, offsetBy: 7))
|
||||
try! FileManager.default.createDirectory(at: imagesFolderURL, withIntermediateDirectories: true, attributes: nil)
|
||||
imageDownloader = ImageDownloader(folder: String(imagesFolderPath))
|
||||
|
||||
authorAvatarDownloader = AuthorAvatarDownloader(imageDownloader: imageDownloader)
|
||||
|
||||
let tempFolder = tempDir.absoluteString
|
||||
let tempFolderPath = tempFolder.suffix(from: tempFolder.index(tempFolder.startIndex, offsetBy: 7))
|
||||
webFeedIconDownloader = WebFeedIconDownloader(imageDownloader: imageDownloader, folder: String(tempFolderPath))
|
||||
}
|
||||
|
||||
private func initializeHomeScreenQuickActions() {
|
||||
let unreadTitle = NSLocalizedString("First Unread", comment: "First Unread")
|
||||
let unreadIcon = UIApplicationShortcutIcon(systemImageName: "chevron.down.circle")
|
||||
let unreadItem = UIApplicationShortcutItem(type: "com.ranchero.NetNewsWire.FirstUnread", localizedTitle: unreadTitle, localizedSubtitle: nil, icon: unreadIcon, userInfo: nil)
|
||||
|
||||
let searchTitle = NSLocalizedString("Search", comment: "Search")
|
||||
let searchIcon = UIApplicationShortcutIcon(systemImageName: "magnifyingglass")
|
||||
let searchItem = UIApplicationShortcutItem(type: "com.ranchero.NetNewsWire.ShowSearch", localizedTitle: searchTitle, localizedSubtitle: nil, icon: searchIcon, userInfo: nil)
|
||||
|
||||
let addTitle = NSLocalizedString("Add Feed", comment: "Add Feed")
|
||||
let addIcon = UIApplicationShortcutIcon(systemImageName: "plus")
|
||||
let addItem = UIApplicationShortcutItem(type: "com.ranchero.NetNewsWire.ShowAdd", localizedTitle: addTitle, localizedSubtitle: nil, icon: addIcon, userInfo: nil)
|
||||
|
||||
UIApplication.shared.shortcutItems = [addItem, searchItem, unreadItem]
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: Go To Background
|
||||
|
||||
private extension AppDelegate {
|
||||
|
||||
func waitForSyncTasksToFinish() {
|
||||
guard !isWaitingForSyncTasks && UIApplication.shared.applicationState == .background else { return }
|
||||
|
||||
isWaitingForSyncTasks = true
|
||||
|
||||
self.waitBackgroundUpdateTask = UIApplication.shared.beginBackgroundTask { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.completeProcessing(true)
|
||||
os_log("Accounts wait for progress terminated for running too long.", log: self.log, type: .info)
|
||||
}
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.waitToComplete() { [weak self] suspend in
|
||||
self?.completeProcessing(suspend)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func waitToComplete(completion: @escaping (Bool) -> Void) {
|
||||
guard UIApplication.shared.applicationState == .background else {
|
||||
os_log("App came back to forground, no longer waiting.", log: self.log, type: .info)
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
|
||||
if AccountManager.shared.refreshInProgress || isSyncArticleStatusRunning {
|
||||
os_log("Waiting for sync to finish...", log: self.log, type: .info)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in
|
||||
self?.waitToComplete(completion: completion)
|
||||
}
|
||||
} else {
|
||||
os_log("Refresh progress complete.", log: self.log, type: .info)
|
||||
completion(true)
|
||||
}
|
||||
}
|
||||
|
||||
func completeProcessing(_ suspend: Bool) {
|
||||
if suspend {
|
||||
suspendApplication()
|
||||
}
|
||||
UIApplication.shared.endBackgroundTask(self.waitBackgroundUpdateTask)
|
||||
self.waitBackgroundUpdateTask = UIBackgroundTaskIdentifier.invalid
|
||||
isWaitingForSyncTasks = false
|
||||
}
|
||||
|
||||
func syncArticleStatus() {
|
||||
guard !isSyncArticleStatusRunning else { return }
|
||||
|
||||
isSyncArticleStatusRunning = true
|
||||
|
||||
let completeProcessing = { [unowned self] in
|
||||
self.isSyncArticleStatusRunning = false
|
||||
UIApplication.shared.endBackgroundTask(self.syncBackgroundUpdateTask)
|
||||
self.syncBackgroundUpdateTask = UIBackgroundTaskIdentifier.invalid
|
||||
}
|
||||
|
||||
self.syncBackgroundUpdateTask = UIApplication.shared.beginBackgroundTask {
|
||||
completeProcessing()
|
||||
os_log("Accounts sync processing terminated for running too long.", log: self.log, type: .info)
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
AccountManager.shared.syncArticleStatusAll() {
|
||||
completeProcessing()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func suspendApplication() {
|
||||
guard UIApplication.shared.applicationState == .background else { return }
|
||||
|
||||
AccountManager.shared.suspendNetworkAll()
|
||||
AccountManager.shared.suspendDatabaseAll()
|
||||
CoalescingQueue.standard.performCallsImmediately()
|
||||
|
||||
os_log("Application processing suspended.", log: self.log, type: .info)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: Background Tasks
|
||||
|
||||
private extension AppDelegate {
|
||||
|
||||
/// Register all background tasks.
|
||||
func registerBackgroundTasks() {
|
||||
// Register background feed refresh.
|
||||
BGTaskScheduler.shared.register(forTaskWithIdentifier: "com.ranchero.NetNewsWire.FeedRefresh", using: nil) { (task) in
|
||||
self.performBackgroundFeedRefresh(with: task as! BGAppRefreshTask)
|
||||
}
|
||||
}
|
||||
|
||||
/// Schedules a background app refresh based on `AppDefaults.refreshInterval`.
|
||||
func scheduleBackgroundFeedRefresh() {
|
||||
let request = BGAppRefreshTaskRequest(identifier: "com.ranchero.NetNewsWire.FeedRefresh")
|
||||
request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60)
|
||||
|
||||
// We send this to a dedicated serial queue because as of 11/05/19 on iOS 13.2 the call to the
|
||||
// task scheduler can hang indefinitely.
|
||||
bgTaskDispatchQueue.async {
|
||||
do {
|
||||
try BGTaskScheduler.shared.submit(request)
|
||||
} catch {
|
||||
os_log(.error, log: self.log, "Could not schedule app refresh: %@", error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Performs background feed refresh.
|
||||
/// - Parameter task: `BGAppRefreshTask`
|
||||
/// - Warning: As of Xcode 11 beta 2, when triggered from the debugger this doesn't work.
|
||||
func performBackgroundFeedRefresh(with task: BGAppRefreshTask) {
|
||||
|
||||
scheduleBackgroundFeedRefresh() // schedule next refresh
|
||||
|
||||
os_log("Woken to perform account refresh.", log: self.log, type: .info)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
if AccountManager.shared.isSuspended {
|
||||
AccountManager.shared.resumeAll()
|
||||
}
|
||||
AccountManager.shared.refreshAll(errorHandler: ErrorHandler.log) { [unowned self] in
|
||||
if !AccountManager.shared.isSuspended {
|
||||
self.suspendApplication()
|
||||
os_log("Account refresh operation completed.", log: self.log, type: .info)
|
||||
task.setTaskCompleted(success: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// set expiration handler
|
||||
task.expirationHandler = { [weak task] in
|
||||
DispatchQueue.main.sync {
|
||||
self.suspendApplication()
|
||||
}
|
||||
os_log("Accounts refresh processing terminated for running too long.", log: self.log, type: .info)
|
||||
task?.setTaskCompleted(success: false)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private extension AppDelegate {
|
||||
@objc func userDefaultsDidChange() {
|
||||
updateUserInterfaceStyle()
|
||||
}
|
||||
|
||||
var window: UIWindow? {
|
||||
guard let scene = UIApplication.shared.connectedScenes.first,
|
||||
let windowSceneDelegate = scene.delegate as? UIWindowSceneDelegate,
|
||||
let window = windowSceneDelegate.window else {
|
||||
return nil
|
||||
}
|
||||
return window
|
||||
}
|
||||
|
||||
func updateUserInterfaceStyle() {
|
||||
// switch AppDefaults.shared.userInterfaceColorPalette {
|
||||
// case .automatic:
|
||||
// window?.overrideUserInterfaceStyle = .unspecified
|
||||
// case .light:
|
||||
// window?.overrideUserInterfaceStyle = .light
|
||||
// case .dark:
|
||||
// window?.overrideUserInterfaceStyle = .dark
|
||||
// }
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
//
|
||||
// ArticleShareView.swift
|
||||
// Multiplatform iOS
|
||||
//
|
||||
// Created by Maurice Parker on 7/13/20.
|
||||
// Copyright © 2020 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import SwiftUI
|
||||
import Articles
|
||||
|
||||
extension UIActivityViewController {
|
||||
convenience init(url: URL, title: String?, applicationActivities: [UIActivity]?) {
|
||||
let itemSource = ArticleActivityItemSource(url: url, subject: title)
|
||||
let titleSource = TitleActivityItemSource(title: title)
|
||||
self.init(activityItems: [titleSource, itemSource], applicationActivities: applicationActivities)
|
||||
}
|
||||
}
|
||||
|
||||
struct ActivityViewController: UIViewControllerRepresentable {
|
||||
|
||||
var title: String?
|
||||
var url: URL
|
||||
|
||||
func makeUIViewController(context: Context) -> UIActivityViewController {
|
||||
return UIActivityViewController(url: url, title: title, applicationActivities: [FindInArticleActivity(), OpenInSafariActivity()])
|
||||
}
|
||||
|
||||
func updateUIViewController(_ controller: UIActivityViewController, context: Context) {
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
//
|
||||
// ArticleActivityItemSource.swift
|
||||
// Multiplatform iOS
|
||||
//
|
||||
// Created by Maurice Parker on 7/13/20.
|
||||
// Copyright © 2020 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
class ArticleActivityItemSource: NSObject, UIActivityItemSource {
|
||||
|
||||
private let url: URL
|
||||
private let subject: String?
|
||||
|
||||
init(url: URL, subject: String?) {
|
||||
self.url = url
|
||||
self.subject = subject
|
||||
}
|
||||
|
||||
func activityViewControllerPlaceholderItem(_ : UIActivityViewController) -> Any {
|
||||
return url
|
||||
}
|
||||
|
||||
func activityViewController(_ activityViewController: UIActivityViewController, itemForActivityType activityType: UIActivity.ActivityType?) -> Any? {
|
||||
return url
|
||||
}
|
||||
|
||||
func activityViewController(_ activityViewController: UIActivityViewController, subjectForActivityType activityType: UIActivity.ActivityType?) -> String {
|
||||
return subject ?? ""
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
//
|
||||
// ArticleView.swift
|
||||
// Multiplatform iOS
|
||||
//
|
||||
// Created by Maurice Parker on 7/6/20.
|
||||
// Copyright © 2020 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Articles
|
||||
|
||||
struct ArticleView: UIViewControllerRepresentable {
|
||||
|
||||
@EnvironmentObject private var sceneModel: SceneModel
|
||||
|
||||
func makeUIViewController(context: Context) -> ArticleViewController {
|
||||
let controller = ArticleViewController()
|
||||
controller.sceneModel = sceneModel
|
||||
return controller
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: ArticleViewController, context: Context) {
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
//
|
||||
// ArticleViewController.swift
|
||||
// Multiplatform iOS
|
||||
//
|
||||
// Created by Maurice Parker on 7/6/20.
|
||||
// Copyright © 2020 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import Combine
|
||||
import WebKit
|
||||
import Account
|
||||
import Articles
|
||||
import SafariServices
|
||||
|
||||
class ArticleViewController: UIViewController {
|
||||
|
||||
weak var sceneModel: SceneModel?
|
||||
|
||||
private var pageViewController: UIPageViewController!
|
||||
|
||||
private var currentWebViewController: WebViewController? {
|
||||
return pageViewController?.viewControllers?.first as? WebViewController
|
||||
}
|
||||
|
||||
var articles: [Article]? {
|
||||
didSet {
|
||||
currentArticle = articles?.first
|
||||
}
|
||||
}
|
||||
|
||||
var currentArticle: Article? {
|
||||
didSet {
|
||||
if let controller = currentWebViewController, controller.article != currentArticle {
|
||||
controller.setArticle(currentArticle)
|
||||
DispatchQueue.main.async {
|
||||
// You have to set the view controller to clear out the UIPageViewController child controller cache.
|
||||
// You also have to do it in an async call or you will get a strange assertion error.
|
||||
self.pageViewController.setViewControllers([controller], direction: .forward, animated: false, completion: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
pageViewController = UIPageViewController(transitionStyle: .scroll, navigationOrientation: .horizontal, options: [:])
|
||||
pageViewController.delegate = self
|
||||
pageViewController.dataSource = self
|
||||
|
||||
pageViewController.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(pageViewController.view)
|
||||
addChild(pageViewController!)
|
||||
NSLayoutConstraint.activate([
|
||||
view.leadingAnchor.constraint(equalTo: pageViewController.view.leadingAnchor),
|
||||
view.trailingAnchor.constraint(equalTo: pageViewController.view.trailingAnchor),
|
||||
view.topAnchor.constraint(equalTo: pageViewController.view.topAnchor),
|
||||
view.bottomAnchor.constraint(equalTo: pageViewController.view.bottomAnchor)
|
||||
])
|
||||
|
||||
sceneModel?.timelineModel.selectedArticlesPublisher?.sink { [weak self] articles in
|
||||
self?.articles = articles
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
let controller = createWebViewController(currentArticle, updateView: true)
|
||||
self.pageViewController.setViewControllers([controller], direction: .forward, animated: false, completion: nil)
|
||||
|
||||
}
|
||||
|
||||
// MARK: API
|
||||
|
||||
func focus() {
|
||||
currentWebViewController?.focus()
|
||||
}
|
||||
|
||||
func canScrollDown() -> Bool {
|
||||
return currentWebViewController?.canScrollDown() ?? false
|
||||
}
|
||||
|
||||
func scrollPageDown() {
|
||||
currentWebViewController?.scrollPageDown()
|
||||
}
|
||||
|
||||
func stopArticleExtractorIfProcessing() {
|
||||
currentWebViewController?.stopArticleExtractorIfProcessing()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
// MARK: WebViewControllerDelegate
|
||||
|
||||
extension ArticleViewController: WebViewControllerDelegate {
|
||||
|
||||
func webViewController(_ webViewController: WebViewController, articleExtractorButtonStateDidUpdate buttonState: ArticleExtractorButtonState) {
|
||||
if webViewController === currentWebViewController {
|
||||
// articleExtractorButton.buttonState = buttonState
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: UIPageViewControllerDataSource
|
||||
|
||||
extension ArticleViewController: UIPageViewControllerDataSource {
|
||||
|
||||
func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
|
||||
guard let webViewController = viewController as? WebViewController,
|
||||
let currentArticle = webViewController.article,
|
||||
let article = sceneModel?.findPrevArticle(currentArticle) else {
|
||||
return nil
|
||||
}
|
||||
return createWebViewController(article)
|
||||
}
|
||||
|
||||
func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
|
||||
guard let webViewController = viewController as? WebViewController,
|
||||
let currentArticle = webViewController.article,
|
||||
let article = sceneModel?.findNextArticle(currentArticle) else {
|
||||
return nil
|
||||
}
|
||||
return createWebViewController(article)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: UIPageViewControllerDelegate
|
||||
|
||||
extension ArticleViewController: UIPageViewControllerDelegate {
|
||||
|
||||
func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
|
||||
guard finished, completed else { return }
|
||||
// guard let article = currentWebViewController?.article else { return }
|
||||
|
||||
// articleExtractorButton.buttonState = currentWebViewController?.articleExtractorButtonState ?? .off
|
||||
|
||||
previousViewControllers.compactMap({ $0 as? WebViewController }).forEach({ $0.stopWebViewActivity() })
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: Private
|
||||
|
||||
private extension ArticleViewController {
|
||||
|
||||
func createWebViewController(_ article: Article?, updateView: Bool = true) -> WebViewController {
|
||||
let controller = WebViewController()
|
||||
controller.sceneModel = sceneModel
|
||||
controller.delegate = self
|
||||
controller.setArticle(article, updateView: updateView)
|
||||
return controller
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public extension Notification.Name {
|
||||
static let FindInArticle = Notification.Name("FindInArticle")
|
||||
static let EndFindInArticle = Notification.Name("EndFindInArticle")
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
//
|
||||
// FindInArticleActivity.swift
|
||||
// NetNewsWire-iOS
|
||||
//
|
||||
// Created by Brian Sanders on 5/7/20.
|
||||
// Copyright © 2020 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
class FindInArticleActivity: UIActivity {
|
||||
override var activityTitle: String? {
|
||||
NSLocalizedString("Find in Article", comment: "Find in Article")
|
||||
}
|
||||
|
||||
override var activityType: UIActivity.ActivityType? {
|
||||
UIActivity.ActivityType(rawValue: "com.ranchero.NetNewsWire.find")
|
||||
}
|
||||
|
||||
override var activityImage: UIImage? {
|
||||
UIImage(systemName: "magnifyingglass", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .regular))
|
||||
}
|
||||
|
||||
override class var activityCategory: UIActivity.Category {
|
||||
.action
|
||||
}
|
||||
|
||||
override func canPerform(withActivityItems activityItems: [Any]) -> Bool {
|
||||
true
|
||||
}
|
||||
|
||||
override func prepare(withActivityItems activityItems: [Any]) {
|
||||
|
||||
}
|
||||
|
||||
override func perform() {
|
||||
NotificationCenter.default.post(Notification(name: .FindInArticle))
|
||||
activityDidFinish(true)
|
||||
}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
//
|
||||
// IconView.swift
|
||||
// Multiplatform iOS
|
||||
//
|
||||
// Created by Maurice Parker on 7/6/20.
|
||||
// Copyright © 2020 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
final class IconView: UIView {
|
||||
|
||||
var iconImage: IconImage? = nil {
|
||||
didSet {
|
||||
if iconImage !== oldValue {
|
||||
imageView.image = iconImage?.image
|
||||
|
||||
if self.traitCollection.userInterfaceStyle == .dark {
|
||||
if self.iconImage?.isDark ?? false {
|
||||
self.isDiscernable = false
|
||||
self.setNeedsLayout()
|
||||
} else {
|
||||
self.isDiscernable = true
|
||||
self.setNeedsLayout()
|
||||
}
|
||||
} else {
|
||||
if self.iconImage?.isBright ?? false {
|
||||
self.isDiscernable = false
|
||||
self.setNeedsLayout()
|
||||
} else {
|
||||
self.isDiscernable = true
|
||||
self.setNeedsLayout()
|
||||
}
|
||||
}
|
||||
self.setNeedsLayout()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var isDiscernable = true
|
||||
|
||||
private let imageView: UIImageView = {
|
||||
let imageView = UIImageView(image: AppAssets.faviconTemplateImage)
|
||||
imageView.contentMode = .scaleAspectFit
|
||||
imageView.clipsToBounds = true
|
||||
imageView.layer.cornerRadius = 4.0
|
||||
return imageView
|
||||
}()
|
||||
|
||||
private var isVerticalBackgroundExposed: Bool {
|
||||
return imageView.frame.size.height < bounds.size.height
|
||||
}
|
||||
|
||||
private var isSymbolImage: Bool {
|
||||
return iconImage?.isSymbol ?? false
|
||||
}
|
||||
|
||||
private var isBackgroundSuppressed: Bool {
|
||||
return iconImage?.isBackgroundSupressed ?? false
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
commonInit()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
commonInit()
|
||||
}
|
||||
|
||||
convenience init() {
|
||||
self.init(frame: .zero)
|
||||
}
|
||||
|
||||
override func didMoveToSuperview() {
|
||||
setNeedsLayout()
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
imageView.setFrameIfNotEqual(rectForImageView())
|
||||
if (iconImage != nil && isVerticalBackgroundExposed) || !isDiscernable {
|
||||
backgroundColor = AppAssets.uiIconBackgroundColor
|
||||
} else {
|
||||
backgroundColor = nil
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private extension IconView {
|
||||
|
||||
func commonInit() {
|
||||
layer.cornerRadius = 4
|
||||
clipsToBounds = true
|
||||
addSubview(imageView)
|
||||
}
|
||||
|
||||
func rectForImageView() -> CGRect {
|
||||
guard let image = iconImage?.image else {
|
||||
return CGRect.zero
|
||||
}
|
||||
|
||||
let imageSize = image.size
|
||||
let viewSize = bounds.size
|
||||
if imageSize.height == imageSize.width {
|
||||
if imageSize.height >= viewSize.height {
|
||||
return CGRect(x: 0.0, y: 0.0, width: viewSize.width, height: viewSize.height)
|
||||
}
|
||||
let offset = floor((viewSize.height - imageSize.height) / 2.0)
|
||||
return CGRect(x: offset, y: offset, width: imageSize.width, height: imageSize.height)
|
||||
}
|
||||
else if imageSize.height > imageSize.width {
|
||||
let factor = viewSize.height / imageSize.height
|
||||
let width = imageSize.width * factor
|
||||
let originX = floor((viewSize.width - width) / 2.0)
|
||||
return CGRect(x: originX, y: 0.0, width: width, height: viewSize.height)
|
||||
}
|
||||
|
||||
// Wider than tall: imageSize.width > imageSize.height
|
||||
let factor = viewSize.width / imageSize.width
|
||||
let height = imageSize.height * factor
|
||||
let originY = floor((viewSize.height - height) / 2.0)
|
||||
return CGRect(x: 0.0, y: originY, width: viewSize.width, height: height)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,361 +0,0 @@
|
||||
//
|
||||
// ImageScrollView.swift
|
||||
// Multiplatform iOS
|
||||
//
|
||||
// Created by Maurice Parker on 7/6/20.
|
||||
// Copyright © 2020 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
@objc public protocol ImageScrollViewDelegate: UIScrollViewDelegate {
|
||||
func imageScrollViewDidGestureSwipeUp(imageScrollView: ImageScrollView)
|
||||
func imageScrollViewDidGestureSwipeDown(imageScrollView: ImageScrollView)
|
||||
}
|
||||
|
||||
open class ImageScrollView: UIScrollView {
|
||||
|
||||
@objc public enum ScaleMode: Int {
|
||||
case aspectFill
|
||||
case aspectFit
|
||||
case widthFill
|
||||
case heightFill
|
||||
}
|
||||
|
||||
@objc public enum Offset: Int {
|
||||
case begining
|
||||
case center
|
||||
}
|
||||
|
||||
static let kZoomInFactorFromMinWhenDoubleTap: CGFloat = 2
|
||||
|
||||
@objc open var imageContentMode: ScaleMode = .widthFill
|
||||
@objc open var initialOffset: Offset = .begining
|
||||
|
||||
@objc public private(set) var zoomView: UIImageView? = nil
|
||||
|
||||
@objc open weak var imageScrollViewDelegate: ImageScrollViewDelegate?
|
||||
|
||||
var imageSize: CGSize = CGSize.zero
|
||||
private var pointToCenterAfterResize: CGPoint = CGPoint.zero
|
||||
private var scaleToRestoreAfterResize: CGFloat = 1.0
|
||||
var maxScaleFromMinScale: CGFloat = 3.0
|
||||
|
||||
var zoomedFrame: CGRect {
|
||||
return zoomView?.frame ?? CGRect.zero
|
||||
}
|
||||
|
||||
override open var frame: CGRect {
|
||||
willSet {
|
||||
if frame.equalTo(newValue) == false && newValue.equalTo(CGRect.zero) == false && imageSize.equalTo(CGSize.zero) == false {
|
||||
prepareToResize()
|
||||
}
|
||||
}
|
||||
|
||||
didSet {
|
||||
if frame.equalTo(oldValue) == false && frame.equalTo(CGRect.zero) == false && imageSize.equalTo(CGSize.zero) == false {
|
||||
recoverFromResizing()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override public init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
|
||||
initialize()
|
||||
}
|
||||
|
||||
required public init?(coder aDecoder: NSCoder) {
|
||||
super.init(coder: aDecoder)
|
||||
|
||||
initialize()
|
||||
}
|
||||
|
||||
private func initialize() {
|
||||
showsVerticalScrollIndicator = false
|
||||
showsHorizontalScrollIndicator = false
|
||||
bouncesZoom = true
|
||||
decelerationRate = UIScrollView.DecelerationRate.fast
|
||||
delegate = self
|
||||
}
|
||||
|
||||
@objc public func adjustFrameToCenter() {
|
||||
|
||||
guard let unwrappedZoomView = zoomView else {
|
||||
return
|
||||
}
|
||||
|
||||
var frameToCenter = unwrappedZoomView.frame
|
||||
|
||||
// center horizontally
|
||||
if frameToCenter.size.width < bounds.width {
|
||||
frameToCenter.origin.x = (bounds.width - frameToCenter.size.width) / 2
|
||||
} else {
|
||||
frameToCenter.origin.x = 0
|
||||
}
|
||||
|
||||
// center vertically
|
||||
if frameToCenter.size.height < bounds.height {
|
||||
frameToCenter.origin.y = (bounds.height - frameToCenter.size.height) / 2
|
||||
} else {
|
||||
frameToCenter.origin.y = 0
|
||||
}
|
||||
|
||||
unwrappedZoomView.frame = frameToCenter
|
||||
}
|
||||
|
||||
private func prepareToResize() {
|
||||
let boundsCenter = CGPoint(x: bounds.midX, y: bounds.midY)
|
||||
pointToCenterAfterResize = convert(boundsCenter, to: zoomView)
|
||||
|
||||
scaleToRestoreAfterResize = zoomScale
|
||||
|
||||
// If we're at the minimum zoom scale, preserve that by returning 0, which will be converted to the minimum
|
||||
// allowable scale when the scale is restored.
|
||||
if scaleToRestoreAfterResize <= minimumZoomScale + CGFloat(Float.ulpOfOne) {
|
||||
scaleToRestoreAfterResize = 0
|
||||
}
|
||||
}
|
||||
|
||||
private func recoverFromResizing() {
|
||||
setMaxMinZoomScalesForCurrentBounds()
|
||||
|
||||
// restore zoom scale, first making sure it is within the allowable range.
|
||||
let maxZoomScale = max(minimumZoomScale, scaleToRestoreAfterResize)
|
||||
zoomScale = min(maximumZoomScale, maxZoomScale)
|
||||
|
||||
// restore center point, first making sure it is within the allowable range.
|
||||
|
||||
// convert our desired center point back to our own coordinate space
|
||||
let boundsCenter = convert(pointToCenterAfterResize, to: zoomView)
|
||||
|
||||
// calculate the content offset that would yield that center point
|
||||
var offset = CGPoint(x: boundsCenter.x - bounds.size.width/2.0, y: boundsCenter.y - bounds.size.height/2.0)
|
||||
|
||||
// restore offset, adjusted to be within the allowable range
|
||||
let maxOffset = maximumContentOffset()
|
||||
let minOffset = minimumContentOffset()
|
||||
|
||||
var realMaxOffset = min(maxOffset.x, offset.x)
|
||||
offset.x = max(minOffset.x, realMaxOffset)
|
||||
|
||||
realMaxOffset = min(maxOffset.y, offset.y)
|
||||
offset.y = max(minOffset.y, realMaxOffset)
|
||||
|
||||
contentOffset = offset
|
||||
}
|
||||
|
||||
private func maximumContentOffset() -> CGPoint {
|
||||
return CGPoint(x: contentSize.width - bounds.width,y:contentSize.height - bounds.height)
|
||||
}
|
||||
|
||||
private func minimumContentOffset() -> CGPoint {
|
||||
return CGPoint.zero
|
||||
}
|
||||
|
||||
// MARK: - Set up
|
||||
|
||||
open func setup() {
|
||||
var topSupperView = superview
|
||||
|
||||
while topSupperView?.superview != nil {
|
||||
topSupperView = topSupperView?.superview
|
||||
}
|
||||
|
||||
// Make sure views have already layout with precise frame
|
||||
topSupperView?.layoutIfNeeded()
|
||||
}
|
||||
|
||||
// MARK: - Display image
|
||||
|
||||
@objc open func display(image: UIImage) {
|
||||
|
||||
if let zoomView = zoomView {
|
||||
zoomView.removeFromSuperview()
|
||||
}
|
||||
|
||||
zoomView = UIImageView(image: image)
|
||||
zoomView!.isUserInteractionEnabled = true
|
||||
addSubview(zoomView!)
|
||||
|
||||
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(doubleTapGestureRecognizer(_:)))
|
||||
tapGesture.numberOfTapsRequired = 2
|
||||
zoomView!.addGestureRecognizer(tapGesture)
|
||||
|
||||
let downSwipeGesture = UISwipeGestureRecognizer(target: self, action: #selector(swipeUpGestureRecognizer(_:)))
|
||||
downSwipeGesture.direction = .down
|
||||
zoomView!.addGestureRecognizer(downSwipeGesture)
|
||||
|
||||
let upSwipeGesture = UISwipeGestureRecognizer(target: self, action: #selector(swipeDownGestureRecognizer(_:)))
|
||||
upSwipeGesture.direction = .up
|
||||
zoomView!.addGestureRecognizer(upSwipeGesture)
|
||||
|
||||
configureImageForSize(image.size)
|
||||
adjustFrameToCenter()
|
||||
}
|
||||
|
||||
private func configureImageForSize(_ size: CGSize) {
|
||||
imageSize = size
|
||||
contentSize = imageSize
|
||||
setMaxMinZoomScalesForCurrentBounds()
|
||||
zoomScale = minimumZoomScale
|
||||
|
||||
switch initialOffset {
|
||||
case .begining:
|
||||
contentOffset = CGPoint.zero
|
||||
case .center:
|
||||
let xOffset = contentSize.width < bounds.width ? 0 : (contentSize.width - bounds.width)/2
|
||||
let yOffset = contentSize.height < bounds.height ? 0 : (contentSize.height - bounds.height)/2
|
||||
|
||||
switch imageContentMode {
|
||||
case .aspectFit:
|
||||
contentOffset = CGPoint.zero
|
||||
case .aspectFill:
|
||||
contentOffset = CGPoint(x: xOffset, y: yOffset)
|
||||
case .heightFill:
|
||||
contentOffset = CGPoint(x: xOffset, y: 0)
|
||||
case .widthFill:
|
||||
contentOffset = CGPoint(x: 0, y: yOffset)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func setMaxMinZoomScalesForCurrentBounds() {
|
||||
// calculate min/max zoomscale
|
||||
let xScale = bounds.width / imageSize.width // the scale needed to perfectly fit the image width-wise
|
||||
let yScale = bounds.height / imageSize.height // the scale needed to perfectly fit the image height-wise
|
||||
|
||||
var minScale: CGFloat = 1
|
||||
|
||||
switch imageContentMode {
|
||||
case .aspectFill:
|
||||
minScale = max(xScale, yScale)
|
||||
case .aspectFit:
|
||||
minScale = min(xScale, yScale)
|
||||
case .widthFill:
|
||||
minScale = xScale
|
||||
case .heightFill:
|
||||
minScale = yScale
|
||||
}
|
||||
|
||||
|
||||
let maxScale = maxScaleFromMinScale*minScale
|
||||
|
||||
// don't let minScale exceed maxScale. (If the image is smaller than the screen, we don't want to force it to be zoomed.)
|
||||
if minScale > maxScale {
|
||||
minScale = maxScale
|
||||
}
|
||||
|
||||
maximumZoomScale = maxScale
|
||||
minimumZoomScale = minScale // * 0.999 // the multiply factor to prevent user cannot scroll page while they use this control in UIPageViewController
|
||||
}
|
||||
|
||||
// MARK: - Gesture
|
||||
|
||||
@objc func doubleTapGestureRecognizer(_ gestureRecognizer: UIGestureRecognizer) {
|
||||
// zoom out if it bigger than middle scale point. Else, zoom in
|
||||
if zoomScale >= maximumZoomScale / 2.0 {
|
||||
setZoomScale(minimumZoomScale, animated: true)
|
||||
} else {
|
||||
let center = gestureRecognizer.location(in: gestureRecognizer.view)
|
||||
let zoomRect = zoomRectForScale(ImageScrollView.kZoomInFactorFromMinWhenDoubleTap * minimumZoomScale, center: center)
|
||||
zoom(to: zoomRect, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
@objc func swipeUpGestureRecognizer(_ gestureRecognizer: UIGestureRecognizer) {
|
||||
if gestureRecognizer.state == .ended {
|
||||
imageScrollViewDelegate?.imageScrollViewDidGestureSwipeUp(imageScrollView: self)
|
||||
}
|
||||
}
|
||||
|
||||
@objc func swipeDownGestureRecognizer(_ gestureRecognizer: UIGestureRecognizer) {
|
||||
if gestureRecognizer.state == .ended {
|
||||
imageScrollViewDelegate?.imageScrollViewDidGestureSwipeDown(imageScrollView: self)
|
||||
}
|
||||
}
|
||||
|
||||
private func zoomRectForScale(_ scale: CGFloat, center: CGPoint) -> CGRect {
|
||||
var zoomRect = CGRect.zero
|
||||
|
||||
// the zoom rect is in the content view's coordinates.
|
||||
// at a zoom scale of 1.0, it would be the size of the imageScrollView's bounds.
|
||||
// as the zoom scale decreases, so more content is visible, the size of the rect grows.
|
||||
zoomRect.size.height = frame.size.height / scale
|
||||
zoomRect.size.width = frame.size.width / scale
|
||||
|
||||
// choose an origin so as to get the right center.
|
||||
zoomRect.origin.x = center.x - (zoomRect.size.width / 2.0)
|
||||
zoomRect.origin.y = center.y - (zoomRect.size.height / 2.0)
|
||||
|
||||
return zoomRect
|
||||
}
|
||||
|
||||
open func refresh() {
|
||||
if let image = zoomView?.image {
|
||||
display(image: image)
|
||||
}
|
||||
}
|
||||
|
||||
open func resize() {
|
||||
self.configureImageForSize(self.imageSize)
|
||||
}
|
||||
}
|
||||
|
||||
extension ImageScrollView: UIScrollViewDelegate {
|
||||
|
||||
public func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||
imageScrollViewDelegate?.scrollViewDidScroll?(scrollView)
|
||||
}
|
||||
|
||||
public func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
|
||||
imageScrollViewDelegate?.scrollViewWillBeginDragging?(scrollView)
|
||||
}
|
||||
|
||||
public func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
|
||||
imageScrollViewDelegate?.scrollViewWillEndDragging?(scrollView, withVelocity: velocity, targetContentOffset: targetContentOffset)
|
||||
}
|
||||
|
||||
public func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
|
||||
imageScrollViewDelegate?.scrollViewDidEndDragging?(scrollView, willDecelerate: decelerate)
|
||||
}
|
||||
|
||||
public func scrollViewWillBeginDecelerating(_ scrollView: UIScrollView) {
|
||||
imageScrollViewDelegate?.scrollViewWillBeginDecelerating?(scrollView)
|
||||
}
|
||||
|
||||
public func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
|
||||
imageScrollViewDelegate?.scrollViewDidEndDecelerating?(scrollView)
|
||||
}
|
||||
|
||||
public func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
|
||||
imageScrollViewDelegate?.scrollViewDidEndScrollingAnimation?(scrollView)
|
||||
}
|
||||
|
||||
public func scrollViewWillBeginZooming(_ scrollView: UIScrollView, with view: UIView?) {
|
||||
imageScrollViewDelegate?.scrollViewWillBeginZooming?(scrollView, with: view)
|
||||
}
|
||||
|
||||
public func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) {
|
||||
imageScrollViewDelegate?.scrollViewDidEndZooming?(scrollView, with: view, atScale: scale)
|
||||
}
|
||||
|
||||
public func scrollViewShouldScrollToTop(_ scrollView: UIScrollView) -> Bool {
|
||||
return false
|
||||
}
|
||||
|
||||
@available(iOS 11.0, *)
|
||||
public func scrollViewDidChangeAdjustedContentInset(_ scrollView: UIScrollView) {
|
||||
imageScrollViewDelegate?.scrollViewDidChangeAdjustedContentInset?(scrollView)
|
||||
}
|
||||
|
||||
public func viewForZooming(in scrollView: UIScrollView) -> UIView? {
|
||||
return zoomView
|
||||
}
|
||||
|
||||
public func scrollViewDidZoom(_ scrollView: UIScrollView) {
|
||||
adjustFrameToCenter()
|
||||
imageScrollViewDelegate?.scrollViewDidZoom?(scrollView)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
//
|
||||
// ImageTransition.swift
|
||||
// Multiplatform iOS
|
||||
//
|
||||
// Created by Maurice Parker on 7/6/20.
|
||||
// Copyright © 2020 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
class ImageTransition: NSObject, UIViewControllerAnimatedTransitioning {
|
||||
|
||||
private weak var webViewController: WebViewController?
|
||||
private let duration = 0.4
|
||||
var presenting = true
|
||||
var originFrame: CGRect!
|
||||
var maskFrame: CGRect!
|
||||
var originImage: UIImage!
|
||||
|
||||
init(controller: WebViewController) {
|
||||
self.webViewController = controller
|
||||
}
|
||||
|
||||
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
|
||||
return duration
|
||||
}
|
||||
|
||||
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
|
||||
if presenting {
|
||||
animateTransitionPresenting(using: transitionContext)
|
||||
} else {
|
||||
animateTransitionReturning(using: transitionContext)
|
||||
}
|
||||
}
|
||||
|
||||
private func animateTransitionPresenting(using transitionContext: UIViewControllerContextTransitioning) {
|
||||
|
||||
let imageView = UIImageView(image: originImage)
|
||||
imageView.frame = originFrame
|
||||
|
||||
let fromView = transitionContext.view(forKey: .from)!
|
||||
fromView.removeFromSuperview()
|
||||
|
||||
transitionContext.containerView.backgroundColor = .systemBackground
|
||||
transitionContext.containerView.addSubview(imageView)
|
||||
|
||||
webViewController?.hideClickedImage()
|
||||
|
||||
UIView.animate(
|
||||
withDuration: duration,
|
||||
delay:0.0,
|
||||
usingSpringWithDamping: 0.8,
|
||||
initialSpringVelocity: 0.2,
|
||||
animations: {
|
||||
let imageController = transitionContext.viewController(forKey: .to) as! ImageViewController
|
||||
imageView.frame = imageController.zoomedFrame
|
||||
}, completion: { _ in
|
||||
imageView.removeFromSuperview()
|
||||
let toView = transitionContext.view(forKey: .to)!
|
||||
transitionContext.containerView.addSubview(toView)
|
||||
transitionContext.completeTransition(true)
|
||||
})
|
||||
}
|
||||
|
||||
private func animateTransitionReturning(using transitionContext: UIViewControllerContextTransitioning) {
|
||||
let imageController = transitionContext.viewController(forKey: .from) as! ImageViewController
|
||||
let imageView = UIImageView(image: originImage)
|
||||
imageView.frame = imageController.zoomedFrame
|
||||
|
||||
let fromView = transitionContext.view(forKey: .from)!
|
||||
let windowFrame = fromView.window!.frame
|
||||
fromView.removeFromSuperview()
|
||||
|
||||
let toView = transitionContext.view(forKey: .to)!
|
||||
transitionContext.containerView.addSubview(toView)
|
||||
|
||||
let maskingView = UIView()
|
||||
|
||||
let fullMaskFrame = CGRect(x: windowFrame.minX, y: maskFrame.minY, width: windowFrame.width, height: maskFrame.height)
|
||||
let path = UIBezierPath(rect: fullMaskFrame)
|
||||
let maskLayer = CAShapeLayer()
|
||||
maskLayer.path = path.cgPath
|
||||
maskingView.layer.mask = maskLayer
|
||||
|
||||
maskingView.addSubview(imageView)
|
||||
transitionContext.containerView.addSubview(maskingView)
|
||||
|
||||
UIView.animate(
|
||||
withDuration: duration,
|
||||
delay:0.0,
|
||||
usingSpringWithDamping: 0.8,
|
||||
initialSpringVelocity: 0.2,
|
||||
animations: {
|
||||
imageView.frame = self.originFrame
|
||||
}, completion: { _ in
|
||||
if let controller = self.webViewController {
|
||||
controller.showClickedImage() {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
imageView.removeFromSuperview()
|
||||
transitionContext.completeTransition(true)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
imageView.removeFromSuperview()
|
||||
transitionContext.completeTransition(true)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
//
|
||||
// ImageViewController.swift
|
||||
// Multiplatform iOS
|
||||
//
|
||||
// Created by Maurice Parker on 7/6/20.
|
||||
// Copyright © 2020 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
class ImageViewController: UIViewController {
|
||||
|
||||
@IBOutlet weak var closeButton: UIButton!
|
||||
@IBOutlet weak var shareButton: UIButton!
|
||||
@IBOutlet weak var imageScrollView: ImageScrollView!
|
||||
@IBOutlet weak var titleLabel: UILabel!
|
||||
@IBOutlet weak var titleBackground: UIVisualEffectView!
|
||||
@IBOutlet weak var titleLeading: NSLayoutConstraint!
|
||||
@IBOutlet weak var titleTrailing: NSLayoutConstraint!
|
||||
|
||||
var image: UIImage!
|
||||
var imageTitle: String?
|
||||
var zoomedFrame: CGRect {
|
||||
return imageScrollView.zoomedFrame
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
closeButton.imageView?.contentMode = .scaleAspectFit
|
||||
closeButton.accessibilityLabel = NSLocalizedString("Close", comment: "Close")
|
||||
shareButton.accessibilityLabel = NSLocalizedString("Share", comment: "Share")
|
||||
|
||||
imageScrollView.setup()
|
||||
imageScrollView.imageScrollViewDelegate = self
|
||||
imageScrollView.imageContentMode = .aspectFit
|
||||
imageScrollView.initialOffset = .center
|
||||
imageScrollView.display(image: image)
|
||||
|
||||
titleLabel.text = imageTitle ?? ""
|
||||
layoutTitleLabel()
|
||||
|
||||
guard imageTitle != "" else {
|
||||
titleBackground.removeFromSuperview()
|
||||
return
|
||||
}
|
||||
titleBackground.layer.cornerRadius = 6
|
||||
}
|
||||
|
||||
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
|
||||
super.viewWillTransition(to: size, with: coordinator)
|
||||
coordinator.animate(alongsideTransition: { [weak self] context in
|
||||
self?.imageScrollView.resize()
|
||||
})
|
||||
}
|
||||
|
||||
@IBAction func share(_ sender: Any) {
|
||||
guard let image = image else { return }
|
||||
let activityViewController = UIActivityViewController(activityItems: [image], applicationActivities: nil)
|
||||
activityViewController.popoverPresentationController?.sourceView = shareButton
|
||||
activityViewController.popoverPresentationController?.sourceRect = shareButton.bounds
|
||||
present(activityViewController, animated: true)
|
||||
}
|
||||
|
||||
@IBAction func done(_ sender: Any) {
|
||||
dismiss(animated: true)
|
||||
}
|
||||
|
||||
private func layoutTitleLabel(){
|
||||
let width = view.frame.width
|
||||
let multiplier = UIDevice.current.userInterfaceIdiom == .pad ? CGFloat(0.1) : CGFloat(0.04)
|
||||
titleLeading.constant += width * multiplier
|
||||
titleTrailing.constant -= width * multiplier
|
||||
titleLabel.layoutIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: ImageScrollViewDelegate
|
||||
|
||||
extension ImageViewController: ImageScrollViewDelegate {
|
||||
|
||||
func imageScrollViewDidGestureSwipeUp(imageScrollView: ImageScrollView) {
|
||||
dismiss(animated: true)
|
||||
}
|
||||
|
||||
func imageScrollViewDidGestureSwipeDown(imageScrollView: ImageScrollView) {
|
||||
dismiss(animated: true)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
//
|
||||
// OpenInSafariActivity.swift
|
||||
// Multiplatform iOS
|
||||
//
|
||||
// Created by Maurice Parker on 7/13/20.
|
||||
// Copyright © 2020 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
class OpenInSafariActivity: UIActivity {
|
||||
|
||||
private var activityItems: [Any]?
|
||||
|
||||
override var activityTitle: String? {
|
||||
return NSLocalizedString("Open in Safari", comment: "Open in Safari")
|
||||
}
|
||||
|
||||
override var activityImage: UIImage? {
|
||||
return UIImage(systemName: "safari", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .regular))
|
||||
}
|
||||
|
||||
override var activityType: UIActivity.ActivityType? {
|
||||
return UIActivity.ActivityType(rawValue: "com.rancharo.NetNewsWire-Evergreen.safari")
|
||||
}
|
||||
|
||||
override class var activityCategory: UIActivity.Category {
|
||||
return .action
|
||||
}
|
||||
|
||||
override func canPerform(withActivityItems activityItems: [Any]) -> Bool {
|
||||
return true
|
||||
}
|
||||
|
||||
override func prepare(withActivityItems activityItems: [Any]) {
|
||||
self.activityItems = activityItems
|
||||
}
|
||||
|
||||
override func perform() {
|
||||
guard let url = activityItems?.first(where: { $0 is URL }) as? URL else {
|
||||
activityDidFinish(false)
|
||||
return
|
||||
}
|
||||
|
||||
UIApplication.shared.open(url, options: [:], completionHandler: nil)
|
||||
activityDidFinish(true)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
//
|
||||
// TitleActivityItemSource.swift
|
||||
// Multiplatform iOS
|
||||
//
|
||||
// Created by Maurice Parker on 7/13/20.
|
||||
// Copyright © 2020 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
class TitleActivityItemSource: NSObject, UIActivityItemSource {
|
||||
|
||||
private let title: String?
|
||||
|
||||
init(title: String?) {
|
||||
self.title = title
|
||||
}
|
||||
|
||||
func activityViewControllerPlaceholderItem(_ activityViewController: UIActivityViewController) -> Any {
|
||||
return title as Any
|
||||
}
|
||||
|
||||
func activityViewController(_ activityViewController: UIActivityViewController, itemForActivityType activityType: UIActivity.ActivityType?) -> Any? {
|
||||
guard let activityType = activityType,
|
||||
let title = title else {
|
||||
return NSNull()
|
||||
}
|
||||
|
||||
switch activityType.rawValue {
|
||||
case "com.omnigroup.OmniFocus3.iOS.QuickEntry",
|
||||
"com.culturedcode.ThingsiPhone.ShareExtension",
|
||||
"com.tapbots.Tweetbot4.shareextension",
|
||||
"com.tapbots.Tweetbot6.shareextension",
|
||||
"com.buffer.buffer.Buffer":
|
||||
return title
|
||||
default:
|
||||
return NSNull()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,765 +0,0 @@
|
||||
//
|
||||
// WebViewController.swift
|
||||
// Multiplatform iOS
|
||||
//
|
||||
// Created by Maurice Parker on 7/6/20.
|
||||
// Copyright © 2020 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import WebKit
|
||||
import RSCore
|
||||
import Account
|
||||
import Articles
|
||||
import SafariServices
|
||||
import MessageUI
|
||||
|
||||
protocol WebViewControllerDelegate: AnyObject {
|
||||
func webViewController(_: WebViewController, articleExtractorButtonStateDidUpdate: ArticleExtractorButtonState)
|
||||
}
|
||||
|
||||
class WebViewController: UIViewController {
|
||||
|
||||
private struct MessageName {
|
||||
static let imageWasClicked = "imageWasClicked"
|
||||
static let imageWasShown = "imageWasShown"
|
||||
static let showFeedInspector = "showFeedInspector"
|
||||
}
|
||||
|
||||
private var topShowBarsView: UIView!
|
||||
private var bottomShowBarsView: UIView!
|
||||
private var topShowBarsViewConstraint: NSLayoutConstraint!
|
||||
private var bottomShowBarsViewConstraint: NSLayoutConstraint!
|
||||
|
||||
private var webView: PreloadedWebView? {
|
||||
guard view.subviews.count > 0 else { return nil }
|
||||
return view.subviews[0] as? PreloadedWebView
|
||||
}
|
||||
|
||||
// private lazy var contextMenuInteraction = UIContextMenuInteraction(delegate: self)
|
||||
private var isFullScreenAvailable: Bool {
|
||||
return AppDefaults.shared.articleFullscreenAvailable && traitCollection.userInterfaceIdiom == .phone // && coordinator.isRootSplitCollapsed
|
||||
}
|
||||
private lazy var transition = ImageTransition(controller: self)
|
||||
private var clickedImageCompletion: (() -> Void)?
|
||||
|
||||
private var articleExtractor: ArticleExtractor? = nil
|
||||
var extractedArticle: ExtractedArticle? {
|
||||
didSet {
|
||||
windowScrollY = 0
|
||||
}
|
||||
}
|
||||
var isShowingExtractedArticle = false
|
||||
|
||||
var articleExtractorButtonState: ArticleExtractorButtonState = .off {
|
||||
didSet {
|
||||
delegate?.webViewController(self, articleExtractorButtonStateDidUpdate: articleExtractorButtonState)
|
||||
}
|
||||
}
|
||||
|
||||
var sceneModel: SceneModel?
|
||||
weak var delegate: WebViewControllerDelegate?
|
||||
|
||||
private(set) var article: Article?
|
||||
|
||||
let scrollPositionQueue = CoalescingQueue(name: "Article Scroll Position", interval: 0.3, maxInterval: 0.3)
|
||||
var windowScrollY = 0
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(webFeedIconDidBecomeAvailable(_:)), name: .WebFeedIconDidBecomeAvailable, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(avatarDidBecomeAvailable(_:)), name: .AvatarDidBecomeAvailable, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(faviconDidBecomeAvailable(_:)), name: .FaviconDidBecomeAvailable, object: nil)
|
||||
|
||||
// Configure the tap zones
|
||||
// configureTopShowBarsView()
|
||||
// configureBottomShowBarsView()
|
||||
|
||||
loadWebView()
|
||||
|
||||
}
|
||||
|
||||
// MARK: Notifications
|
||||
|
||||
@objc func webFeedIconDidBecomeAvailable(_ note: Notification) {
|
||||
reloadArticleImage()
|
||||
}
|
||||
|
||||
@objc func avatarDidBecomeAvailable(_ note: Notification) {
|
||||
reloadArticleImage()
|
||||
}
|
||||
|
||||
@objc func faviconDidBecomeAvailable(_ note: Notification) {
|
||||
reloadArticleImage()
|
||||
}
|
||||
|
||||
// MARK: Actions
|
||||
|
||||
// @objc func showBars(_ sender: Any) {
|
||||
// showBars()
|
||||
// }
|
||||
|
||||
// MARK: API
|
||||
|
||||
func setArticle(_ article: Article?, updateView: Bool = true) {
|
||||
stopArticleExtractor()
|
||||
|
||||
if article != self.article {
|
||||
self.article = article
|
||||
if updateView {
|
||||
if article?.webFeed?.isArticleExtractorAlwaysOn ?? false {
|
||||
startArticleExtractor()
|
||||
}
|
||||
windowScrollY = 0
|
||||
loadWebView()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func focus() {
|
||||
webView?.becomeFirstResponder()
|
||||
}
|
||||
|
||||
func canScrollDown() -> Bool {
|
||||
guard let webView = webView else { return false }
|
||||
return webView.scrollView.contentOffset.y < finalScrollPosition()
|
||||
}
|
||||
|
||||
func scrollPageDown() {
|
||||
guard let webView = webView else { return }
|
||||
|
||||
let overlap = 2 * UIFont.systemFont(ofSize: UIFont.systemFontSize).lineHeight * UIScreen.main.scale
|
||||
let scrollToY: CGFloat = {
|
||||
let fullScroll = webView.scrollView.contentOffset.y + webView.scrollView.layoutMarginsGuide.layoutFrame.height - overlap
|
||||
let final = finalScrollPosition()
|
||||
return fullScroll < final ? fullScroll : final
|
||||
}()
|
||||
|
||||
let convertedPoint = self.view.convert(CGPoint(x: 0, y: 0), to: webView.scrollView)
|
||||
let scrollToPoint = CGPoint(x: convertedPoint.x, y: scrollToY)
|
||||
webView.scrollView.setContentOffset(scrollToPoint, animated: true)
|
||||
}
|
||||
|
||||
func hideClickedImage() {
|
||||
webView?.evaluateJavaScript("hideClickedImage();")
|
||||
}
|
||||
|
||||
func showClickedImage(completion: @escaping () -> Void) {
|
||||
clickedImageCompletion = completion
|
||||
webView?.evaluateJavaScript("showClickedImage();")
|
||||
}
|
||||
|
||||
func fullReload() {
|
||||
loadWebView(replaceExistingWebView: true)
|
||||
}
|
||||
|
||||
// func showBars() {
|
||||
// AppDefaults.shared.articleFullscreenEnabled = false
|
||||
// coordinator.showStatusBar()
|
||||
// topShowBarsViewConstraint?.constant = 0
|
||||
// bottomShowBarsViewConstraint?.constant = 0
|
||||
// navigationController?.setNavigationBarHidden(false, animated: true)
|
||||
// navigationController?.setToolbarHidden(false, animated: true)
|
||||
// configureContextMenuInteraction()
|
||||
// }
|
||||
//
|
||||
// func hideBars() {
|
||||
// if isFullScreenAvailable {
|
||||
// AppDefaults.shared.articleFullscreenEnabled = true
|
||||
// coordinator.hideStatusBar()
|
||||
// topShowBarsViewConstraint?.constant = -44.0
|
||||
// bottomShowBarsViewConstraint?.constant = 44.0
|
||||
// navigationController?.setNavigationBarHidden(true, animated: true)
|
||||
// navigationController?.setToolbarHidden(true, animated: true)
|
||||
// configureContextMenuInteraction()
|
||||
// }
|
||||
// }
|
||||
|
||||
func toggleArticleExtractor() {
|
||||
|
||||
guard let article = article else {
|
||||
return
|
||||
}
|
||||
|
||||
guard articleExtractor?.state != .processing else {
|
||||
stopArticleExtractor()
|
||||
loadWebView()
|
||||
return
|
||||
}
|
||||
|
||||
guard !isShowingExtractedArticle else {
|
||||
isShowingExtractedArticle = false
|
||||
loadWebView()
|
||||
articleExtractorButtonState = .off
|
||||
return
|
||||
}
|
||||
|
||||
if let articleExtractor = articleExtractor {
|
||||
if article.preferredLink == articleExtractor.articleLink {
|
||||
isShowingExtractedArticle = true
|
||||
loadWebView()
|
||||
articleExtractorButtonState = .on
|
||||
}
|
||||
} else {
|
||||
startArticleExtractor()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func stopArticleExtractorIfProcessing() {
|
||||
if articleExtractor?.state == .processing {
|
||||
stopArticleExtractor()
|
||||
}
|
||||
}
|
||||
|
||||
func stopWebViewActivity() {
|
||||
if let webView = webView {
|
||||
stopMediaPlayback(webView)
|
||||
cancelImageLoad(webView)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: ArticleExtractorDelegate
|
||||
|
||||
extension WebViewController: ArticleExtractorDelegate {
|
||||
|
||||
func articleExtractionDidFail(with: Error) {
|
||||
stopArticleExtractor()
|
||||
articleExtractorButtonState = .error
|
||||
loadWebView()
|
||||
}
|
||||
|
||||
func articleExtractionDidComplete(extractedArticle: ExtractedArticle) {
|
||||
if articleExtractor?.state != .cancelled {
|
||||
self.extractedArticle = extractedArticle
|
||||
isShowingExtractedArticle = true
|
||||
loadWebView()
|
||||
articleExtractorButtonState = .on
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: UIContextMenuInteractionDelegate
|
||||
|
||||
//extension WebViewController: UIContextMenuInteractionDelegate {
|
||||
// func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? {
|
||||
//
|
||||
// return UIContextMenuConfiguration(identifier: nil, previewProvider: contextMenuPreviewProvider) { [weak self] suggestedActions in
|
||||
// guard let self = self else { return nil }
|
||||
// var actions = [UIAction]()
|
||||
//
|
||||
// if let action = self.prevArticleAction() {
|
||||
// actions.append(action)
|
||||
// }
|
||||
// if let action = self.nextArticleAction() {
|
||||
// actions.append(action)
|
||||
// }
|
||||
// if let action = self.toggleReadAction() {
|
||||
// actions.append(action)
|
||||
// }
|
||||
// actions.append(self.toggleStarredAction())
|
||||
// if let action = self.nextUnreadArticleAction() {
|
||||
// actions.append(action)
|
||||
// }
|
||||
// actions.append(self.toggleArticleExtractorAction())
|
||||
// actions.append(self.shareAction())
|
||||
//
|
||||
// return UIMenu(title: "", children: actions)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
|
||||
// coordinator.showBrowserForCurrentArticle()
|
||||
// }
|
||||
//
|
||||
//}
|
||||
|
||||
// MARK: WKNavigationDelegate
|
||||
|
||||
extension WebViewController: WKNavigationDelegate {
|
||||
|
||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
for (index, view) in view.subviews.enumerated() {
|
||||
if index != 0, let oldWebView = view as? PreloadedWebView {
|
||||
oldWebView.removeFromSuperview()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
||||
|
||||
if navigationAction.navigationType == .linkActivated {
|
||||
guard let url = navigationAction.request.url else {
|
||||
decisionHandler(.allow)
|
||||
return
|
||||
}
|
||||
|
||||
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
||||
if components?.scheme == "http" || components?.scheme == "https" {
|
||||
decisionHandler(.cancel)
|
||||
|
||||
// If the resource cannot be opened with an installed app, present the web view.
|
||||
UIApplication.shared.open(url, options: [.universalLinksOnly: true]) { didOpen in
|
||||
assert(Thread.isMainThread)
|
||||
guard didOpen == false else {
|
||||
return
|
||||
}
|
||||
let vc = SFSafariViewController(url: url)
|
||||
self.present(vc, animated: true)
|
||||
}
|
||||
} else if components?.scheme == "mailto" {
|
||||
decisionHandler(.cancel)
|
||||
|
||||
guard let emailAddress = url.percentEncodedEmailAddress else {
|
||||
return
|
||||
}
|
||||
|
||||
if UIApplication.shared.canOpenURL(emailAddress) {
|
||||
UIApplication.shared.open(emailAddress, options: [.universalLinksOnly : false], completionHandler: nil)
|
||||
} else {
|
||||
let alert = UIAlertController(title: NSLocalizedString("Error", comment: "Error"), message: NSLocalizedString("This device cannot send emails.", comment: "This device cannot send emails."), preferredStyle: .alert)
|
||||
alert.addAction(.init(title: NSLocalizedString("Dismiss", comment: "Dismiss"), style: .cancel, handler: nil))
|
||||
self.present(alert, animated: true, completion: nil)
|
||||
}
|
||||
} else if components?.scheme == "tel" {
|
||||
decisionHandler(.cancel)
|
||||
|
||||
if UIApplication.shared.canOpenURL(url) {
|
||||
UIApplication.shared.open(url, options: [.universalLinksOnly : false], completionHandler: nil)
|
||||
}
|
||||
|
||||
} else {
|
||||
decisionHandler(.allow)
|
||||
}
|
||||
} else {
|
||||
decisionHandler(.allow)
|
||||
}
|
||||
}
|
||||
|
||||
func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
|
||||
fullReload()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: WKUIDelegate
|
||||
|
||||
extension WebViewController: WKUIDelegate {
|
||||
func webView(_ webView: WKWebView, contextMenuForElement elementInfo: WKContextMenuElementInfo, willCommitWithAnimator animator: UIContextMenuInteractionCommitAnimating) {
|
||||
// We need to have at least an unimplemented WKUIDelegate assigned to the WKWebView. This makes the
|
||||
// link preview launch Safari when the link preview is tapped. In theory, you shoud be able to get
|
||||
// the link from the elementInfo above and transition to SFSafariViewController instead of launching
|
||||
// Safari. As the time of this writing, the link in elementInfo is always nil. ¯\_(ツ)_/¯
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: WKScriptMessageHandler
|
||||
|
||||
extension WebViewController: WKScriptMessageHandler {
|
||||
|
||||
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
|
||||
switch message.name {
|
||||
case MessageName.imageWasShown:
|
||||
clickedImageCompletion?()
|
||||
case MessageName.imageWasClicked:
|
||||
imageWasClicked(body: message.body as? String)
|
||||
case MessageName.showFeedInspector:
|
||||
return
|
||||
// if let webFeed = article?.webFeed {
|
||||
// coordinator.showFeedInspector(for: webFeed)
|
||||
// }
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: UIViewControllerTransitioningDelegate
|
||||
|
||||
extension WebViewController: UIViewControllerTransitioningDelegate {
|
||||
|
||||
func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
|
||||
transition.presenting = true
|
||||
return transition
|
||||
}
|
||||
|
||||
func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
|
||||
transition.presenting = false
|
||||
return transition
|
||||
}
|
||||
}
|
||||
|
||||
// MARK:
|
||||
|
||||
extension WebViewController: UIScrollViewDelegate {
|
||||
|
||||
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||
scrollPositionQueue.add(self, #selector(scrollPositionDidChange))
|
||||
}
|
||||
|
||||
@objc func scrollPositionDidChange() {
|
||||
webView?.evaluateJavaScript("window.scrollY") { (scrollY, error) in
|
||||
guard error == nil else { return }
|
||||
let javascriptScrollY = scrollY as? Int ?? 0
|
||||
// I don't know why this value gets returned sometimes, but it is in error
|
||||
guard javascriptScrollY != 33554432 else { return }
|
||||
self.windowScrollY = javascriptScrollY
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: JSON
|
||||
|
||||
private struct ImageClickMessage: Codable {
|
||||
let x: Float
|
||||
let y: Float
|
||||
let width: Float
|
||||
let height: Float
|
||||
let imageTitle: String?
|
||||
let imageURL: String
|
||||
}
|
||||
|
||||
// MARK: Private
|
||||
|
||||
private extension WebViewController {
|
||||
|
||||
func loadWebView(replaceExistingWebView: Bool = false) {
|
||||
guard isViewLoaded else { return }
|
||||
|
||||
if !replaceExistingWebView, let webView = webView {
|
||||
self.renderPage(webView)
|
||||
return
|
||||
}
|
||||
|
||||
sceneModel?.webViewProvider?.dequeueWebView() { webView in
|
||||
|
||||
webView.ready {
|
||||
|
||||
// Add the webview
|
||||
webView.translatesAutoresizingMaskIntoConstraints = false
|
||||
self.view.insertSubview(webView, at: 0)
|
||||
NSLayoutConstraint.activate([
|
||||
self.view.leadingAnchor.constraint(equalTo: webView.leadingAnchor),
|
||||
self.view.trailingAnchor.constraint(equalTo: webView.trailingAnchor),
|
||||
self.view.topAnchor.constraint(equalTo: webView.topAnchor),
|
||||
self.view.bottomAnchor.constraint(equalTo: webView.bottomAnchor)
|
||||
])
|
||||
|
||||
// UISplitViewController reports the wrong size to WKWebView which can cause horizontal
|
||||
// rubberbanding on the iPad. This interferes with our UIPageViewController preventing
|
||||
// us from easily swiping between WKWebViews. This hack fixes that.
|
||||
webView.scrollView.contentInset = UIEdgeInsets(top: 0, left: -1, bottom: 0, right: 0)
|
||||
|
||||
webView.scrollView.setZoomScale(1.0, animated: false)
|
||||
|
||||
self.view.setNeedsLayout()
|
||||
self.view.layoutIfNeeded()
|
||||
|
||||
// Configure the webview
|
||||
webView.navigationDelegate = self
|
||||
webView.uiDelegate = self
|
||||
webView.scrollView.delegate = self
|
||||
// self.configureContextMenuInteraction()
|
||||
|
||||
webView.configuration.userContentController.add(WrapperScriptMessageHandler(self), name: MessageName.imageWasClicked)
|
||||
webView.configuration.userContentController.add(WrapperScriptMessageHandler(self), name: MessageName.imageWasShown)
|
||||
webView.configuration.userContentController.add(WrapperScriptMessageHandler(self), name: MessageName.showFeedInspector)
|
||||
|
||||
self.renderPage(webView)
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func renderPage(_ webView: PreloadedWebView?) {
|
||||
guard let webView = webView else { return }
|
||||
|
||||
let theme = ArticleThemesManager.shared.currentTheme
|
||||
let rendering: ArticleRenderer.Rendering
|
||||
|
||||
if let articleExtractor = articleExtractor, articleExtractor.state == .processing {
|
||||
rendering = ArticleRenderer.loadingHTML(theme: theme)
|
||||
} else if let articleExtractor = articleExtractor, articleExtractor.state == .failedToParse, let article = article {
|
||||
rendering = ArticleRenderer.articleHTML(article: article, theme: theme)
|
||||
} else if let article = article, let extractedArticle = extractedArticle {
|
||||
if isShowingExtractedArticle {
|
||||
rendering = ArticleRenderer.articleHTML(article: article, extractedArticle: extractedArticle, theme: theme)
|
||||
} else {
|
||||
rendering = ArticleRenderer.articleHTML(article: article, theme: theme)
|
||||
}
|
||||
} else if let article = article {
|
||||
rendering = ArticleRenderer.articleHTML(article: article, theme: theme)
|
||||
} else {
|
||||
rendering = ArticleRenderer.noSelectionHTML(theme: theme)
|
||||
}
|
||||
|
||||
let substitutions = [
|
||||
"title": rendering.title,
|
||||
"baseURL": rendering.baseURL,
|
||||
"style": rendering.style,
|
||||
"body": rendering.html,
|
||||
"windowScrollY": String(windowScrollY)
|
||||
]
|
||||
|
||||
let html = try! MacroProcessor.renderedText(withTemplate: ArticleRenderer.page.html, substitutions: substitutions)
|
||||
webView.loadHTMLString(html, baseURL: ArticleRenderer.page.baseURL)
|
||||
|
||||
}
|
||||
|
||||
func finalScrollPosition() -> CGFloat {
|
||||
guard let webView = webView else { return 0 }
|
||||
return webView.scrollView.contentSize.height - webView.scrollView.bounds.height + webView.scrollView.safeAreaInsets.bottom
|
||||
}
|
||||
|
||||
func startArticleExtractor() {
|
||||
if let link = article?.preferredLink, let extractor = ArticleExtractor(link) {
|
||||
extractor.delegate = self
|
||||
extractor.process()
|
||||
articleExtractor = extractor
|
||||
articleExtractorButtonState = .animated
|
||||
}
|
||||
}
|
||||
|
||||
func stopArticleExtractor() {
|
||||
articleExtractor?.cancel()
|
||||
articleExtractor = nil
|
||||
isShowingExtractedArticle = false
|
||||
articleExtractorButtonState = .off
|
||||
}
|
||||
|
||||
func reloadArticleImage() {
|
||||
guard let article = article else { return }
|
||||
|
||||
var components = URLComponents()
|
||||
components.scheme = ArticleRenderer.imageIconScheme
|
||||
components.path = article.articleID
|
||||
|
||||
if let imageSrc = components.string {
|
||||
webView?.evaluateJavaScript("reloadArticleImage(\"\(imageSrc)\")")
|
||||
}
|
||||
}
|
||||
|
||||
func imageWasClicked(body: String?) {
|
||||
guard let webView = webView,
|
||||
let body = body,
|
||||
let data = body.data(using: .utf8),
|
||||
let clickMessage = try? JSONDecoder().decode(ImageClickMessage.self, from: data),
|
||||
let range = clickMessage.imageURL.range(of: ";base64,")
|
||||
else { return }
|
||||
|
||||
let base64Image = String(clickMessage.imageURL.suffix(from: range.upperBound))
|
||||
if let imageData = Data(base64Encoded: base64Image), let image = UIImage(data: imageData) {
|
||||
|
||||
let y = CGFloat(clickMessage.y) + webView.safeAreaInsets.top
|
||||
let rect = CGRect(x: CGFloat(clickMessage.x), y: y, width: CGFloat(clickMessage.width), height: CGFloat(clickMessage.height))
|
||||
transition.originFrame = webView.convert(rect, to: nil)
|
||||
|
||||
if navigationController?.navigationBar.isHidden ?? false {
|
||||
transition.maskFrame = webView.convert(webView.frame, to: nil)
|
||||
} else {
|
||||
transition.maskFrame = webView.convert(webView.safeAreaLayoutGuide.layoutFrame, to: nil)
|
||||
}
|
||||
|
||||
transition.originImage = image
|
||||
|
||||
// coordinator.showFullScreenImage(image: image, imageTitle: clickMessage.imageTitle, transitioningDelegate: self)
|
||||
}
|
||||
}
|
||||
|
||||
func stopMediaPlayback(_ webView: WKWebView) {
|
||||
webView.evaluateJavaScript("stopMediaPlayback();")
|
||||
}
|
||||
|
||||
func cancelImageLoad(_ webView: WKWebView) {
|
||||
webView.evaluateJavaScript("cancelImageLoad();")
|
||||
}
|
||||
|
||||
// func configureTopShowBarsView() {
|
||||
// topShowBarsView = UIView()
|
||||
// topShowBarsView.backgroundColor = .clear
|
||||
// topShowBarsView.translatesAutoresizingMaskIntoConstraints = false
|
||||
// view.addSubview(topShowBarsView)
|
||||
//
|
||||
// if AppDefaults.shared.articleFullscreenEnabled {
|
||||
// topShowBarsViewConstraint = view.topAnchor.constraint(equalTo: topShowBarsView.bottomAnchor, constant: -44.0)
|
||||
// } else {
|
||||
// topShowBarsViewConstraint = view.topAnchor.constraint(equalTo: topShowBarsView.bottomAnchor, constant: 0.0)
|
||||
// }
|
||||
//
|
||||
// NSLayoutConstraint.activate([
|
||||
// topShowBarsViewConstraint,
|
||||
// view.leadingAnchor.constraint(equalTo: topShowBarsView.leadingAnchor),
|
||||
// view.trailingAnchor.constraint(equalTo: topShowBarsView.trailingAnchor),
|
||||
// topShowBarsView.heightAnchor.constraint(equalToConstant: 44.0)
|
||||
// ])
|
||||
// topShowBarsView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(showBars(_:))))
|
||||
// }
|
||||
//
|
||||
// func configureBottomShowBarsView() {
|
||||
// bottomShowBarsView = UIView()
|
||||
// topShowBarsView.backgroundColor = .clear
|
||||
// bottomShowBarsView.translatesAutoresizingMaskIntoConstraints = false
|
||||
// view.addSubview(bottomShowBarsView)
|
||||
// if AppDefaults.shared.articleFullscreenEnabled {
|
||||
// bottomShowBarsViewConstraint = view.bottomAnchor.constraint(equalTo: bottomShowBarsView.topAnchor, constant: 44.0)
|
||||
// } else {
|
||||
// bottomShowBarsViewConstraint = view.bottomAnchor.constraint(equalTo: bottomShowBarsView.topAnchor, constant: 0.0)
|
||||
// }
|
||||
// NSLayoutConstraint.activate([
|
||||
// bottomShowBarsViewConstraint,
|
||||
// view.leadingAnchor.constraint(equalTo: bottomShowBarsView.leadingAnchor),
|
||||
// view.trailingAnchor.constraint(equalTo: bottomShowBarsView.trailingAnchor),
|
||||
// bottomShowBarsView.heightAnchor.constraint(equalToConstant: 44.0)
|
||||
// ])
|
||||
// bottomShowBarsView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(showBars(_:))))
|
||||
// }
|
||||
|
||||
// func configureContextMenuInteraction() {
|
||||
// if isFullScreenAvailable {
|
||||
// if navigationController?.isNavigationBarHidden ?? false {
|
||||
// webView?.addInteraction(contextMenuInteraction)
|
||||
// } else {
|
||||
// webView?.removeInteraction(contextMenuInteraction)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// func contextMenuPreviewProvider() -> UIViewController {
|
||||
// let previewProvider = UIStoryboard.main.instantiateController(ofType: ContextMenuPreviewViewController.self)
|
||||
// previewProvider.article = article
|
||||
// return previewProvider
|
||||
// }
|
||||
//
|
||||
// func prevArticleAction() -> UIAction? {
|
||||
// guard coordinator.isPrevArticleAvailable else { return nil }
|
||||
// let title = NSLocalizedString("Previous Article", comment: "Previous Article")
|
||||
// return UIAction(title: title, image: AppAssets.prevArticleImage) { [weak self] action in
|
||||
// self?.coordinator.selectPrevArticle()
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// func nextArticleAction() -> UIAction? {
|
||||
// guard coordinator.isNextArticleAvailable else { return nil }
|
||||
// let title = NSLocalizedString("Next Article", comment: "Next Article")
|
||||
// return UIAction(title: title, image: AppAssets.nextArticleImage) { [weak self] action in
|
||||
// self?.coordinator.selectNextArticle()
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// func toggleReadAction() -> UIAction? {
|
||||
// guard let article = article, !article.status.read || article.isAvailableToMarkUnread else { return nil }
|
||||
//
|
||||
// let title = article.status.read ? NSLocalizedString("Mark as Unread", comment: "Mark as Unread") : NSLocalizedString("Mark as Read", comment: "Mark as Read")
|
||||
// let readImage = article.status.read ? AppAssets.circleClosedImage : AppAssets.circleOpenImage
|
||||
// return UIAction(title: title, image: readImage) { [weak self] action in
|
||||
// self?.coordinator.toggleReadForCurrentArticle()
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// func toggleStarredAction() -> UIAction {
|
||||
// let starred = article?.status.starred ?? false
|
||||
// let title = starred ? NSLocalizedString("Mark as Unstarred", comment: "Mark as Unstarred") : NSLocalizedString("Mark as Starred", comment: "Mark as Starred")
|
||||
// let starredImage = starred ? AppAssets.starOpenImage : AppAssets.starClosedImage
|
||||
// return UIAction(title: title, image: starredImage) { [weak self] action in
|
||||
// self?.coordinator.toggleStarredForCurrentArticle()
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// func nextUnreadArticleAction() -> UIAction? {
|
||||
// guard coordinator.isAnyUnreadAvailable else { return nil }
|
||||
// let title = NSLocalizedString("Next Unread Article", comment: "Next Unread Article")
|
||||
// return UIAction(title: title, image: AppAssets.nextUnreadArticleImage) { [weak self] action in
|
||||
// self?.coordinator.selectNextUnread()
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// func toggleArticleExtractorAction() -> UIAction {
|
||||
// let extracted = articleExtractorButtonState == .on
|
||||
// let title = extracted ? NSLocalizedString("Show Feed Article", comment: "Show Feed Article") : NSLocalizedString("Show Reader View", comment: "Show Reader View")
|
||||
// let extractorImage = extracted ? AppAssets.articleExtractorOffSF : AppAssets.articleExtractorOnSF
|
||||
// return UIAction(title: title, image: extractorImage) { [weak self] action in
|
||||
// self?.toggleArticleExtractor()
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// func shareAction() -> UIAction {
|
||||
// let title = NSLocalizedString("Share", comment: "Share")
|
||||
// return UIAction(title: title, image: AppAssets.shareImage) { [weak self] action in
|
||||
// self?.showActivityDialog()
|
||||
// }
|
||||
// }
|
||||
|
||||
}
|
||||
|
||||
// MARK: Find in Article
|
||||
|
||||
private struct FindInArticleOptions: Codable {
|
||||
var text: String
|
||||
var caseSensitive = false
|
||||
var regex = false
|
||||
}
|
||||
|
||||
internal struct FindInArticleState: Codable {
|
||||
struct WebViewClientRect: Codable {
|
||||
let x: Double
|
||||
let y: Double
|
||||
let width: Double
|
||||
let height: Double
|
||||
}
|
||||
|
||||
struct FindInArticleResult: Codable {
|
||||
let rects: [WebViewClientRect]
|
||||
let bounds: WebViewClientRect
|
||||
let index: UInt
|
||||
let matchGroups: [String]
|
||||
}
|
||||
|
||||
let index: UInt?
|
||||
let results: [FindInArticleResult]
|
||||
let count: UInt
|
||||
}
|
||||
|
||||
extension WebViewController {
|
||||
|
||||
func searchText(_ searchText: String, completionHandler: @escaping (FindInArticleState) -> Void) {
|
||||
guard let json = try? JSONEncoder().encode(FindInArticleOptions(text: searchText)) else {
|
||||
return
|
||||
}
|
||||
let encoded = json.base64EncodedString()
|
||||
|
||||
webView?.evaluateJavaScript("updateFind(\"\(encoded)\")") {
|
||||
(result, error) in
|
||||
guard error == nil,
|
||||
let b64 = result as? String,
|
||||
let rawData = Data(base64Encoded: b64),
|
||||
let findState = try? JSONDecoder().decode(FindInArticleState.self, from: rawData) else {
|
||||
return
|
||||
}
|
||||
|
||||
completionHandler(findState)
|
||||
}
|
||||
}
|
||||
|
||||
func endSearch() {
|
||||
webView?.evaluateJavaScript("endFind()")
|
||||
}
|
||||
|
||||
func selectNextSearchResult() {
|
||||
webView?.evaluateJavaScript("selectNextResult()")
|
||||
}
|
||||
|
||||
func selectPreviousSearchResult() {
|
||||
webView?.evaluateJavaScript("selectPreviousResult()")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
//
|
||||
// AttributedStringView.swift
|
||||
// NetNewsWire-iOS
|
||||
//
|
||||
// Created by Maurice Parker on 9/16/19.
|
||||
// Copyright © 2019 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct AttributedStringView: UIViewRepresentable {
|
||||
|
||||
let string: NSAttributedString
|
||||
let preferredMaxLayoutWidth: CGFloat
|
||||
|
||||
func makeUIView(context: Context) -> HackedTextView {
|
||||
return HackedTextView()
|
||||
}
|
||||
|
||||
func updateUIView(_ view: HackedTextView, context: Context) {
|
||||
view.attributedText = string
|
||||
|
||||
view.preferredMaxLayoutWidth = preferredMaxLayoutWidth
|
||||
view.isScrollEnabled = false
|
||||
view.textContainer.lineBreakMode = .byWordWrapping
|
||||
|
||||
view.isUserInteractionEnabled = true
|
||||
view.adjustsFontForContentSizeCategory = true
|
||||
view.font = .preferredFont(forTextStyle: .body)
|
||||
view.textColor = UIColor.label
|
||||
view.tintColor = AppAssets.accentColor
|
||||
view.backgroundColor = UIColor.secondarySystemGroupedBackground
|
||||
|
||||
view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||
view.setContentCompressionResistancePriority(.required, for: .vertical)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class HackedTextView: UITextView {
|
||||
var preferredMaxLayoutWidth = CGFloat.zero
|
||||
override var intrinsicContentSize: CGSize {
|
||||
return sizeThatFits(CGSize(width: preferredMaxLayoutWidth, height: .infinity))
|
||||
}
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict/>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
<array>
|
||||
<string>armv7</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||
<array>
|
||||
<string>com.ranchero.NetNewsWire.FeedRefresh</string>
|
||||
</array>
|
||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||
<string>Grant permission to save images from the article.</string>
|
||||
<key>NSUserActivityTypes</key>
|
||||
<array>
|
||||
<string>AddWebFeedIntent</string>
|
||||
<string>NextUnread</string>
|
||||
<string>ReadArticle</string>
|
||||
<string>Restoration</string>
|
||||
<string>SelectFeed</string>
|
||||
</array>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>fetch</string>
|
||||
<string>processing</string>
|
||||
<string>remote-notification</string>
|
||||
</array>
|
||||
<key>UserAgent</key>
|
||||
<string>NetNewsWire (RSS Reader; https://netnewswire.com/)</string>
|
||||
<key>OrganizationIdentifier</key>
|
||||
<string>$(ORGANIZATION_IDENTIFIER)</string>
|
||||
<key>DeveloperEntitlements</key>
|
||||
<string>$(DEVELOPER_ENTITLEMENTS)</string>
|
||||
<key>AppGroup</key>
|
||||
<string>group.$(ORGANIZATION_IDENTIFIER).NetNewsWire.iOS</string>
|
||||
<key>AppIdentifierPrefix</key>
|
||||
<string>$(AppIdentifierPrefix)</string>
|
||||
<key>LSApplicationQueriesSchemes</key>
|
||||
<array>
|
||||
<string>mailto</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,63 +0,0 @@
|
||||
//
|
||||
// SafariView.swift
|
||||
// Multiplatform iOS
|
||||
//
|
||||
// Created by Stuart Breckenridge on 30/6/20.
|
||||
// Copyright © 2020 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SafariServices
|
||||
|
||||
|
||||
private final class Safari: UIViewControllerRepresentable {
|
||||
|
||||
var urlToLoad: URL
|
||||
|
||||
init(url: URL) {
|
||||
self.urlToLoad = url
|
||||
}
|
||||
|
||||
func makeUIViewController(context: Context) -> SFSafariViewController {
|
||||
let viewController = SFSafariViewController(url: urlToLoad)
|
||||
viewController.delegate = context.coordinator
|
||||
return viewController
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: SFSafariViewController, context: Context) {
|
||||
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(self)
|
||||
}
|
||||
|
||||
class Coordinator: NSObject, SFSafariViewControllerDelegate {
|
||||
var parent: Safari
|
||||
|
||||
init(_ parent: Safari) {
|
||||
self.parent = parent
|
||||
}
|
||||
|
||||
func safariViewControllerDidFinish(_ controller: SFSafariViewController) {
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct SafariView: View {
|
||||
|
||||
var url: URL
|
||||
|
||||
var body: some View {
|
||||
Safari(url: url)
|
||||
}
|
||||
}
|
||||
|
||||
struct SafariView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
SafariView(url: URL(string: "https://netnewswire.com/")!)
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
{\rtf1\ansi\ansicpg1252\cocoartf2513
|
||||
\cocoatextscaling0\cocoaplatform0{\fonttbl\f0\fnil\fcharset0 LucidaGrande-Bold;}
|
||||
{\colortbl;\red255\green255\blue255;\red0\green0\blue0;\red10\green96\blue255;}
|
||||
{\*\expandedcolortbl;;\cssrgb\c0\c0\c0;\cssrgb\c0\c47843\c100000\cname systemBlueColor;}
|
||||
\margl1440\margr1440\vieww8340\viewh9300\viewkind0
|
||||
\pard\tx560\tx1120\tx1680\tx2240\tx2800\tx3360\tx3920\tx4480\tx5040\tx5600\tx6160\tx6720\li363\fi-364\pardirnatural\partightenfactor0
|
||||
|
||||
\f0\b\fs28 \cf2 By Brent Simmons and the Ranchero Software team
|
||||
\fs22 \
|
||||
\pard\tx560\tx1120\tx1680\tx2240\tx2800\tx3360\tx3920\tx4480\tx5040\tx5600\tx6160\tx6720\pardirnatural\partightenfactor0
|
||||
{\field{\*\fldinst{HYPERLINK "https://ranchero.com/netnewswire/"}}{\fldrslt
|
||||
\fs28 \cf3 netnewswire.com}}}
|
||||
@@ -1,20 +0,0 @@
|
||||
{\rtf1\ansi\ansicpg1252\cocoartf2511
|
||||
\cocoatextscaling0\cocoaplatform0{\fonttbl\f0\fnil\fcharset0 LucidaGrande;}
|
||||
{\colortbl;\red255\green255\blue255;\red0\green0\blue0;}
|
||||
{\*\expandedcolortbl;;\cssrgb\c0\c0\c0\cname textColor;}
|
||||
\margl1440\margr1440\vieww14220\viewh13280\viewkind0
|
||||
\deftab720
|
||||
\pard\pardeftab720\li360\fi-360\sa60\partightenfactor0
|
||||
|
||||
\f0\fs22 \cf2 iOS app design: {\field{\*\fldinst{HYPERLINK "https://inessential.com/"}}{\fldrslt Brent Simmons}} and {\field{\*\fldinst{HYPERLINK "https://github.com/vincode-io"}}{\fldrslt Maurice Parker}}\
|
||||
Lead iOS developer: {\field{\*\fldinst{HYPERLINK "https://github.com/vincode-io"}}{\fldrslt Maurice Parker}}\
|
||||
App icon: {\field{\*\fldinst{HYPERLINK "https://twitter.com/BradEllis"}}{\fldrslt Brad Ellis}}\
|
||||
\pard\pardeftab720\li366\fi-367\sa60\partightenfactor0
|
||||
\cf2 Feedly syncing: {\field{\*\fldinst{HYPERLINK "https://twitter.com/kielgillard"}}{\fldrslt Kiel Gillard}}\
|
||||
Under-the-hood magic and CSS stylin\'92s: {\field{\*\fldinst{HYPERLINK "https://github.com/wevah"}}{\fldrslt Nate Weaver}}\
|
||||
\pard\pardeftab720\li362\fi-363\sa60\partightenfactor0
|
||||
\cf2 Newsfoot (JS footnote displayer): {\field{\*\fldinst{HYPERLINK "https://github.com/brehaut/"}}{\fldrslt Andrew Brehaut}}\
|
||||
\pard\pardeftab720\li355\fi-356\sa60\partightenfactor0
|
||||
\cf2 Help book: {\field{\*\fldinst{HYPERLINK "https://nostodnayr.net/"}}{\fldrslt Ryan Dotson}}\
|
||||
\pard\pardeftab720\li358\fi-359\sa60\partightenfactor0
|
||||
\cf2 And more {\field{\*\fldinst{HYPERLINK "https://github.com/brentsimmons/NetNewsWire/graphs/contributors"}}{\fldrslt contributors}}!}
|
||||
@@ -1,9 +0,0 @@
|
||||
{\rtf1\ansi\ansicpg1252\cocoartf2513
|
||||
\cocoatextscaling0\cocoaplatform0{\fonttbl\f0\fnil\fcharset0 LucidaGrande;}
|
||||
{\colortbl;\red255\green255\blue255;\red0\green0\blue0;}
|
||||
{\*\expandedcolortbl;;\cssrgb\c0\c0\c0\cname textColor;}
|
||||
\margl1440\margr1440\vieww9000\viewh8400\viewkind0
|
||||
\deftab720
|
||||
\pard\pardeftab720\sa60\partightenfactor0
|
||||
|
||||
\f0\fs22 \cf2 NetNewsWire 6 is dedicated to everyone working to save democracy around the world.}
|
||||
@@ -1,31 +0,0 @@
|
||||
//
|
||||
// SettingsAboutModel.swift
|
||||
// Multiplatform iOS
|
||||
//
|
||||
// Created by Maurice Parker on 7/6/20.
|
||||
// Copyright © 2020 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
class SettingsAboutModel: ObservableObject {
|
||||
|
||||
var about: NSAttributedString
|
||||
var credits: NSAttributedString
|
||||
var thanks: NSAttributedString
|
||||
var dedication: NSAttributedString
|
||||
|
||||
init() {
|
||||
about = SettingsAboutModel.loadResource("About")
|
||||
credits = SettingsAboutModel.loadResource("Credits")
|
||||
thanks = SettingsAboutModel.loadResource("Thanks")
|
||||
dedication = SettingsAboutModel.loadResource("Dedication")
|
||||
}
|
||||
|
||||
private static func loadResource(_ resource: String) -> NSAttributedString {
|
||||
let url = Bundle.main.url(forResource: resource, withExtension: "rtf")!
|
||||
return try! NSAttributedString(url: url, options: [NSAttributedString.DocumentReadingOptionKey.documentType: NSAttributedString.DocumentType.rtf], documentAttributes: nil)
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
//
|
||||
// SettingsAboutView.swift
|
||||
// NetNewsWire-iOS
|
||||
//
|
||||
// Created by Maurice Parker on 9/16/19.
|
||||
// Copyright © 2019 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
struct SettingsAboutView: View {
|
||||
|
||||
@StateObject var viewModel = SettingsAboutModel()
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
List {
|
||||
Text("NetNewsWire").font(.largeTitle)
|
||||
AttributedStringView(string: self.viewModel.about, preferredMaxLayoutWidth: geometry.size.width - 20)
|
||||
Section(header: Text("CREDITS")) {
|
||||
AttributedStringView(string: self.viewModel.credits, preferredMaxLayoutWidth: geometry.size.width - 20)
|
||||
}
|
||||
Section(header: Text("THANKS")) {
|
||||
AttributedStringView(string: self.viewModel.thanks, preferredMaxLayoutWidth: geometry.size.width - 20)
|
||||
}
|
||||
Section(header: Text("DEDICATION"), footer: Text("Copyright © 2002-2021 Brent Simmons").font(.footnote)) {
|
||||
AttributedStringView(string: self.viewModel.dedication, preferredMaxLayoutWidth: geometry.size.width - 20)
|
||||
}
|
||||
}.listStyle(InsetGroupedListStyle())
|
||||
}
|
||||
.navigationTitle(Text("About"))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct SettingsAboutView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
SettingsAboutView()
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
{\rtf1\ansi\ansicpg1252\cocoartf2511
|
||||
\cocoatextscaling0\cocoaplatform0{\fonttbl\f0\fnil\fcharset0 LucidaGrande;}
|
||||
{\colortbl;\red255\green255\blue255;\red0\green0\blue0;}
|
||||
{\*\expandedcolortbl;;\cssrgb\c0\c0\c0\cname textColor;}
|
||||
\margl1440\margr1440\vieww11780\viewh11640\viewkind0
|
||||
\deftab720
|
||||
\pard\pardeftab720\li365\fi-366\sa60\partightenfactor0
|
||||
|
||||
\f0\fs22 \cf2 Thanks to Sheila and my family; thanks to my friends in Seattle and around the globe; thanks to the ever-patient and ever-awesome NetNewsWire beta testers. \
|
||||
\pard\tx0\pardeftab720\li360\fi-361\sa60\partightenfactor0
|
||||
\cf2 Thanks to {\field{\*\fldinst{HYPERLINK "https://shapeof.com/"}}{\fldrslt Gus Mueller}} for {\field{\*\fldinst{HYPERLINK "https://github.com/ccgus/fmdb"}}{\fldrslt FMDB}} by {\field{\*\fldinst{HYPERLINK "http://flyingmeat.com/"}}{\fldrslt Flying Meat Software}}. Thanks to {\field{\*\fldinst{HYPERLINK "https://github.com"}}{\fldrslt GitHub}} and {\field{\*\fldinst{HYPERLINK "https://slack.com"}}{\fldrslt Slack}} for making open source collaboration easy and fun. Thanks to {\field{\*\fldinst{HYPERLINK "https://benubois.com/"}}{\fldrslt Ben Ubois}} at {\field{\*\fldinst{HYPERLINK "https://feedbin.com/"}}{\fldrslt Feedbin}} for all the extra help with syncing and article rendering \'97\'a0and for {\field{\*\fldinst{HYPERLINK "https://feedbin.com/blog/2019/03/11/the-future-of-full-content/"}}{\fldrslt hosting the server for the Reader view}}.}
|
||||
@@ -1,37 +0,0 @@
|
||||
//
|
||||
// AccountCredentialsError.swift
|
||||
// Multiplatform iOS
|
||||
//
|
||||
// Created by Rizwan on 21/07/20.
|
||||
// Copyright © 2020 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum AccountCredentialsError: CustomStringConvertible, Equatable {
|
||||
case none, keyChain, invalidCredentials, noNetwork, other(error: Error)
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .keyChain:
|
||||
return NSLocalizedString("Keychain error while storing credentials.", comment: "")
|
||||
case .invalidCredentials:
|
||||
return NSLocalizedString("Invalid email/password combination.", comment: "")
|
||||
case .noNetwork:
|
||||
return NSLocalizedString("Network error. Try again later.", comment: "")
|
||||
case .other(let error):
|
||||
return NSLocalizedString(error.localizedDescription, comment: "Other add account error")
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
static func ==(lhs: AccountCredentialsError, rhs: AccountCredentialsError) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (.other(let lhsError), .other(let rhsError)):
|
||||
return lhsError.localizedDescription == rhsError.localizedDescription
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
//
|
||||
// AccountHeaderImageView.swift
|
||||
// Multiplatform iOS
|
||||
//
|
||||
// Created by Rizwan on 08/07/20.
|
||||
// Copyright © 2020 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import RSCore
|
||||
|
||||
struct AccountHeaderImageView: View {
|
||||
var image: RSImage
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .center) {
|
||||
Spacer()
|
||||
Image(rsImage: image)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(height: 48, alignment: .center)
|
||||
.foregroundColor(Color.primary)
|
||||
Spacer()
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
}
|
||||
|
||||
struct AccountHeaderImageView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group {
|
||||
AccountHeaderImageView(image: AppAssets.image(for: .onMyMac)!)
|
||||
AccountHeaderImageView(image: AppAssets.image(for: .feedbin)!)
|
||||
AccountHeaderImageView(image: AppAssets.accountLocalPadImage)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
//
|
||||
// SettingsAccountLabelView.swift
|
||||
// Multiplatform iOS
|
||||
//
|
||||
// Created by Rizwan on 07/07/20.
|
||||
// Copyright © 2020 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import RSCore
|
||||
|
||||
struct SettingsAccountLabelView: View {
|
||||
let accountImage: RSImage?
|
||||
let accountLabel: String
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Image(rsImage: accountImage!)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 32, height: 32)
|
||||
Text(verbatim: accountLabel).font(.title)
|
||||
}
|
||||
.foregroundColor(.primary).padding(4.0)
|
||||
}
|
||||
}
|
||||
|
||||
struct SettingsAccountLabelView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
List {
|
||||
SettingsAccountLabelView(
|
||||
accountImage: AppAssets.image(for: .onMyMac),
|
||||
accountLabel: "On My Device"
|
||||
)
|
||||
SettingsAccountLabelView(
|
||||
accountImage: AppAssets.image(for: .feedbin),
|
||||
accountLabel: "Feedbin"
|
||||
)
|
||||
SettingsAccountLabelView(
|
||||
accountImage: AppAssets.accountLocalPadImage,
|
||||
accountLabel: "On My iPad"
|
||||
)
|
||||
SettingsAccountLabelView(
|
||||
accountImage: AppAssets.accountLocalPhoneImage,
|
||||
accountLabel: "On My iPhone"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
//
|
||||
// SettingsAddAccountModel.swift
|
||||
// Multiplatform iOS
|
||||
//
|
||||
// Created by Rizwan on 09/07/20.
|
||||
// Copyright © 2020 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Account
|
||||
import RSCore
|
||||
|
||||
class SettingsAddAccountModel: ObservableObject {
|
||||
|
||||
struct SettingsAddAccount: Identifiable {
|
||||
var id: Int { accountType.rawValue }
|
||||
|
||||
let name: String
|
||||
let accountType: AccountType
|
||||
|
||||
var image: RSImage {
|
||||
AppAssets.image(for: accountType)!
|
||||
}
|
||||
}
|
||||
|
||||
@Published var accounts: [SettingsAddAccount] = []
|
||||
@Published var isAddPresented = false
|
||||
@Published var selectedAccountType: AccountType? = nil {
|
||||
didSet {
|
||||
selectedAccountType != nil ? (isAddPresented = true) : (isAddPresented = false)
|
||||
}
|
||||
}
|
||||
|
||||
init() {
|
||||
self.accounts = [
|
||||
SettingsAddAccount(name: Account.defaultLocalAccountName, accountType: .onMyMac),
|
||||
SettingsAddAccount(name: "Feedbin", accountType: .feedbin),
|
||||
SettingsAddAccount(name: "Feedly", accountType: .feedly),
|
||||
SettingsAddAccount(name: "Feed Wrangler", accountType: .feedWrangler),
|
||||
SettingsAddAccount(name: "iCloud", accountType: .cloudKit),
|
||||
SettingsAddAccount(name: "NewsBlur", accountType: .newsBlur),
|
||||
SettingsAddAccount(name: "Fresh RSS", accountType: .freshRSS)
|
||||
]
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
//
|
||||
// SettingsAddAccountView.swift
|
||||
// Multiplatform iOS
|
||||
//
|
||||
// Created by Rizwan on 07/07/20.
|
||||
// Copyright © 2020 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Account
|
||||
|
||||
struct SettingsAddAccountView: View {
|
||||
@StateObject private var model = SettingsAddAccountModel()
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
ForEach(model.accounts) { account in
|
||||
Button(action: {
|
||||
model.selectedAccountType = account.accountType
|
||||
}) {
|
||||
SettingsAccountLabelView(
|
||||
accountImage: account.image,
|
||||
accountLabel: account.name
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(InsetGroupedListStyle())
|
||||
.sheet(isPresented: $model.isAddPresented) {
|
||||
switch model.selectedAccountType! {
|
||||
case .onMyMac:
|
||||
AddLocalAccountView()
|
||||
case .feedbin:
|
||||
AddFeedbinAccountView()
|
||||
case .cloudKit:
|
||||
AddCloudKitAccountView()
|
||||
case .feedWrangler:
|
||||
AddFeedWranglerAccountView()
|
||||
case .newsBlur:
|
||||
AddNewsBlurAccountView()
|
||||
case .feedly:
|
||||
AddFeedlyAccountView()
|
||||
default:
|
||||
AddReaderAPIAccountView(accountType: model.selectedAccountType!)
|
||||
}
|
||||
}
|
||||
.navigationBarTitle(Text("Add Account"), displayMode: .inline)
|
||||
}
|
||||
}
|
||||
|
||||
struct SettingsAddAccountView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
SettingsAddAccountView()
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
//
|
||||
// SettingsCloudKitAccountView.swift
|
||||
// Multiplatform iOS
|
||||
//
|
||||
// Created by Rizwan on 13/07/20.
|
||||
// Copyright © 2020 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Account
|
||||
|
||||
struct SettingsCloudKitAccountView: View {
|
||||
@Environment(\.presentationMode) var presentationMode
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
List {
|
||||
Section(header: AccountHeaderImageView(image: AppAssets.image(for: .cloudKit)!)) { }
|
||||
Section {
|
||||
HStack {
|
||||
Spacer()
|
||||
Button(action: { self.addAccount() }) {
|
||||
Text("Add Account")
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(InsetGroupedListStyle())
|
||||
.navigationBarTitle(Text(verbatim: "iCloud"), displayMode: .inline)
|
||||
.navigationBarItems(leading: Button(action: { self.dismiss() }) { Text("Cancel") } )
|
||||
}
|
||||
}
|
||||
|
||||
private func addAccount() {
|
||||
_ = AccountManager.shared.createAccount(type: .cloudKit)
|
||||
dismiss()
|
||||
}
|
||||
|
||||
private func dismiss() {
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
struct SettingsCloudKitAccountView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
SettingsCloudKitAccountView()
|
||||
}
|
||||
}
|
||||
@@ -1,281 +0,0 @@
|
||||
//
|
||||
// SettingsCredentialsAccountModel.swift
|
||||
// Multiplatform iOS
|
||||
//
|
||||
// Created by Rizwan on 21/07/20.
|
||||
// Copyright © 2020 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Account
|
||||
import Secrets
|
||||
|
||||
class SettingsCredentialsAccountModel: ObservableObject {
|
||||
var account: Account? = nil
|
||||
var accountType: AccountType
|
||||
@Published var shouldDismiss: Bool = false
|
||||
@Published var email: String = ""
|
||||
@Published var password: String = ""
|
||||
@Published var apiUrl: String = ""
|
||||
@Published var busy: Bool = false
|
||||
@Published var accountCredentialsError: AccountCredentialsError? {
|
||||
didSet {
|
||||
accountCredentialsError != AccountCredentialsError.none ? (showError = true) : (showError = false)
|
||||
}
|
||||
}
|
||||
@Published var showError: Bool = false
|
||||
@Published var showPassword: Bool = false
|
||||
|
||||
init(account: Account) {
|
||||
self.account = account
|
||||
self.accountType = account.type
|
||||
if let credentials = try? account.retrieveCredentials(type: .basic) {
|
||||
self.email = credentials.username
|
||||
self.password = credentials.secret
|
||||
}
|
||||
}
|
||||
|
||||
init(accountType: AccountType) {
|
||||
self.accountType = accountType
|
||||
}
|
||||
|
||||
var isUpdate: Bool {
|
||||
return account != nil
|
||||
}
|
||||
|
||||
var isValid: Bool {
|
||||
if apiUrlEnabled {
|
||||
return !email.isEmpty && !password.isEmpty && !apiUrl.isEmpty
|
||||
}
|
||||
return !email.isEmpty && !password.isEmpty
|
||||
}
|
||||
|
||||
var accountName: String {
|
||||
switch accountType {
|
||||
case .onMyMac:
|
||||
return Account.defaultLocalAccountName
|
||||
case .cloudKit:
|
||||
return "iCloud"
|
||||
case .feedbin:
|
||||
return "Feedbin"
|
||||
case .feedly:
|
||||
return "Feedly"
|
||||
case .feedWrangler:
|
||||
return "Feed Wrangler"
|
||||
case .newsBlur:
|
||||
return "NewsBlur"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
var emailText: String {
|
||||
return accountType == .newsBlur ? NSLocalizedString("Username or Email", comment: "") : NSLocalizedString("Email", comment: "")
|
||||
}
|
||||
|
||||
var apiUrlEnabled: Bool {
|
||||
return accountType == .freshRSS
|
||||
}
|
||||
|
||||
func addAccount() {
|
||||
switch accountType {
|
||||
case .feedbin:
|
||||
addFeedbinAccount()
|
||||
case .feedWrangler:
|
||||
addFeedWranglerAccount()
|
||||
case .newsBlur:
|
||||
addNewsBlurAccount()
|
||||
case .freshRSS:
|
||||
addFreshRSSAccount()
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension SettingsCredentialsAccountModel {
|
||||
// MARK:- Feedbin
|
||||
|
||||
func addFeedbinAccount() {
|
||||
busy = true
|
||||
accountCredentialsError = AccountCredentialsError.none
|
||||
|
||||
let emailAddress = email.trimmingCharacters(in: .whitespaces)
|
||||
let credentials = Credentials(type: .basic, username: emailAddress, secret: password)
|
||||
|
||||
Account.validateCredentials(type: .feedbin, credentials: credentials) { (result) in
|
||||
self.busy = false
|
||||
|
||||
switch result {
|
||||
case .success(let authenticated):
|
||||
if (authenticated != nil) {
|
||||
var newAccount = false
|
||||
let workAccount: Account
|
||||
if self.account == nil {
|
||||
workAccount = AccountManager.shared.createAccount(type: .feedbin)
|
||||
newAccount = true
|
||||
} else {
|
||||
workAccount = self.account!
|
||||
}
|
||||
|
||||
do {
|
||||
do {
|
||||
try workAccount.removeCredentials(type: .basic)
|
||||
} catch {}
|
||||
try workAccount.storeCredentials(credentials)
|
||||
|
||||
if newAccount {
|
||||
workAccount.refreshAll() { result in }
|
||||
}
|
||||
|
||||
self.shouldDismiss = true
|
||||
} catch {
|
||||
self.accountCredentialsError = AccountCredentialsError.keyChain
|
||||
}
|
||||
|
||||
} else {
|
||||
self.accountCredentialsError = AccountCredentialsError.invalidCredentials
|
||||
}
|
||||
case .failure:
|
||||
self.accountCredentialsError = AccountCredentialsError.noNetwork
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: FeedWrangler
|
||||
|
||||
func addFeedWranglerAccount() {
|
||||
busy = true
|
||||
let credentials = Credentials(type: .feedWranglerBasic, username: email, secret: password)
|
||||
|
||||
Account.validateCredentials(type: .feedWrangler, credentials: credentials) { [weak self] result in
|
||||
guard let self = self else { return }
|
||||
|
||||
self.busy = false
|
||||
switch result {
|
||||
case .success(let validatedCredentials):
|
||||
guard let validatedCredentials = validatedCredentials else {
|
||||
self.accountCredentialsError = .invalidCredentials
|
||||
return
|
||||
}
|
||||
|
||||
let account = AccountManager.shared.createAccount(type: .feedWrangler)
|
||||
do {
|
||||
try account.removeCredentials(type: .feedWranglerBasic)
|
||||
try account.removeCredentials(type: .feedWranglerToken)
|
||||
try account.storeCredentials(credentials)
|
||||
try account.storeCredentials(validatedCredentials)
|
||||
self.shouldDismiss = true
|
||||
account.refreshAll(completion: { result in
|
||||
switch result {
|
||||
case .success:
|
||||
break
|
||||
case .failure(let error):
|
||||
self.accountCredentialsError = .other(error: error)
|
||||
}
|
||||
})
|
||||
|
||||
} catch {
|
||||
self.accountCredentialsError = .keyChain
|
||||
}
|
||||
|
||||
case .failure:
|
||||
self.accountCredentialsError = .noNetwork
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK:- NewsBlur
|
||||
|
||||
func addNewsBlurAccount() {
|
||||
busy = true
|
||||
let credentials = Credentials(type: .newsBlurBasic, username: email, secret: password)
|
||||
|
||||
Account.validateCredentials(type: .newsBlur, credentials: credentials) { [weak self] result in
|
||||
|
||||
guard let self = self else { return }
|
||||
|
||||
self.busy = false
|
||||
|
||||
switch result {
|
||||
case .success(let validatedCredentials):
|
||||
|
||||
guard let validatedCredentials = validatedCredentials else {
|
||||
self.accountCredentialsError = .invalidCredentials
|
||||
return
|
||||
}
|
||||
|
||||
let account = AccountManager.shared.createAccount(type: .newsBlur)
|
||||
|
||||
do {
|
||||
try account.removeCredentials(type: .newsBlurBasic)
|
||||
try account.removeCredentials(type: .newsBlurSessionId)
|
||||
try account.storeCredentials(credentials)
|
||||
try account.storeCredentials(validatedCredentials)
|
||||
self.shouldDismiss = true
|
||||
account.refreshAll(completion: { result in
|
||||
switch result {
|
||||
case .success:
|
||||
break
|
||||
case .failure(let error):
|
||||
self.accountCredentialsError = .other(error: error)
|
||||
}
|
||||
})
|
||||
|
||||
} catch {
|
||||
self.accountCredentialsError = .keyChain
|
||||
}
|
||||
|
||||
case .failure:
|
||||
self.accountCredentialsError = .noNetwork
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK:- Fresh RSS
|
||||
|
||||
func addFreshRSSAccount() {
|
||||
busy = true
|
||||
let credentials = Credentials(type: .readerBasic, username: email, secret: password)
|
||||
|
||||
Account.validateCredentials(type: .freshRSS, credentials: credentials, endpoint: URL(string: apiUrl)!) { [weak self] result in
|
||||
|
||||
guard let self = self else { return }
|
||||
|
||||
self.busy = false
|
||||
|
||||
switch result {
|
||||
case .success(let validatedCredentials):
|
||||
|
||||
guard let validatedCredentials = validatedCredentials else {
|
||||
self.accountCredentialsError = .invalidCredentials
|
||||
return
|
||||
}
|
||||
|
||||
let account = AccountManager.shared.createAccount(type: .freshRSS)
|
||||
|
||||
do {
|
||||
try account.removeCredentials(type: .readerBasic)
|
||||
try account.removeCredentials(type: .readerAPIKey)
|
||||
try account.storeCredentials(credentials)
|
||||
try account.storeCredentials(validatedCredentials)
|
||||
self.shouldDismiss = true
|
||||
account.refreshAll(completion: { result in
|
||||
switch result {
|
||||
case .success:
|
||||
break
|
||||
case .failure(let error):
|
||||
self.accountCredentialsError = .other(error: error)
|
||||
}
|
||||
})
|
||||
|
||||
} catch {
|
||||
self.accountCredentialsError = .keyChain
|
||||
}
|
||||
|
||||
case .failure:
|
||||
self.accountCredentialsError = .noNetwork
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
//
|
||||
// SettingsCredentialsAccountView.swift
|
||||
// Multiplatform iOS
|
||||
//
|
||||
// Created by Rizwan on 21/07/20.
|
||||
// Copyright © 2020 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Account
|
||||
|
||||
struct SettingsCredentialsAccountView: View {
|
||||
@Environment(\.presentationMode) var presentationMode
|
||||
@ObservedObject var settingsModel: SettingsCredentialsAccountModel
|
||||
|
||||
init(account: Account) {
|
||||
self.settingsModel = SettingsCredentialsAccountModel(account: account)
|
||||
}
|
||||
|
||||
init(accountType: AccountType) {
|
||||
self.settingsModel = SettingsCredentialsAccountModel(accountType: accountType)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
List {
|
||||
Section(header: AccountHeaderImageView(image: AppAssets.image(for: settingsModel.accountType)!)) {
|
||||
TextField(settingsModel.emailText, text: $settingsModel.email).textContentType(.emailAddress)
|
||||
HStack {
|
||||
if settingsModel.showPassword {
|
||||
TextField("Password", text:$settingsModel.password)
|
||||
}
|
||||
else {
|
||||
SecureField("Password", text: $settingsModel.password)
|
||||
}
|
||||
Button(action: {
|
||||
settingsModel.showPassword.toggle()
|
||||
}) {
|
||||
Text(settingsModel.showPassword ? "Hide" : "Show")
|
||||
}
|
||||
}
|
||||
if settingsModel.apiUrlEnabled {
|
||||
TextField("API URL", text: $settingsModel.apiUrl)
|
||||
}
|
||||
}
|
||||
Section(footer: errorFooter) {
|
||||
HStack {
|
||||
Spacer()
|
||||
Button(action: { settingsModel.addAccount() }) {
|
||||
if settingsModel.isUpdate {
|
||||
Text("Update Account")
|
||||
} else {
|
||||
Text("Add Account")
|
||||
}
|
||||
}
|
||||
.disabled(!settingsModel.isValid)
|
||||
Spacer()
|
||||
if settingsModel.busy {
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(InsetGroupedListStyle())
|
||||
.disabled(settingsModel.busy)
|
||||
.onReceive(settingsModel.$shouldDismiss, perform: { dismiss in
|
||||
if dismiss == true {
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
})
|
||||
.navigationBarTitle(Text(verbatim: settingsModel.accountName), displayMode: .inline)
|
||||
.navigationBarItems(leading:
|
||||
Button(action: { self.dismiss() }) { Text("Cancel") }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
var errorFooter: some View {
|
||||
HStack {
|
||||
Spacer()
|
||||
if settingsModel.showError {
|
||||
Text(verbatim: settingsModel.accountCredentialsError!.description).foregroundColor(.red)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
private func dismiss() {
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
struct SettingsCredentialsAccountView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
SettingsCredentialsAccountView(accountType: .feedbin)
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
//
|
||||
// SettingsDetailAccountModel.swift
|
||||
// Multiplatform iOS
|
||||
//
|
||||
// Created by Rizwan on 08/07/20.
|
||||
// Copyright © 2020 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Account
|
||||
import RSCore
|
||||
|
||||
class SettingsDetailAccountModel: ObservableObject {
|
||||
let account: Account
|
||||
@Published var name: String {
|
||||
didSet {
|
||||
account.name = name.isEmpty ? nil : name
|
||||
}
|
||||
}
|
||||
@Published var isActive: Bool {
|
||||
didSet {
|
||||
account.isActive = isActive
|
||||
}
|
||||
}
|
||||
|
||||
init(_ account: Account) {
|
||||
self.account = account
|
||||
self.name = account.name ?? ""
|
||||
self.isActive = account.isActive
|
||||
}
|
||||
|
||||
var defaultName: String {
|
||||
account.defaultName
|
||||
}
|
||||
|
||||
var nameForDisplay: String {
|
||||
account.nameForDisplay
|
||||
}
|
||||
|
||||
var accountImage: RSImage {
|
||||
AppAssets.image(for: account.type)!
|
||||
}
|
||||
|
||||
var isCredentialsAvailable: Bool {
|
||||
return account.type != .onMyMac
|
||||
}
|
||||
|
||||
var isDeletable: Bool {
|
||||
return AccountManager.shared.defaultAccount != account
|
||||
}
|
||||
|
||||
func delete() {
|
||||
AccountManager.shared.deleteAccount(account)
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
//
|
||||
// SettingsDetailAccountView.swift
|
||||
// Multiplatform iOS
|
||||
//
|
||||
// Created by Rizwan on 08/07/20.
|
||||
// Copyright © 2020 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Combine
|
||||
import Account
|
||||
import RSWeb
|
||||
import RSCore
|
||||
|
||||
struct SettingsDetailAccountView: View {
|
||||
@Environment(\.presentationMode) var presentationMode
|
||||
@ObservedObject var settingsModel: SettingsDetailAccountModel
|
||||
@State private var isFeedbinCredentialsPresented = false
|
||||
@State private var isDeleteAlertPresented = false
|
||||
|
||||
init(_ account: Account) {
|
||||
settingsModel = SettingsDetailAccountModel.init(account)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section(header:AccountHeaderImageView(image: settingsModel.accountImage)) {
|
||||
HStack {
|
||||
TextField(settingsModel.defaultName, text: $settingsModel.name)
|
||||
}
|
||||
Toggle(isOn: $settingsModel.isActive) {
|
||||
Text("Active")
|
||||
}
|
||||
}
|
||||
if settingsModel.isCredentialsAvailable {
|
||||
Section {
|
||||
HStack {
|
||||
Spacer()
|
||||
Button(action: {
|
||||
self.isFeedbinCredentialsPresented.toggle()
|
||||
}) {
|
||||
Text("Credentials")
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $isFeedbinCredentialsPresented) {
|
||||
self.settingsCredentialsAccountView
|
||||
}
|
||||
}
|
||||
if settingsModel.isDeletable {
|
||||
Section {
|
||||
HStack {
|
||||
Spacer()
|
||||
Button(action: {
|
||||
self.isDeleteAlertPresented.toggle()
|
||||
}) {
|
||||
Text("Delete Account").foregroundColor(.red)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.alert(isPresented: $isDeleteAlertPresented) {
|
||||
Alert(
|
||||
title: Text("Are you sure you want to delete \"\(settingsModel.nameForDisplay)\"?"),
|
||||
primaryButton: Alert.Button.default(
|
||||
Text("Delete"),
|
||||
action: {
|
||||
self.settingsModel.delete()
|
||||
self.dismiss()
|
||||
}),
|
||||
secondaryButton: Alert.Button.cancel()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(InsetGroupedListStyle())
|
||||
.navigationBarTitle(Text(verbatim: settingsModel.nameForDisplay), displayMode: .inline)
|
||||
}
|
||||
|
||||
var settingsCredentialsAccountView: SettingsCredentialsAccountView {
|
||||
return SettingsCredentialsAccountView(account: settingsModel.account)
|
||||
}
|
||||
|
||||
func dismiss() {
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
struct SettingsDetailAccountView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
return SettingsDetailAccountView(AccountManager.shared.defaultAccount)
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
//
|
||||
// SettingsLocalAccountView.swift
|
||||
// Multiplatform iOS
|
||||
//
|
||||
// Created by Rizwan on 07/07/20.
|
||||
// Copyright © 2020 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Account
|
||||
|
||||
struct SettingsLocalAccountView: View {
|
||||
@Environment(\.presentationMode) var presentation
|
||||
@State var name: String = ""
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
List {
|
||||
Section(header: AccountHeaderImageView(image: AppAssets.image(for: .onMyMac)!)) {
|
||||
HStack {
|
||||
TextField("Name", text: $name)
|
||||
}
|
||||
}
|
||||
Section {
|
||||
HStack {
|
||||
Spacer()
|
||||
Button(action: { self.addAccount() }) {
|
||||
Text("Add Account")
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(InsetGroupedListStyle())
|
||||
.navigationBarTitle(Text(verbatim: Account.defaultLocalAccountName), displayMode: .inline)
|
||||
.navigationBarItems(leading: Button(action: { self.dismiss() }) { Text("Cancel") } )
|
||||
}
|
||||
}
|
||||
|
||||
private func addAccount() {
|
||||
let account = AccountManager.shared.createAccount(type: .onMyMac)
|
||||
account.name = name
|
||||
dismiss()
|
||||
}
|
||||
|
||||
private func dismiss() {
|
||||
presentation.wrappedValue.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
struct SettingsLocalAccountView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
SettingsLocalAccountView()
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
//
|
||||
// ColorPaletteContainerView.swift
|
||||
// Multiplatform iOS
|
||||
//
|
||||
// Created by Rizwan on 02/07/20.
|
||||
// Copyright © 2020 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ColorPaletteContainerView: View {
|
||||
private let colorPalettes = UserInterfaceColorPalette.allCases
|
||||
@EnvironmentObject private var appSettings: AppDefaults
|
||||
@Environment(\.presentationMode) var presentationMode
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
ForEach.init(0 ..< colorPalettes.count) { index in
|
||||
Button(action: {
|
||||
onTapColorPalette(at:index)
|
||||
}) {
|
||||
ColorPaletteView(colorPalette: colorPalettes[index])
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(InsetGroupedListStyle())
|
||||
.navigationBarTitle("Color Palette", displayMode: .inline)
|
||||
}
|
||||
|
||||
func onTapColorPalette(at index: Int) {
|
||||
if let colorPalette = UserInterfaceColorPalette(rawValue: index) {
|
||||
appSettings.userInterfaceColorPalette = colorPalette
|
||||
}
|
||||
self.presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
struct ColorPaletteView: View {
|
||||
var colorPalette: UserInterfaceColorPalette
|
||||
@EnvironmentObject private var appSettings: AppDefaults
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(colorPalette.description).foregroundColor(.primary)
|
||||
Spacer()
|
||||
if colorPalette == appSettings.userInterfaceColorPalette {
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundColor(.blue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ColorPaletteContainerView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
NavigationView {
|
||||
ColorPaletteContainerView()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
//
|
||||
// FeedsSettingsModel.swift
|
||||
// Multiplatform iOS
|
||||
//
|
||||
// Created by Rizwan on 04/07/20.
|
||||
// Copyright © 2020 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import Account
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
enum FeedsSettingsError: LocalizedError, Equatable {
|
||||
case none, noActiveAccount, exportFailed(reason: String?), importFailed
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .noActiveAccount:
|
||||
return NSLocalizedString("You must have at least one active account.", comment: "Missing active account")
|
||||
case .exportFailed(let reason):
|
||||
return reason
|
||||
case .importFailed:
|
||||
return NSLocalizedString(
|
||||
"We were unable to process the selected file. Please ensure that it is a properly formatted OPML file.",
|
||||
comment: "Import Failed Message"
|
||||
)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var title: String? {
|
||||
switch self {
|
||||
case .noActiveAccount:
|
||||
return NSLocalizedString("Error", comment: "Error Title")
|
||||
case .exportFailed:
|
||||
return NSLocalizedString("OPML Export Error", comment: "Export Failed")
|
||||
case .importFailed:
|
||||
return NSLocalizedString("Import Failed", comment: "Import Failed")
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class FeedsSettingsModel: ObservableObject {
|
||||
@Published var exportingFilePath = ""
|
||||
@Published var feedsSettingsError: FeedsSettingsError? {
|
||||
didSet {
|
||||
feedsSettingsError != FeedsSettingsError.none ? (showError = true) : (showError = false)
|
||||
}
|
||||
}
|
||||
@Published var showError: Bool = false
|
||||
@Published var isImporting: Bool = false
|
||||
@Published var isExporting: Bool = false
|
||||
@Published var selectedAccount: Account? = nil
|
||||
|
||||
let importingContentTypes: [UTType] = [UTType(filenameExtension: "opml"), UTType("public.xml")].compactMap { $0 }
|
||||
|
||||
func checkForActiveAccount() -> Bool {
|
||||
if AccountManager.shared.activeAccounts.count == 0 {
|
||||
feedsSettingsError = .noActiveAccount
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func importOPML(account: Account?) {
|
||||
selectedAccount = account
|
||||
isImporting = true
|
||||
}
|
||||
|
||||
func exportOPML(account: Account?) {
|
||||
selectedAccount = account
|
||||
isExporting = true
|
||||
}
|
||||
|
||||
func generateExportURL() -> URL? {
|
||||
guard let account = selectedAccount else { return nil }
|
||||
let accountName = account.nameForDisplay.replacingOccurrences(of: " ", with: "").trimmingCharacters(in: .whitespaces)
|
||||
let filename = "Subscriptions-\(accountName).opml"
|
||||
let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent(filename)
|
||||
let opmlString = OPMLExporter.OPMLString(with: account, title: filename)
|
||||
do {
|
||||
try opmlString.write(to: tempFile, atomically: true, encoding: String.Encoding.utf8)
|
||||
} catch {
|
||||
feedsSettingsError = .exportFailed(reason: error.localizedDescription)
|
||||
return nil
|
||||
}
|
||||
|
||||
return tempFile
|
||||
}
|
||||
|
||||
func processImportedFiles(_ urls: [URL]) {
|
||||
urls.forEach{
|
||||
selectedAccount?.importOPML($0, completion: { [weak self] result in
|
||||
switch result {
|
||||
case .success:
|
||||
break
|
||||
case .failure:
|
||||
self?.feedsSettingsError = .importFailed
|
||||
break
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
//
|
||||
// SettingsModel.swift
|
||||
// Multiplatform iOS
|
||||
//
|
||||
// Created by Maurice Parker on 7/4/20.
|
||||
// Copyright © 2020 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Account
|
||||
|
||||
class SettingsModel: ObservableObject {
|
||||
|
||||
enum HelpSites {
|
||||
case netNewsWireHelp, netNewsWire, supportNetNewsWire, github, bugTracker, technotes, netNewsWireSlack, releaseNotes, none
|
||||
|
||||
var url: URL? {
|
||||
switch self {
|
||||
case .netNewsWireHelp:
|
||||
return URL(string: "https://netnewswire.com/help/ios/5.0/en/")!
|
||||
case .netNewsWire:
|
||||
return URL(string: "https://netnewswire.com/")!
|
||||
case .supportNetNewsWire:
|
||||
return URL(string: "https://github.com/brentsimmons/NetNewsWire/blob/main/Technotes/HowToSupportNetNewsWire.markdown")!
|
||||
case .github:
|
||||
return URL(string: "https://github.com/brentsimmons/NetNewsWire")!
|
||||
case .bugTracker:
|
||||
return URL(string: "https://github.com/brentsimmons/NetNewsWire/issues")!
|
||||
case .technotes:
|
||||
return URL(string: "https://github.com/brentsimmons/NetNewsWire/tree/main/Technotes")!
|
||||
case .netNewsWireSlack:
|
||||
return URL(string: "https://netnewswire.com/slack")!
|
||||
case .releaseNotes:
|
||||
return URL.releaseNotes
|
||||
case .none:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Published var presentSheet: Bool = false
|
||||
var accounts: [Account] {
|
||||
get {
|
||||
AccountManager.shared.sortedAccounts
|
||||
}
|
||||
set {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
var activeAccounts: [Account] {
|
||||
get {
|
||||
AccountManager.shared.sortedActiveAccounts
|
||||
}
|
||||
set {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Init
|
||||
|
||||
init() {
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(displayNameDidChange), name: .DisplayNameDidChange, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(userDidAddAccount), name: .UserDidAddAccount, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(userDidDeleteAccount), name: .UserDidDeleteAccount, object: nil)
|
||||
}
|
||||
|
||||
var selectedWebsite: HelpSites = .none {
|
||||
didSet {
|
||||
if selectedWebsite == .none {
|
||||
presentSheet = false
|
||||
} else {
|
||||
presentSheet = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func refreshAccounts() {
|
||||
objectWillChange.self.send()
|
||||
}
|
||||
|
||||
// MARK:- Notifications
|
||||
|
||||
@objc func displayNameDidChange() {
|
||||
refreshAccounts()
|
||||
}
|
||||
|
||||
@objc func userDidAddAccount() {
|
||||
refreshAccounts()
|
||||
}
|
||||
|
||||
@objc func userDidDeleteAccount() {
|
||||
refreshAccounts()
|
||||
}
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
//
|
||||
// SettingsView.swift
|
||||
// Multiplatform iOS
|
||||
//
|
||||
// Created by Stuart Breckenridge on 30/6/20.
|
||||
// Copyright © 2020 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Account
|
||||
|
||||
struct SettingsView: View {
|
||||
|
||||
@Environment(\.presentationMode) var presentationMode
|
||||
|
||||
@StateObject private var viewModel = SettingsModel()
|
||||
@StateObject private var feedsSettingsModel = FeedsSettingsModel()
|
||||
@StateObject private var settings = AppDefaults.shared
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
List {
|
||||
systemSettings
|
||||
accounts
|
||||
importExport
|
||||
timeline
|
||||
articles
|
||||
appearance
|
||||
help
|
||||
}
|
||||
.listStyle(InsetGroupedListStyle())
|
||||
.navigationBarTitle("Settings", displayMode: .inline)
|
||||
.navigationBarItems(leading:
|
||||
HStack {
|
||||
Button("Done") {
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
.fileImporter(
|
||||
isPresented: $feedsSettingsModel.isImporting,
|
||||
allowedContentTypes: feedsSettingsModel.importingContentTypes,
|
||||
allowsMultipleSelection: true,
|
||||
onCompletion: { result in
|
||||
if let urls = try? result.get() {
|
||||
feedsSettingsModel.processImportedFiles(urls)
|
||||
}
|
||||
}
|
||||
)
|
||||
.fileMover(isPresented: $feedsSettingsModel.isExporting,
|
||||
file: feedsSettingsModel.generateExportURL()) { _ in }
|
||||
.sheet(isPresented: $viewModel.presentSheet, content: {
|
||||
SafariView(url: viewModel.selectedWebsite.url!)
|
||||
})
|
||||
}
|
||||
|
||||
var systemSettings: some View {
|
||||
Section(header: Text("Notifications, Badge, Data, & More"), content: {
|
||||
Button(action: {
|
||||
UIApplication.shared.open(URL(string: "\(UIApplication.openSettingsURLString)")!)
|
||||
}, label: {
|
||||
Text("Open System Settings").foregroundColor(.primary)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
var accounts: some View {
|
||||
Section(header: Text("Accounts"), content: {
|
||||
ForEach(0..<viewModel.accounts.count, id: \.hashValue , content: { i in
|
||||
NavigationLink(
|
||||
destination: SettingsDetailAccountView(viewModel.accounts[i]),
|
||||
label: {
|
||||
Text(viewModel.accounts[i].nameForDisplay)
|
||||
})
|
||||
})
|
||||
NavigationLink(
|
||||
destination: SettingsAddAccountView(),
|
||||
label: {
|
||||
Text("Add Account")
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
var importExport: some View {
|
||||
Section(header: Text("Feeds"), content: {
|
||||
if viewModel.activeAccounts.count > 1 {
|
||||
NavigationLink("Import Subscriptions", destination: importOptions)
|
||||
}
|
||||
else {
|
||||
Button(action:{
|
||||
if feedsSettingsModel.checkForActiveAccount() {
|
||||
feedsSettingsModel.importOPML(account: viewModel.activeAccounts.first)
|
||||
}
|
||||
}) {
|
||||
Text("Import Subscriptions")
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
}
|
||||
|
||||
if viewModel.accounts.count > 1 {
|
||||
NavigationLink("Export Subscriptions", destination: exportOptions)
|
||||
}
|
||||
else {
|
||||
Button(action:{
|
||||
feedsSettingsModel.exportOPML(account: viewModel.accounts.first)
|
||||
}) {
|
||||
Text("Export Subscriptions")
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
}
|
||||
Toggle("Confirm When Deleting", isOn: $settings.sidebarConfirmDelete)
|
||||
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
|
||||
})
|
||||
.alert(isPresented: $feedsSettingsModel.showError) {
|
||||
Alert(
|
||||
title: Text(feedsSettingsModel.feedsSettingsError!.title ?? "Oops"),
|
||||
message: Text(feedsSettingsModel.feedsSettingsError!.localizedDescription),
|
||||
dismissButton: Alert.Button.cancel({
|
||||
feedsSettingsModel.feedsSettingsError = FeedsSettingsError.none
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
var importOptions: some View {
|
||||
List {
|
||||
Section(header: Text("Choose an account to receive the imported feeds and folders"), content: {
|
||||
ForEach(0..<viewModel.activeAccounts.count, id: \.hashValue , content: { i in
|
||||
Button {
|
||||
feedsSettingsModel.importOPML(account: viewModel.activeAccounts[i])
|
||||
} label: {
|
||||
Text(viewModel.activeAccounts[i].nameForDisplay)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
.listStyle(InsetGroupedListStyle())
|
||||
.navigationBarTitle("Import Subscriptions", displayMode: .inline)
|
||||
}
|
||||
|
||||
var exportOptions: some View {
|
||||
List {
|
||||
Section(header: Text("Choose an account with the subscriptions to export"), content: {
|
||||
ForEach(0..<viewModel.accounts.count, id: \.hashValue , content: { i in
|
||||
Button {
|
||||
feedsSettingsModel.exportOPML(account: viewModel.accounts[i])
|
||||
} label: {
|
||||
Text(viewModel.accounts[i].nameForDisplay)
|
||||
}
|
||||
})
|
||||
|
||||
})
|
||||
}
|
||||
.listStyle(InsetGroupedListStyle())
|
||||
.navigationBarTitle("Export Subscriptions", displayMode: .inline)
|
||||
}
|
||||
|
||||
|
||||
|
||||
var timeline: some View {
|
||||
Section(header: Text("Timeline"), content: {
|
||||
Toggle("Sort Oldest to Newest", isOn: $settings.timelineSortDirection)
|
||||
Toggle("Group by Feed", isOn: $settings.timelineGroupByFeed)
|
||||
Toggle("Refresh to Clear Read Articles", isOn: $settings.refreshClearsReadArticles)
|
||||
NavigationLink(
|
||||
destination: TimelineLayoutView().environmentObject(settings),
|
||||
label: {
|
||||
Text("Timeline Layout")
|
||||
})
|
||||
}).toggleStyle(SwitchToggleStyle(tint: .accentColor))
|
||||
}
|
||||
|
||||
var articles: some View {
|
||||
Section(header: Text("Articles"), content: {
|
||||
Toggle("Confirm Mark All as Read", isOn: .constant(true))
|
||||
Toggle(isOn: .constant(true), label: {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Enable Full Screen Articles")
|
||||
Text("Tap the article top bar to enter Full Screen. Tap the bottom or top to exit.").font(.caption).foregroundColor(.secondary)
|
||||
}
|
||||
})
|
||||
}).toggleStyle(SwitchToggleStyle(tint: .accentColor))
|
||||
}
|
||||
|
||||
var appearance: some View {
|
||||
Section(header: Text("Appearance"), content: {
|
||||
NavigationLink(
|
||||
destination: ColorPaletteContainerView().environmentObject(settings),
|
||||
label: {
|
||||
HStack {
|
||||
Text("Color Palette")
|
||||
Spacer()
|
||||
Text(settings.userInterfaceColorPalette.description)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
var help: some View {
|
||||
Section(header: Text("Help"), footer: Text(appVersion()).font(.caption2), content: {
|
||||
Button("NetNewsWire Help", action: {
|
||||
viewModel.selectedWebsite = .netNewsWireHelp
|
||||
}).foregroundColor(.primary)
|
||||
Button("Website", action: {
|
||||
viewModel.selectedWebsite = .netNewsWire
|
||||
}).foregroundColor(.primary)
|
||||
Button("How To Support NetNewsWire", action: {
|
||||
viewModel.selectedWebsite = .supportNetNewsWire
|
||||
}).foregroundColor(.primary)
|
||||
Button("Github Repository", action: {
|
||||
viewModel.selectedWebsite = .github
|
||||
}).foregroundColor(.primary)
|
||||
Button("Bug Tracker", action: {
|
||||
viewModel.selectedWebsite = .bugTracker
|
||||
}).foregroundColor(.primary)
|
||||
Button("NetNewsWire Slack", action: {
|
||||
viewModel.selectedWebsite = .netNewsWireSlack
|
||||
}).foregroundColor(.primary)
|
||||
Button("Release Notes", action: {
|
||||
viewModel.selectedWebsite = .releaseNotes
|
||||
}).foregroundColor(.primary)
|
||||
NavigationLink(
|
||||
destination: SettingsAboutView(),
|
||||
label: {
|
||||
Text("About NetNewsWire")
|
||||
})
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
private func appVersion() -> String {
|
||||
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? ""
|
||||
let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? ""
|
||||
return "NetNewsWire \(version) (Build \(build))"
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct SettingsView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
SettingsView()
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
//
|
||||
// TimelineLayoutView.swift
|
||||
// Multiplatform iOS
|
||||
//
|
||||
// Created by Stuart Breckenridge on 1/7/20.
|
||||
// Copyright © 2020 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct TimelineLayoutView: View {
|
||||
|
||||
@EnvironmentObject private var defaults: AppDefaults
|
||||
|
||||
private let sampleTitle = "Lorem dolor sed viverra ipsum. Gravida rutrum quisque non tellus. Rutrum tellus pellentesque eu tincidunt tortor. Sed blandit libero volutpat sed cras ornare. Et netus et malesuada fames ac. Ultrices eros in cursus turpis massa tincidunt dui ut ornare. Lacus sed viverra tellus in. Sollicitudin ac orci phasellus egestas. Purus in mollis nunc sed. Sollicitudin ac orci phasellus egestas tellus rutrum tellus pellentesque. Interdum consectetur libero id faucibus nisl tincidunt eget."
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
List {
|
||||
Section(header: Text("Icon Size"), content: {
|
||||
iconSize
|
||||
})
|
||||
Section(header: Text("Number of Lines"), content: {
|
||||
numberOfLines
|
||||
}) }
|
||||
.listStyle(InsetGroupedListStyle())
|
||||
|
||||
Divider()
|
||||
timelineRowPreview.padding()
|
||||
Divider()
|
||||
}
|
||||
.navigationBarTitle("Timeline Layout")
|
||||
}
|
||||
|
||||
var iconSize: some View {
|
||||
Slider(value: $defaults.timelineIconDimensions, in: 20...60, step: 10, minimumValueLabel: Text("Small"), maximumValueLabel: Text("Large"), label: {
|
||||
Text(String(defaults.timelineIconDimensions))
|
||||
})
|
||||
}
|
||||
|
||||
var numberOfLines: some View {
|
||||
Slider(value: $defaults.timelineNumberOfLines, in: 1...5, step: 1, minimumValueLabel: Text("1"), maximumValueLabel: Text("5"), label: {
|
||||
Text("Article Title")
|
||||
})
|
||||
|
||||
|
||||
}
|
||||
|
||||
var timelineRowPreview: some View {
|
||||
|
||||
HStack(alignment: .top) {
|
||||
Image(systemName: "circle.fill")
|
||||
.resizable()
|
||||
.frame(width: 10, height: 10, alignment: .top)
|
||||
.foregroundColor(.accentColor)
|
||||
|
||||
Image(systemName: "paperplane.circle")
|
||||
.resizable()
|
||||
.frame(width: CGFloat(defaults.timelineIconDimensions), height: CGFloat(defaults.timelineIconDimensions), alignment: .top)
|
||||
.foregroundColor(.accentColor)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(sampleTitle)
|
||||
.font(.headline)
|
||||
.lineLimit(Int(defaults.timelineNumberOfLines))
|
||||
HStack {
|
||||
Text("Feed Name")
|
||||
.foregroundColor(.secondary)
|
||||
.font(.footnote)
|
||||
Spacer()
|
||||
Text("10:31")
|
||||
.font(.footnote)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TimelineLayout_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
TimelineLayoutView()
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.$(ORGANIZATION_IDENTIFIER).NetNewsWire.iOS</string>
|
||||
</array>
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)$(ORGANIZATION_IDENTIFIER).NetNewsWire.iOS</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,24 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>aps-environment</key>
|
||||
<string>development</string>
|
||||
<key>com.apple.developer.icloud-container-identifiers</key>
|
||||
<array>
|
||||
<string>iCloud.$(ORGANIZATION_IDENTIFIER).NetNewsWire</string>
|
||||
</array>
|
||||
<key>com.apple.developer.icloud-services</key>
|
||||
<array>
|
||||
<string>CloudKit</string>
|
||||
</array>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.$(ORGANIZATION_IDENTIFIER).NetNewsWire.iOS</string>
|
||||
</array>
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)$(ORGANIZATION_IDENTIFIER).NetNewsWire.iOS</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
Reference in New Issue
Block a user