diff --git a/iOS/MainWindow/Sidebar/SidebarTree.swift b/iOS/MainWindow/Sidebar/SidebarTree.swift new file mode 100644 index 000000000..50cdbf0b9 --- /dev/null +++ b/iOS/MainWindow/Sidebar/SidebarTree.swift @@ -0,0 +1,259 @@ +// +// SidebarTree.swift +// NetNewsWire-iOS +// +// Created by Brent Simmons on 2/7/25. +// Copyright © 2025 Ranchero Software. All rights reserved. +// + +import Foundation +import Account + +typealias SectionID = Int +typealias ItemID = Int + +protocol Section: Identifiable {} +protocol Item: Identifiable {} + +@MainActor protocol SidebarContainer: Identifiable { + + var isExpanded: Bool { get set } + var items: [any Item] { get } + + func updateItems() +} + +extension SidebarContainer { + + func createFeedsDictionary() -> [String: SidebarFeed] { + var d = [String: SidebarFeed]() // feedID: SidebarFeed + for item in items where item is SidebarFeed { + let sidebarFeed = item as! SidebarFeed + d[sidebarFeed.feedID] = sidebarFeed + } + return d + } + + func createFoldersDictionary() -> [Int: SidebarFolder] { + var d = [Int: SidebarFolder]() + for item in items where item is SidebarFolder { + let sidebarFolder = item as! SidebarFolder + d[sidebarFolder.folderID] = sidebarFolder + } + return d + } +} + +// MARK: - SidebarSmartFeedsFolder + +@MainActor final class SidebarSmartFeedsFolder: Section, SidebarContainer { + + let id = createID() + var isExpanded = true + + let items: [any Item] = [ + SidebarSmartFeed(SmartFeedsController.shared.todayFeed), + SidebarSmartFeed(SmartFeedsController.shared.unreadFeed), + SidebarSmartFeed(SmartFeedsController.shared.starredFeed) + ] + + func updateItems() { + } +} + + +// MARK: - SidebarSmartFeed + +@MainActor final class SidebarSmartFeed: Item { + + let id = createID() + let smartFeed: any PseudoFeed + + init(_ smartFeed: any PseudoFeed) { + self.smartFeed = smartFeed + } +} + +// MARK: - SidebarAccount + +@MainActor final class SidebarAccount: Section, SidebarContainer { + + let id = createID() + let accountID: String + weak var account: Account? + var isExpanded = true + + var items = [any Item]() { // top-level feeds and folders + didSet { + feedsDictionary = createFeedsDictionary() + foldersDictionary = createFoldersDictionary() + } + } + + private var feedsDictionary = [String: SidebarFeed]() + private var foldersDictionary = [Int: SidebarFolder]() + + init(_ account: Account) { + self.accountID = account.accountID + self.account = account + updateItems() + } + + func updateItems() { + + guard let account else { + items = [any Item]() + return + } + + var sidebarFeeds = [SidebarFeed]() + for feed in account.topLevelFeeds { + if let existingFeed = feedsDictionary[feed.feedID] { + sidebarFeeds.append(existingFeed) + } else { + sidebarFeeds.append(SidebarFeed(feed)) + } + } + + var sidebarFolders = [SidebarFolder]() + if let folders = account.folders { + for folder in folders { + if let existingFolder = foldersDictionary[folder.folderID] { + sidebarFolders.append(existingFolder) + } else { + sidebarFolders.append(SidebarFolder(folder)) + } + } + } + + for folder in sidebarFolders { + folder.updateItems() + } + + // TODO: sort feeds + items = sidebarFeeds + sidebarFolders + } +} + +// MARK: - SidebarFolder + +@MainActor final class SidebarFolder: Item, SidebarContainer { + + let id = createID() + let folderID: Int + weak var folder: Folder? + var isExpanded = false + var items = [any Item]() { // SidebarFeed + didSet { + feedsDictionary = createFeedsDictionary() + } + } + + private var feedsDictionary = [String: SidebarFeed]() + + init(_ folder: Folder) { + self.folderID = folder.folderID + self.folder = folder + updateItems() + } + + func updateItems() { + + guard let folder else { + items = [any Item]() + return + } + + var sidebarFeeds = [any Item]() + for feed in folder.topLevelFeeds { + if let existingFeed = feedsDictionary[feed.feedID] { + sidebarFeeds.append(existingFeed) + } else { + sidebarFeeds.append(SidebarFeed(feed)) + } + } + + // TODO: sort feeds + items = sidebarFeeds + } +} + +// MARK: - SidebarFeed + +@MainActor final class SidebarFeed: Item { + + let id = createID() + let feedID: String + weak var feed: Feed? + + init(_ feed: Feed) { + self.feedID = feed.feedID + self.feed = feed + } +} + +// MARK: - SidebarTree + +@MainActor final class SidebarTree { + + var sections = [any Section]() + + private lazy var sidebarSmartFeedsFolder = SidebarSmartFeedsFolder() + + func rebuildTree() { + + rebuildSections() + + for section in sections where section is SidebarAccount { + let sidebarAccount = section as! SidebarAccount + sidebarAccount.updateItems() + } + } +} + +private extension SidebarTree { + + func rebuildSections() { + + var updatedSections = [any Section]() + + updatedSections.append(sidebarSmartFeedsFolder) + + for account in AccountManager.shared.activeAccounts { + if let existingSection = existingSection(for: account) { + updatedSections.append(existingSection) + } else { + updatedSections.append(SidebarAccount(account)) + } + } + + // TODO: sort accounts + + sections = updatedSections + } + + func existingSection(for account: Account) -> (any Section)? { + + // Linear search through array because it’s just a few items. + + let accountID = account.accountID + + for sidebarSection in sections where sidebarSection is SidebarAccount { + let sidebarAccount = sidebarSection as! SidebarAccount + if sidebarAccount.accountID == accountID { + return sidebarAccount + } + } + + return nil + } +} + +// MARK: - IDs + +@MainActor private var autoIncrementingID = 0 + +@MainActor private func createID() -> Int { + defer { autoIncrementingID += 1 } + return autoIncrementingID +} diff --git a/iOS/MainWindow/Sidebar/SidebarViewController.swift b/iOS/MainWindow/Sidebar/SidebarViewController.swift index 0f1cc0048..70cee3577 100644 --- a/iOS/MainWindow/Sidebar/SidebarViewController.swift +++ b/iOS/MainWindow/Sidebar/SidebarViewController.swift @@ -1,136 +1,213 @@ +//// +//// SidebarViewController.swift +//// NetNewsWire-iOS +//// +//// Created by Brent Simmons on 2/2/25. +//// Copyright © 2025 Ranchero Software. All rights reserved. +//// // -// SidebarViewController.swift -// NetNewsWire-iOS +//import Foundation +//import UIKit +//import Account // -// Created by Brent Simmons on 2/2/25. -// Copyright © 2025 Ranchero Software. All rights reserved. +//final class SidebarViewController: UICollectionViewController { // - -import Foundation -import UIKit - -final class SidebarViewController: UICollectionViewController { - - enum Section { - case smartFeeds - } - - struct SidebarItem: Hashable, Identifiable { - let id: UUID = UUID() - let title: String - let icon: UIImage? - } - - typealias DataSource = UICollectionViewDiffableDataSource - private lazy var dataSource = createDataSource() - - private lazy var filterButton = UIBarButtonItem( - image: AppImage.filterInactive, - style: .plain, - target: self, - action: #selector(toggleFilter(_:)) - ) - - private lazy var settingsButton = UIBarButtonItem( - image: AppImage.settings, - style: .plain, - target: self, - action: #selector(showSettings(_:)) - ) - - private lazy var addNewItemButton = UIBarButtonItem(systemItem: .add) - - private lazy var refreshProgressItemButton = UIBarButtonItem(customView: refreshProgressView) - private lazy var refreshProgressView: RefreshProgressView = Bundle.main.loadNibNamed("RefreshProgressView", owner: self, options: nil)?[0] as! RefreshProgressView - - private lazy var flexibleSpaceBarButtonItem = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil) - - private lazy var toolbar = UIToolbar() - - init() { - let configuration = UICollectionLayoutListConfiguration(appearance: .sidebar) - let layout = UICollectionViewCompositionalLayout.list(using: configuration) - - super.init(collectionViewLayout: layout) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidLoad() { - - super.viewDidLoad() - - collectionView.contentInset = UIEdgeInsets(top: 10, left: 0, bottom: 0, right: 0) - - title = "Feeds" - navigationController?.navigationBar.prefersLargeTitles = true - navigationItem.rightBarButtonItem = filterButton - - toolbar.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(toolbar) - - NSLayoutConstraint.activate([ - toolbar.leadingAnchor.constraint(equalTo: view.leadingAnchor), - toolbar.trailingAnchor.constraint(equalTo: view.trailingAnchor), - toolbar.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), - toolbar.heightAnchor.constraint(equalToConstant: 44) - ]) - - let toolbarItems = [ - settingsButton, - flexibleSpaceBarButtonItem, - refreshProgressItemButton, - flexibleSpaceBarButtonItem, - addNewItemButton - ] - toolbar.setItems(toolbarItems, animated: false) - - applySnapshot() - } -} - -// MARK: - Actions - -// TODO: Implement actions - -extension SidebarViewController { - - @objc func toggleFilter(_ sender: Any) { - } - - @objc func showSettings(_ sender: Any?) { - } -} - -private extension SidebarViewController { - - private func createDataSource() -> DataSource { - let cellRegistration = UICollectionView.CellRegistration { (cell, indexPath, item) in - var content = UIListContentConfiguration.cell() - content.text = item.title - content.image = item.icon - cell.contentConfiguration = content - } - - dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { (collectionView, indexPath, item) in - return collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: item) - } - - return dataSource - } - - private func applySnapshot() { - var snapshot = NSDiffableDataSourceSnapshot() - - snapshot.appendSections([.smartFeeds]) - snapshot.appendItems([ - SidebarItem(title: "Today", icon: AppImage.today), - SidebarItem(title: "All Unread", icon: AppImage.allUnread), - SidebarItem(title: "Starred", icon: AppImage.starred) - ]) - - dataSource.apply(snapshot, animatingDifferences: true) - } -} +// typealias DataSource = UICollectionViewDiffableDataSource +// private lazy var dataSource = createDataSource() +// +// private lazy var filterButton = UIBarButtonItem( +// image: AppImage.filterInactive, +// style: .plain, +// target: self, +// action: #selector(toggleFilter(_:)) +// ) +// +// private lazy var settingsButton = UIBarButtonItem( +// image: AppImage.settings, +// style: .plain, +// target: self, +// action: #selector(showSettings(_:)) +// ) +// +// private lazy var addNewItemButton = UIBarButtonItem(systemItem: .add) +// +// private lazy var refreshProgressItemButton = UIBarButtonItem(customView: refreshProgressView) +// private lazy var refreshProgressView: RefreshProgressView = Bundle.main.loadNibNamed("RefreshProgressView", owner: self, options: nil)?[0] as! RefreshProgressView +// +// private lazy var flexibleSpaceBarButtonItem = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil) +// +// private lazy var toolbar = UIToolbar() +// +// init() { +// var configuration = UICollectionLayoutListConfiguration(appearance: .sidebar) +// configuration.headerMode = .supplementary +// let layout = UICollectionViewCompositionalLayout.list(using: configuration) +// +// super.init(collectionViewLayout: layout) +// } +// +// required init?(coder: NSCoder) { +// fatalError("init(coder:) has not been implemented") +// } +// +// override func viewDidLoad() { +// +// super.viewDidLoad() +// +// collectionView.contentInset = UIEdgeInsets(top: 10, left: 0, bottom: 0, right: 0) +// +// title = "Feeds" +// navigationController?.navigationBar.prefersLargeTitles = true +// navigationItem.rightBarButtonItem = filterButton +// +// toolbar.translatesAutoresizingMaskIntoConstraints = false +// view.addSubview(toolbar) +// +// NSLayoutConstraint.activate([ +// toolbar.leadingAnchor.constraint(equalTo: view.leadingAnchor), +// toolbar.trailingAnchor.constraint(equalTo: view.trailingAnchor), +// toolbar.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), +// toolbar.heightAnchor.constraint(equalToConstant: 44) +// ]) +// +// let toolbarItems = [ +// settingsButton, +// flexibleSpaceBarButtonItem, +// refreshProgressItemButton, +// flexibleSpaceBarButtonItem, +// addNewItemButton +// ] +// toolbar.setItems(toolbarItems, animated: false) +// +// collectionView.register(SidebarHeaderView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: SidebarHeaderView.reuseIdentifier) +// +// applySnapshot() +// } +//} +// +//// MARK: - Actions +// +//// TODO: Implement actions +// +//extension SidebarViewController { +// +// @objc func toggleFilter(_ sender: Any) { +// } +// +// @objc func showSettings(_ sender: Any?) { +// } +//} +// +//// MARK: - SidebarHeaderViewDelegate +// +//extension SidebarViewController: SidebarHeaderViewDelegate { +// +// func sidebarHeaderViewUserDidToggleExpanded(_ sidebarHeaderView: SidebarHeaderView) { +// var section = sections[sidebarHeaderView.sectionIndex] +// section.isExpanded.toggle() +// applySnapshot() +// } +//} +// +//private extension SidebarViewController { +// +// func createDataSource() -> DataSource { +// let cellRegistration = UICollectionView.CellRegistration { (cell, _, item) in +// var content = UIListContentConfiguration.cell() +// content.text = item.title +// content.image = item.icon +// cell.contentConfiguration = content +// } +// +// dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { (collectionView, indexPath, item) in +// return collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: item) +// } +// +// let headerRegistration = UICollectionView.SupplementaryRegistration(elementKind: UICollectionView.elementKindSectionHeader) { header, _, indexPath in +// +// let section = self.dataSource.snapshot().sectionIdentifiers[indexPath.section] +// +// var content = UIListContentConfiguration.sidebarHeader() +// content.text = sectionTitle(for: section) +// header.contentConfiguration = content +// +// // Add a disclosure indicator to show collapsible behavior +// let button = UIButton(type: .system) +// button.setImage(UIImage(systemName: "chevron.down"), for: .normal) +// button.tintColor = .secondaryLabel +// button.addTarget(self, action: #selector(self.toggleSection(_:)), for: .touchUpInside) +// button.tag = indexPath.section +// header.accessories = [.customView(configuration: .init(customView: button, placement: .trailing(displayed: .always)))] +// +// // Rotate chevron based on section state +// button.transform = sectionStates[section] == true ? .identity : CGAffineTransform(rotationAngle: -.pi / 2) +// } +// +// dataSource.supplementaryViewProvider = { collectionView, kind, indexPath in +// let header = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: SidebarHeaderView.reuseIdentifier, for: indexPath) as! SidebarHeaderView +// let section = self.sections[indexPath.section] +// header.configure(title: section.title, sectionIndex: indexPath.section, delegate: self) +// return header +// } +// +// return dataSource +// } +// +// func applySnapshot() { +// var snapshot = NSDiffableDataSourceSnapshot() +// +// for section in sections { +// snapshot.appendSections([section]) +// if section.isExpanded { +// snapshot.appendItems(section.items, toSection: section) +// } +// } +// +// dataSource.apply(snapshot, animatingDifferences: true) +// } +//} +// +//protocol SidebarHeaderViewDelegate: AnyObject { +// +// func sidebarHeaderViewUserDidToggleExpanded(_: SidebarHeaderView) +//} +// +//final class SidebarHeaderView: UICollectionReusableView { +// +// static let reuseIdentifier = "SidebarHeaderView" +// +// var sectionIndex: Int = 0 +// var delegate: SidebarHeaderViewDelegate? +// +// override init(frame: CGRect) { +// super.init(frame: frame) +// backgroundColor = .darkGray +// +// let tapGesture = UITapGestureRecognizer(target: self, action: #selector(toggleExpanded(_:))) +// addGestureRecognizer(tapGesture) +// } +// +// required init?(coder: NSCoder) { +// fatalError("init(coder:) has not been implemented") +// } +// +// func configure(title: String, sectionIndex: Int, delegate: SidebarHeaderViewDelegate) { +// self.sectionIndex = sectionIndex +// self.delegate = delegate +// +// let label = UILabel(frame: bounds) +// label.text = title +// label.textColor = .white +// label.textAlignment = .center +// addSubview(label) +// } +// +// @objc func toggleExpanded(_ sender: Any?) { +// delegate?.sidebarHeaderViewUserDidToggleExpanded(self) +//// guard let controller else { +//// return +//// } +//// controller.sections[sectionIndex].state.toggle() +//// controller.applySnapshot() +// } +//}