mirror of
https://github.com/Ranchero-Software/NetNewsWire
synced 2025-08-12 06:26:36 +00:00
Added initial POC version of NetNewsWire for iOS to use as a starting point for the actual app.
This commit is contained in:
128
iOS/Master/Cell/MasterTableViewCell.swift
Normal file
128
iOS/Master/Cell/MasterTableViewCell.swift
Normal file
@@ -0,0 +1,128 @@
|
||||
//
|
||||
// MasterTableViewCell.swift
|
||||
// NetNewsWire
|
||||
//
|
||||
// Created by Brent Simmons on 8/1/15.
|
||||
// Copyright © 2015 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import RSCore
|
||||
import Account
|
||||
import RSTree
|
||||
|
||||
class MasterTableViewCell : UITableViewCell {
|
||||
|
||||
override var accessibilityLabel: String? {
|
||||
set {}
|
||||
get {
|
||||
if unreadCount > 0 {
|
||||
let unreadLabel = NSLocalizedString("unread", comment: "Unread label for accessiblity")
|
||||
return "\(name) \(unreadCount) \(unreadLabel)"
|
||||
} else {
|
||||
return name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var faviconImage: UIImage? {
|
||||
didSet {
|
||||
if let image = faviconImage {
|
||||
faviconImageView.image = shouldShowImage ? image : nil
|
||||
}
|
||||
else {
|
||||
faviconImageView.image = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var shouldShowImage = false {
|
||||
didSet {
|
||||
if shouldShowImage != oldValue {
|
||||
setNeedsLayout()
|
||||
}
|
||||
faviconImageView.image = shouldShowImage ? faviconImage : nil
|
||||
}
|
||||
}
|
||||
|
||||
var unreadCount: Int {
|
||||
get {
|
||||
return unreadCountView.unreadCount
|
||||
}
|
||||
set {
|
||||
if unreadCountView.unreadCount != newValue {
|
||||
unreadCountView.unreadCount = newValue
|
||||
unreadCountView.isHidden = (newValue < 1)
|
||||
setNeedsLayout()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var name: String {
|
||||
get {
|
||||
return titleView.text ?? ""
|
||||
}
|
||||
set {
|
||||
if titleView.text != newValue {
|
||||
titleView.text = newValue
|
||||
setNeedsDisplay()
|
||||
setNeedsLayout()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private let titleView: UILabel = {
|
||||
let label = UILabel()
|
||||
label.numberOfLines = 1
|
||||
label.lineBreakMode = .byTruncatingTail
|
||||
label.allowsDefaultTighteningForTruncation = false
|
||||
return label
|
||||
}()
|
||||
|
||||
private let faviconImageView: UIImageView = {
|
||||
return UIImageView(image: AppAssets.feedImage)
|
||||
}()
|
||||
|
||||
private let unreadCountView = MasterUnreadCountView(frame: CGRect.zero)
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
commonInit()
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
let layout = MasterTableViewCellLayout(cellSize: bounds.size, shouldShowImage: shouldShowImage, label: titleView, unreadCountView: unreadCountView)
|
||||
layoutWith(layout)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private extension MasterTableViewCell {
|
||||
|
||||
func commonInit() {
|
||||
addSubviewAtInit(unreadCountView)
|
||||
addSubviewAtInit(faviconImageView)
|
||||
addSubviewAtInit(titleView)
|
||||
}
|
||||
|
||||
func addSubviewAtInit(_ view: UIView) {
|
||||
addSubview(view)
|
||||
view.translatesAutoresizingMaskIntoConstraints = false
|
||||
}
|
||||
|
||||
func layoutWith(_ layout: MasterTableViewCellLayout) {
|
||||
faviconImageView.rs_setFrameIfNotEqual(layout.faviconRect)
|
||||
titleView.rs_setFrameIfNotEqual(layout.titleRect)
|
||||
unreadCountView.rs_setFrameIfNotEqual(layout.unreadCountRect)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension UIView {
|
||||
func rs_setFrameIfNotEqual(_ rect: CGRect) {
|
||||
if !self.frame.equalTo(rect) {
|
||||
self.frame = rect
|
||||
}
|
||||
}
|
||||
}
|
||||
74
iOS/Master/Cell/MasterTableViewCellLayout.swift
Normal file
74
iOS/Master/Cell/MasterTableViewCellLayout.swift
Normal file
@@ -0,0 +1,74 @@
|
||||
//
|
||||
// MasterTableViewCellLayout.swift
|
||||
// NetNewsWire
|
||||
//
|
||||
// Created by Brent Simmons on 11/24/17.
|
||||
// Copyright © 2017 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import RSCore
|
||||
|
||||
struct MasterTableViewCellLayout {
|
||||
|
||||
private static let imageSize = CGSize(width: 16, height: 16)
|
||||
private static let imageMarginLeft = CGFloat(integerLiteral: 8)
|
||||
private static let imageMarginRight = CGFloat(integerLiteral: 8)
|
||||
private static let unreadCountMarginLeft = CGFloat(integerLiteral: 8)
|
||||
private static let unreadCountMarginRight = CGFloat(integerLiteral: 8)
|
||||
|
||||
let faviconRect: CGRect
|
||||
let titleRect: CGRect
|
||||
let unreadCountRect: CGRect
|
||||
|
||||
init(cellSize: CGSize, shouldShowImage: Bool, label: UILabel, unreadCountView: MasterUnreadCountView) {
|
||||
|
||||
let bounds = CGRect(x: 0.0, y: 0.0, width: floor(cellSize.width), height: floor(cellSize.height))
|
||||
|
||||
var rFavicon = CGRect.zero
|
||||
if shouldShowImage {
|
||||
rFavicon = CGRect(x: MasterTableViewCellLayout.imageMarginLeft, y: 0.0, width: MasterTableViewCellLayout.imageSize.width, height: MasterTableViewCellLayout.imageSize.height)
|
||||
rFavicon = MasterTableViewCellLayout.centerVertically(rFavicon, bounds)
|
||||
}
|
||||
self.faviconRect = rFavicon
|
||||
|
||||
let labelSize = SingleLineUILabelSizer.size(for: label.text ?? "", font: label.font!)
|
||||
|
||||
var rLabel = CGRect(x: 0.0, y: 0.0, width: labelSize.width, height: labelSize.height)
|
||||
if shouldShowImage {
|
||||
rLabel.origin.x = rFavicon.maxX + MasterTableViewCellLayout.imageMarginRight
|
||||
}
|
||||
rLabel = MasterTableViewCellLayout.centerVertically(rLabel, bounds)
|
||||
|
||||
let unreadCountSize = unreadCountView.intrinsicContentSize
|
||||
let unreadCountIsHidden = unreadCountView.unreadCount < 1
|
||||
|
||||
var rUnread = CGRect.zero
|
||||
if !unreadCountIsHidden {
|
||||
rUnread.size = unreadCountSize
|
||||
rUnread.origin.x = (bounds.maxX - unreadCountSize.width) - MasterTableViewCellLayout.unreadCountMarginRight
|
||||
rUnread = MasterTableViewCellLayout.centerVertically(rUnread, bounds)
|
||||
let labelMaxX = rUnread.minX - MasterTableViewCellLayout.unreadCountMarginLeft
|
||||
if rLabel.maxX > labelMaxX {
|
||||
rLabel.size.width = labelMaxX - rLabel.minX
|
||||
}
|
||||
}
|
||||
self.unreadCountRect = rUnread
|
||||
|
||||
if rLabel.maxX > bounds.maxX {
|
||||
rLabel.size.width = bounds.maxX - rLabel.maxX
|
||||
}
|
||||
|
||||
self.titleRect = rLabel
|
||||
}
|
||||
|
||||
// Ideally this will be implemented in RSCore (see RSGeometry)
|
||||
static func centerVertically(_ originalRect: CGRect, _ containerRect: CGRect) -> CGRect {
|
||||
var result = originalRect
|
||||
result.origin.y = containerRect.midY - (result.height / 2.0)
|
||||
result = result.integral
|
||||
result.size = originalRect.size
|
||||
return result
|
||||
}
|
||||
|
||||
}
|
||||
107
iOS/Master/Cell/MasterUnreadCountView.swift
Normal file
107
iOS/Master/Cell/MasterUnreadCountView.swift
Normal file
@@ -0,0 +1,107 @@
|
||||
//
|
||||
// MasterUnreadCountView.swift
|
||||
// NetNewsWire
|
||||
//
|
||||
// Created by Brent Simmons on 11/22/15.
|
||||
// Copyright © 2015 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
private let padding = UIEdgeInsets(top: 1.0, left: 7.0, bottom: 1.0, right: 7.0)
|
||||
private let cornerRadius = 8.0
|
||||
private let bgColor = UIColor.darkGray
|
||||
private let textColor = UIColor.white
|
||||
private let textFont = UIFont.systemFont(ofSize: 11.0, weight: UIFont.Weight.semibold)
|
||||
private var textAttributes: [NSAttributedString.Key: AnyObject] = [NSAttributedString.Key.foregroundColor: textColor, NSAttributedString.Key.font: textFont, NSAttributedString.Key.kern: NSNull()]
|
||||
private var textSizeCache = [Int: CGSize]()
|
||||
|
||||
class MasterUnreadCountView : UIView {
|
||||
|
||||
var unreadCount = 0 {
|
||||
didSet {
|
||||
invalidateIntrinsicContentSize()
|
||||
setNeedsDisplay()
|
||||
}
|
||||
}
|
||||
|
||||
var unreadCountString: String {
|
||||
return unreadCount < 1 ? "" : "\(unreadCount)"
|
||||
}
|
||||
|
||||
private var intrinsicContentSizeIsValid = false
|
||||
private var _intrinsicContentSize = CGSize.zero
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
self.isOpaque = false
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
super.init(coder: aDecoder)
|
||||
self.isOpaque = false
|
||||
}
|
||||
|
||||
override var intrinsicContentSize: CGSize {
|
||||
if !intrinsicContentSizeIsValid {
|
||||
var size = CGSize.zero
|
||||
if unreadCount > 0 {
|
||||
size = textSize()
|
||||
size.width += (padding.left + padding.right)
|
||||
size.height += (padding.top + padding.bottom)
|
||||
}
|
||||
_intrinsicContentSize = size
|
||||
intrinsicContentSizeIsValid = true
|
||||
}
|
||||
return _intrinsicContentSize
|
||||
}
|
||||
|
||||
override func invalidateIntrinsicContentSize() {
|
||||
intrinsicContentSizeIsValid = false
|
||||
}
|
||||
|
||||
private func textSize() -> CGSize {
|
||||
|
||||
if unreadCount < 1 {
|
||||
return CGSize.zero
|
||||
}
|
||||
|
||||
if let cachedSize = textSizeCache[unreadCount] {
|
||||
return cachedSize
|
||||
}
|
||||
|
||||
var size = unreadCountString.size(withAttributes: textAttributes)
|
||||
size.height = ceil(size.height)
|
||||
size.width = ceil(size.width)
|
||||
|
||||
textSizeCache[unreadCount] = size
|
||||
return size
|
||||
|
||||
}
|
||||
|
||||
private func textRect() -> CGRect {
|
||||
|
||||
let size = textSize()
|
||||
var r = CGRect.zero
|
||||
r.size = size
|
||||
r.origin.x = (bounds.maxX - padding.right) - r.size.width
|
||||
r.origin.y = padding.top
|
||||
return r
|
||||
|
||||
}
|
||||
|
||||
override func draw(_ dirtyRect: CGRect) {
|
||||
|
||||
let cornerRadii = CGSize(width: cornerRadius, height: cornerRadius)
|
||||
let path = UIBezierPath(roundedRect: bounds, byRoundingCorners: .allCorners, cornerRadii: cornerRadii)
|
||||
bgColor.setFill()
|
||||
path.fill()
|
||||
|
||||
if unreadCount > 0 {
|
||||
unreadCountString.draw(at: textRect().origin, withAttributes: textAttributes)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
121
iOS/Master/MasterPrimaryViewController.swift
Normal file
121
iOS/Master/MasterPrimaryViewController.swift
Normal file
@@ -0,0 +1,121 @@
|
||||
//
|
||||
// MasterPrimaryViewController.swift
|
||||
// NetNewsWire
|
||||
//
|
||||
// Created by Maurice Parker on 4/8/19.
|
||||
// Copyright © 2019 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import Account
|
||||
import RSCore
|
||||
import RSTree
|
||||
|
||||
class MasterPrimaryViewController: MasterViewController {
|
||||
|
||||
// MARK: Actions
|
||||
|
||||
@IBAction func showOPMLImportExport(_ sender: UIBarButtonItem) {
|
||||
|
||||
let optionMenu = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet)
|
||||
|
||||
let importOPML = UIAlertAction(title: "Import OPML", style: .default) { [unowned self] alertAction in
|
||||
let docPicker = UIDocumentPickerViewController(documentTypes: ["public.xml", "org.opml.opml"], in: .import)
|
||||
docPicker.delegate = self
|
||||
docPicker.modalPresentationStyle = .formSheet
|
||||
self.present(docPicker, animated: true)
|
||||
}
|
||||
optionMenu.addAction(importOPML)
|
||||
|
||||
let exportOPML = UIAlertAction(title: "Export OPML", style: .default) { [unowned self] alertAction in
|
||||
|
||||
let filename = "MySubscriptions.opml"
|
||||
let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent(filename)
|
||||
let opmlString = OPMLExporter.OPMLString(with: AccountManager.shared.localAccount, title: filename)
|
||||
do {
|
||||
try opmlString.write(to: tempFile, atomically: true, encoding: String.Encoding.utf8)
|
||||
} catch {
|
||||
self.presentError(title: "OPML Export Error", message: error.localizedDescription)
|
||||
}
|
||||
|
||||
let docPicker = UIDocumentPickerViewController(url: tempFile, in: .exportToService)
|
||||
docPicker.modalPresentationStyle = .formSheet
|
||||
self.present(docPicker, animated: true)
|
||||
|
||||
}
|
||||
optionMenu.addAction(exportOPML)
|
||||
optionMenu.addAction(UIAlertAction(title: "Cancel", style: .cancel))
|
||||
|
||||
if let popoverController = optionMenu.popoverPresentationController {
|
||||
popoverController.barButtonItem = sender
|
||||
}
|
||||
|
||||
self.present(optionMenu, animated: true)
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Table View
|
||||
|
||||
override func numberOfSections(in tableView: UITableView) -> Int {
|
||||
return treeController.rootNode.numberOfChildNodes
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
return treeController.rootNode.childAtIndex(section)?.numberOfChildNodes ?? 0
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||||
guard let nameProvider = treeController.rootNode.childAtIndex(section)?.representedObject as? DisplayNameProvider else {
|
||||
return nil
|
||||
}
|
||||
return nameProvider.nameForDisplay
|
||||
}
|
||||
|
||||
// MARK: API
|
||||
|
||||
override func delete(indexPath: IndexPath) {
|
||||
|
||||
guard let containerNode = treeController.rootNode.childAtIndex(indexPath.section),
|
||||
let deleteNode = containerNode.childAtIndex(indexPath.row),
|
||||
let container = containerNode.representedObject as? Container else {
|
||||
return
|
||||
}
|
||||
|
||||
animatingChanges = true
|
||||
|
||||
if let feed = deleteNode.representedObject as? Feed {
|
||||
container.deleteFeed(feed)
|
||||
}
|
||||
|
||||
if let folder = deleteNode.representedObject as? Folder {
|
||||
container.deleteFolder(folder)
|
||||
}
|
||||
|
||||
treeController.rebuild()
|
||||
tableView.deleteRows(at: [indexPath], with: .automatic)
|
||||
|
||||
animatingChanges = false
|
||||
|
||||
}
|
||||
|
||||
override func nodeFor(indexPath: IndexPath) -> Node? {
|
||||
return treeController.rootNode.childAtIndex(indexPath.section)?.childAtIndex(indexPath.row)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension MasterPrimaryViewController: UIDocumentPickerDelegate {
|
||||
|
||||
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
|
||||
|
||||
for url in urls {
|
||||
do {
|
||||
try OPMLImporter.parseAndImport(fileURL: url, account: AccountManager.shared.localAccount)
|
||||
} catch {
|
||||
presentError(title: "OPML Import Error", message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
74
iOS/Master/MasterSecondaryViewController.swift
Normal file
74
iOS/Master/MasterSecondaryViewController.swift
Normal file
@@ -0,0 +1,74 @@
|
||||
//
|
||||
// MasterSecondaryViewController.swift
|
||||
// NetNewsWire
|
||||
//
|
||||
// Created by Maurice Parker on 4/8/19.
|
||||
// Copyright © 2019 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import Account
|
||||
import RSCore
|
||||
import RSTree
|
||||
|
||||
class MasterSecondaryViewController: MasterViewController {
|
||||
|
||||
var viewRootNode: Node?
|
||||
|
||||
// MARK: - Table View
|
||||
|
||||
override func numberOfSections(in tableView: UITableView) -> Int {
|
||||
return 1
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
return viewRootNode?.numberOfChildNodes ?? 0
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {
|
||||
|
||||
if editingStyle == .delete {
|
||||
|
||||
guard let containerNode = viewRootNode,
|
||||
let deleteNode = containerNode.childAtIndex(indexPath.row),
|
||||
let container = containerNode.representedObject as? Container,
|
||||
let feed = deleteNode.representedObject as? Feed else {
|
||||
return
|
||||
}
|
||||
|
||||
animatingChanges = true
|
||||
container.deleteFeed(feed)
|
||||
treeController.rebuild()
|
||||
tableView.deleteRows(at: [indexPath], with: .fade)
|
||||
animatingChanges = false
|
||||
|
||||
} else if editingStyle == .insert {
|
||||
// Create a new instance of the appropriate class, insert it into the array, and add a new row to the table view.
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: API
|
||||
|
||||
override func delete(indexPath: IndexPath) {
|
||||
|
||||
guard let containerNode = viewRootNode,
|
||||
let deleteNode = containerNode.childAtIndex(indexPath.row),
|
||||
let container = containerNode.representedObject as? Container,
|
||||
let feed = deleteNode.representedObject as? Feed else {
|
||||
return
|
||||
}
|
||||
|
||||
animatingChanges = true
|
||||
container.deleteFeed(feed)
|
||||
treeController.rebuild()
|
||||
tableView.deleteRows(at: [indexPath], with: .fade)
|
||||
animatingChanges = false
|
||||
|
||||
}
|
||||
|
||||
override func nodeFor(indexPath: IndexPath) -> Node? {
|
||||
return viewRootNode?.childAtIndex(indexPath.row)
|
||||
}
|
||||
|
||||
}
|
||||
135
iOS/Master/MasterTreeControllerDelegate.swift
Normal file
135
iOS/Master/MasterTreeControllerDelegate.swift
Normal file
@@ -0,0 +1,135 @@
|
||||
//
|
||||
// MasterTreeControllerDelegate.swift
|
||||
// NetNewsWire
|
||||
//
|
||||
// Created by Maurice Parker on 4/8/19.
|
||||
// Copyright © 2019 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RSTree
|
||||
import Articles
|
||||
import Account
|
||||
|
||||
final class MasterTreeControllerDelegate: TreeControllerDelegate {
|
||||
|
||||
func treeController(treeController: TreeController, childNodesFor node: Node) -> [Node]? {
|
||||
|
||||
if node.isRoot {
|
||||
return childNodesForRootNode(node)
|
||||
}
|
||||
if node.representedObject is Container {
|
||||
return childNodesForContainerNode(node)
|
||||
}
|
||||
if node.representedObject is SmartFeedsController {
|
||||
return childNodesForSmartFeeds(node)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private extension MasterTreeControllerDelegate {
|
||||
|
||||
func childNodesForRootNode(_ rootNode: Node) -> [Node]? {
|
||||
|
||||
// The top-level nodes are Smart Feeds and accounts.
|
||||
|
||||
let smartFeedsNode = rootNode.existingOrNewChildNode(with: SmartFeedsController.shared)
|
||||
smartFeedsNode.canHaveChildNodes = true
|
||||
smartFeedsNode.isGroupItem = true
|
||||
|
||||
return [smartFeedsNode] + sortedAccountNodes(rootNode)
|
||||
|
||||
}
|
||||
|
||||
func childNodesForSmartFeeds(_ parentNode: Node) -> [Node] {
|
||||
|
||||
return SmartFeedsController.shared.smartFeeds.map { parentNode.existingOrNewChildNode(with: $0) }
|
||||
}
|
||||
|
||||
func childNodesForContainerNode(_ containerNode: Node) -> [Node]? {
|
||||
|
||||
let container = containerNode.representedObject as! Container
|
||||
|
||||
var children = [AnyObject]()
|
||||
children.append(contentsOf: Array(container.topLevelFeeds))
|
||||
if let folders = container.folders {
|
||||
children.append(contentsOf: Array(folders))
|
||||
}
|
||||
|
||||
var updatedChildNodes = [Node]()
|
||||
|
||||
children.forEach { (representedObject) in
|
||||
|
||||
if let existingNode = containerNode.childNodeRepresentingObject(representedObject) {
|
||||
if !updatedChildNodes.contains(existingNode) {
|
||||
updatedChildNodes += [existingNode]
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if let newNode = self.createNode(representedObject: representedObject, parent: containerNode) {
|
||||
updatedChildNodes += [newNode]
|
||||
}
|
||||
}
|
||||
|
||||
return updatedChildNodes.sortedAlphabeticallyWithFoldersAtEnd()
|
||||
}
|
||||
|
||||
func createNode(representedObject: Any, parent: Node) -> Node? {
|
||||
|
||||
if let feed = representedObject as? Feed {
|
||||
return createNode(feed: feed, parent: parent)
|
||||
}
|
||||
if let folder = representedObject as? Folder {
|
||||
return createNode(folder: folder, parent: parent)
|
||||
}
|
||||
if let account = representedObject as? Account {
|
||||
return createNode(account: account, parent: parent)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func createNode(feed: Feed, parent: Node) -> Node {
|
||||
|
||||
return parent.createChildNode(feed)
|
||||
}
|
||||
|
||||
func createNode(folder: Folder, parent: Node) -> Node {
|
||||
|
||||
let node = parent.createChildNode(folder)
|
||||
node.canHaveChildNodes = true
|
||||
return node
|
||||
}
|
||||
|
||||
func createNode(account: Account, parent: Node) -> Node {
|
||||
|
||||
let node = parent.createChildNode(account)
|
||||
node.canHaveChildNodes = true
|
||||
node.isGroupItem = true
|
||||
return node
|
||||
}
|
||||
|
||||
func sortedAccountNodes(_ parent: Node) -> [Node] {
|
||||
|
||||
let nodes = AccountManager.shared.accounts.map { (account) -> Node in
|
||||
let accountNode = parent.existingOrNewChildNode(with: account)
|
||||
accountNode.canHaveChildNodes = true
|
||||
accountNode.isGroupItem = true
|
||||
return accountNode
|
||||
}
|
||||
return nodes.sortedAlphabetically()
|
||||
}
|
||||
|
||||
func nodeInArrayRepresentingObject(_ nodes: [Node], _ representedObject: AnyObject) -> Node? {
|
||||
|
||||
for oneNode in nodes {
|
||||
if oneNode.representedObject === representedObject {
|
||||
return oneNode
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
320
iOS/Master/MasterViewController.swift
Normal file
320
iOS/Master/MasterViewController.swift
Normal file
@@ -0,0 +1,320 @@
|
||||
//
|
||||
// MasterViewController.swift
|
||||
// NetNewsWire
|
||||
//
|
||||
// Created by Maurice Parker on 4/8/19.
|
||||
// Copyright © 2019 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import Account
|
||||
import RSCore
|
||||
import RSTree
|
||||
|
||||
class MasterViewController: UITableViewController {
|
||||
|
||||
var animatingChanges = false
|
||||
|
||||
let treeControllerDelegate = MasterTreeControllerDelegate()
|
||||
lazy var treeController: TreeController = {
|
||||
return TreeController(delegate: treeControllerDelegate)
|
||||
}()
|
||||
|
||||
override func viewDidLoad() {
|
||||
|
||||
super.viewDidLoad()
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(unreadCountDidChange(_:)), name: .UnreadCountDidChange, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(containerChildrenDidChange(_:)), name: .ChildrenDidChange, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(batchUpdateDidPerform(_:)), name: .BatchUpdateDidPerform, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(faviconDidBecomeAvailable(_:)), name: .FaviconDidBecomeAvailable, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(feedSettingDidChange(_:)), name: .FeedSettingDidChange, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(displayNameDidChange(_:)), name: .DisplayNameDidChange, object: nil)
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(progressDidChange(_:)), name: .AccountRefreshProgressDidChange, object: nil)
|
||||
|
||||
refreshControl = UIRefreshControl()
|
||||
refreshControl!.addTarget(self, action: #selector(refreshAccounts(_:)), for: .valueChanged)
|
||||
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
clearsSelectionOnViewWillAppear = splitViewController!.isCollapsed
|
||||
super.viewWillAppear(animated)
|
||||
}
|
||||
|
||||
@objc private func refreshAccounts(_ sender: Any) {
|
||||
AccountManager.shared.refreshAll()
|
||||
}
|
||||
|
||||
@objc dynamic func progressDidChange(_ notification: Notification) {
|
||||
if AccountManager.shared.combinedRefreshProgress.isComplete {
|
||||
refreshControl?.endRefreshing()
|
||||
} else {
|
||||
refreshControl?.beginRefreshing()
|
||||
}
|
||||
}
|
||||
|
||||
@objc func containerChildrenDidChange(_ note: Notification) {
|
||||
rebuildTreeAndReloadDataIfNeeded()
|
||||
}
|
||||
|
||||
@objc func batchUpdateDidPerform(_ notification: Notification) {
|
||||
rebuildTreeAndReloadDataIfNeeded()
|
||||
}
|
||||
|
||||
@objc func unreadCountDidChange(_ note: Notification) {
|
||||
guard let representedObject = note.object else {
|
||||
return
|
||||
}
|
||||
configureUnreadCountForCellsForRepresentedObject(representedObject as AnyObject)
|
||||
}
|
||||
|
||||
@objc func faviconDidBecomeAvailable(_ note: Notification) {
|
||||
applyToAvailableCells(configureFavicon)
|
||||
}
|
||||
|
||||
@objc func feedSettingDidChange(_ note: Notification) {
|
||||
|
||||
guard let feed = note.object as? Feed, let key = note.userInfo?[Feed.FeedSettingUserInfoKey] as? String else {
|
||||
return
|
||||
}
|
||||
|
||||
if key == Feed.FeedSettingKey.homePageURL || key == Feed.FeedSettingKey.faviconURL {
|
||||
configureCellsForRepresentedObject(feed)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@objc func displayNameDidChange(_ note: Notification) {
|
||||
|
||||
guard let object = note.object else {
|
||||
return
|
||||
}
|
||||
|
||||
rebuildTreeAndReloadDataIfNeeded()
|
||||
configureCellsForRepresentedObject(object as AnyObject)
|
||||
|
||||
}
|
||||
|
||||
// MARK: Table View
|
||||
|
||||
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) as! MasterTableViewCell
|
||||
|
||||
guard let node = nodeFor(indexPath: indexPath) else {
|
||||
return cell
|
||||
}
|
||||
|
||||
configure(cell, node)
|
||||
return cell
|
||||
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
|
||||
guard let node = nodeFor(indexPath: indexPath), !(node.representedObject is PseudoFeed) else {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
|
||||
|
||||
// Set up the delete action
|
||||
let deleteTitle = NSLocalizedString("Delete", comment: "Delete")
|
||||
let deleteAction = UIContextualAction(style: .normal, title: deleteTitle) { [weak self] (action, view, completionHandler) in
|
||||
self?.delete(indexPath: indexPath)
|
||||
completionHandler(true)
|
||||
}
|
||||
|
||||
deleteAction.backgroundColor = UIColor.red
|
||||
|
||||
// Set up the rename action
|
||||
let renameTitle = NSLocalizedString("Rename", comment: "Rename")
|
||||
let renameAction = UIContextualAction(style: .normal, title: renameTitle) { [weak self] (action, view, completionHandler) in
|
||||
self?.rename(indexPath: indexPath)
|
||||
completionHandler(true)
|
||||
}
|
||||
|
||||
renameAction.backgroundColor = UIColor.gray
|
||||
|
||||
return UISwipeActionsConfiguration(actions: [deleteAction, renameAction])
|
||||
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
|
||||
guard let node = nodeFor(indexPath: indexPath) else {
|
||||
assertionFailure()
|
||||
return
|
||||
}
|
||||
|
||||
if let pseudoFeed = node.representedObject as? PseudoFeed {
|
||||
let timeline = UIStoryboard.main.instantiateController(ofType: MasterTimelineViewController.self)
|
||||
timeline.title = pseudoFeed.nameForDisplay
|
||||
timeline.representedObjects = [pseudoFeed]
|
||||
self.navigationController?.pushViewController(timeline, animated: true)
|
||||
}
|
||||
|
||||
if let folder = node.representedObject as? Folder {
|
||||
let secondary = UIStoryboard.main.instantiateController(ofType: MasterSecondaryViewController.self)
|
||||
secondary.title = folder.nameForDisplay
|
||||
secondary.viewRootNode = node
|
||||
self.navigationController?.pushViewController(secondary, animated: true)
|
||||
}
|
||||
|
||||
if let feed = node.representedObject as? Feed {
|
||||
let timeline = UIStoryboard.main.instantiateController(ofType: MasterTimelineViewController.self)
|
||||
timeline.title = feed.nameForDisplay
|
||||
timeline.representedObjects = [feed]
|
||||
self.navigationController?.pushViewController(timeline, animated: true)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: Actions
|
||||
|
||||
@IBAction func addFeed(_ sender: UIBarButtonItem) {
|
||||
let feedViewController = UIStoryboard(name: "AddFeed", bundle: nil).instantiateViewController(withIdentifier: "AddFeedNavigationController")
|
||||
feedViewController.modalPresentationStyle = .popover
|
||||
feedViewController.popoverPresentationController?.barButtonItem = sender
|
||||
self.present(feedViewController, animated: true)
|
||||
}
|
||||
|
||||
@IBAction func addFolder(_ sender: UIBarButtonItem) {
|
||||
let feedViewController = UIStoryboard(name: "AddFolder", bundle: nil).instantiateViewController(withIdentifier: "AddFolderNavigationController")
|
||||
feedViewController.modalPresentationStyle = .popover
|
||||
feedViewController.popoverPresentationController?.barButtonItem = sender
|
||||
self.present(feedViewController, animated: true)
|
||||
}
|
||||
|
||||
// MARK: API
|
||||
|
||||
func configure(_ cell: MasterTableViewCell, _ node: Node) {
|
||||
cell.name = nameFor(node)
|
||||
configureUnreadCount(cell, node)
|
||||
configureFavicon(cell, node)
|
||||
cell.shouldShowImage = node.representedObject is SmallIconProvider
|
||||
}
|
||||
|
||||
func configureUnreadCount(_ cell: MasterTableViewCell, _ node: Node) {
|
||||
cell.unreadCount = unreadCountFor(node)
|
||||
}
|
||||
|
||||
func configureFavicon(_ cell: MasterTableViewCell, _ node: Node) {
|
||||
cell.faviconImage = imageFor(node)
|
||||
}
|
||||
|
||||
func imageFor(_ node: Node) -> UIImage? {
|
||||
if let smallIconProvider = node.representedObject as? SmallIconProvider {
|
||||
return smallIconProvider.smallIcon
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func nameFor(_ node: Node) -> String {
|
||||
if let displayNameProvider = node.representedObject as? DisplayNameProvider {
|
||||
return displayNameProvider.nameForDisplay
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func unreadCountFor(_ node: Node) -> Int {
|
||||
if let unreadCountProvider = node.representedObject as? UnreadCountProvider {
|
||||
return unreadCountProvider.unreadCount
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func delete(indexPath: IndexPath) {
|
||||
assertionFailure()
|
||||
}
|
||||
|
||||
func rename(indexPath: IndexPath) {
|
||||
|
||||
let title = NSLocalizedString("Rename", comment: "Rename")
|
||||
let alertController = UIAlertController(title: title, message: nil, preferredStyle: .alert)
|
||||
|
||||
let cancelTitle = NSLocalizedString("Cancel", comment: "Cancel")
|
||||
alertController.addAction(UIAlertAction(title: cancelTitle, style: .cancel))
|
||||
|
||||
// TODO: Add the title of what is being renamed...
|
||||
// let formatString = NSLocalizedString("Rename “%@”", comment: "Feed finder")
|
||||
// let message = NSString.localizedStringWithFormat(formatString as NSString, )
|
||||
|
||||
|
||||
let renameTitle = NSLocalizedString("Rename", comment: "Rename")
|
||||
let renameAction = UIAlertAction(title: renameTitle, style: .default) { [weak self] action in
|
||||
|
||||
guard let node = self?.nodeFor(indexPath: indexPath),
|
||||
let name = alertController.textFields?[0].text,
|
||||
!name.isEmpty else {
|
||||
return
|
||||
}
|
||||
|
||||
if let feed = node.representedObject as? Feed {
|
||||
feed.editedName = name
|
||||
} else if let folder = node.representedObject as? Folder {
|
||||
folder.name = name
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
alertController.addAction(renameAction)
|
||||
|
||||
alertController.addTextField() { textField in
|
||||
textField.placeholder = NSLocalizedString("Name", comment: "Name")
|
||||
}
|
||||
|
||||
self.present(alertController, animated: true) {
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func nodeFor(indexPath: IndexPath) -> Node? {
|
||||
assertionFailure()
|
||||
return nil
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: Private
|
||||
|
||||
private extension MasterViewController {
|
||||
|
||||
func rebuildTreeAndReloadDataIfNeeded() {
|
||||
if !animatingChanges && !BatchUpdate.shared.isPerforming {
|
||||
treeController.rebuild()
|
||||
tableView.reloadData()
|
||||
}
|
||||
}
|
||||
|
||||
func configureCellsForRepresentedObject(_ representedObject: AnyObject) {
|
||||
|
||||
applyToCellsForRepresentedObject(representedObject, configure)
|
||||
}
|
||||
|
||||
func configureUnreadCountForCellsForRepresentedObject(_ representedObject: AnyObject) {
|
||||
applyToCellsForRepresentedObject(representedObject, configureUnreadCount)
|
||||
}
|
||||
|
||||
func applyToCellsForRepresentedObject(_ representedObject: AnyObject, _ callback: (MasterTableViewCell, Node) -> Void) {
|
||||
applyToAvailableCells { (cell, node) in
|
||||
if node.representedObject === representedObject {
|
||||
callback(cell, node)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func applyToAvailableCells(_ callback: (MasterTableViewCell, Node) -> Void) {
|
||||
tableView.visibleCells.forEach { cell in
|
||||
guard let indexPath = tableView.indexPath(for: cell), let node = nodeFor(indexPath: indexPath) else {
|
||||
return
|
||||
}
|
||||
callback(cell as! MasterTableViewCell, node)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user