Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP Support multiple open items #782

Closed
wants to merge 29 commits into from
Closed
Changes from 1 commit
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
a028a1d
Keep track of open items count in items navigation bar
mvasilak Sep 22, 2023
be265cb
Improve AppCoordinator code
mvasilak Oct 3, 2023
fb62202
Simplify SplitControllerDelegate
mvasilak Oct 5, 2023
f2a141e
Improve AppCoordinator code
mvasilak Oct 5, 2023
dc5a323
Remove redundant logic
mvasilak Oct 6, 2023
5443bbe
Improve AppCoordinator code
mvasilak Oct 10, 2023
0e2b14c
Add ability to restore most recently opened item
mvasilak Oct 15, 2023
3481f50
Implement state restoration for open items
mvasilak Oct 16, 2023
104b562
Allow switch to another open item via bar button menu
mvasilak Oct 18, 2023
8299bd3
Make showing a screenshot window for seamless presentation a protocol
mvasilak Oct 18, 2023
e547c4c
Use instant presenter to switch between open items
mvasilak Oct 18, 2023
392ddaa
Fix PDFReaderViewController regression
mvasilak Oct 25, 2023
3d1a5d4
Fix loading of open item from database
mvasilak Oct 25, 2023
3b9c3bc
Remove unnecessary indentation in AppCoordinator
mvasilak Oct 25, 2023
94bbefe
Maintain user order of open items
mvasilak Oct 26, 2023
61fb72f
Refactor NoteEditorViewController
mvasilak Oct 31, 2023
a3aec94
Refactor DetailCoordinator
mvasilak Nov 1, 2023
323d58a
Refactor DetailCoordinator
mvasilak Nov 1, 2023
8908dce
Refactor note editing
mvasilak Nov 1, 2023
7ae432d
Refactor NoteEditorCoordinator.SaveResult
mvasilak Nov 1, 2023
1963a5a
Add support for notes in open items
mvasilak Nov 1, 2023
b4c7e07
Improve OpenItemsController.Item kind coding
mvasilak Nov 7, 2023
6821eb3
Fix NoteEditorViewController open items button logic
mvasilak Nov 23, 2023
61fbe36
Cleanup unused notification
mvasilak Nov 28, 2023
1f91dc3
Validate open items when set on app launch
mvasilak Nov 28, 2023
7b79067
Observe open items for deletions
mvasilak Nov 29, 2023
b9e243a
Improve code
mvasilak Nov 29, 2023
a168093
Improve code
mvasilak Nov 30, 2023
4a1acf5
Add support for different open items per session
mvasilak Nov 30, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
Keep track of open items count in items navigation bar
mvasilak committed Dec 2, 2023
commit a028a1d1b3e253b1360b7dd03ba93020a3c59f5d
4 changes: 4 additions & 0 deletions Zotero.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
@@ -29,6 +29,7 @@
61C817F22A49B5D30085B1E6 /* CollectionResponseSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3B1EDEE250242E700D8BC1E /* CollectionResponseSpec.swift */; };
61FA14CE2B05081D00E7D423 /* TextConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61FA14CD2B05081D00E7D423 /* TextConverter.swift */; };
61FA14D02B08E24A00E7D423 /* ColorPickerStackView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61FA14CF2B08E24A00E7D423 /* ColorPickerStackView.swift */; };
61E24DCC2ABB385E00D75F50 /* OpenItemsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61E24DCB2ABB385E00D75F50 /* OpenItemsController.swift */; };
B300B33324291C8D00C1FE1E /* RTranslatorMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = B300B33224291C8D00C1FE1E /* RTranslatorMetadata.swift */; };
B300B3352429222B00C1FE1E /* TranslatorMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = B300B3342429222B00C1FE1E /* TranslatorMetadata.swift */; };
B300B3362429234C00C1FE1E /* TranslatorMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = B300B3342429222B00C1FE1E /* TranslatorMetadata.swift */; };
@@ -1211,6 +1212,7 @@
61BD13942A5831EF008A0704 /* TextKit1TextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextKit1TextView.swift; sourceTree = "<group>"; };
61FA14CD2B05081D00E7D423 /* TextConverter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextConverter.swift; sourceTree = "<group>"; };
61FA14CF2B08E24A00E7D423 /* ColorPickerStackView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorPickerStackView.swift; sourceTree = "<group>"; };
61E24DCB2ABB385E00D75F50 /* OpenItemsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenItemsController.swift; sourceTree = "<group>"; };
B300B33224291C8D00C1FE1E /* RTranslatorMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RTranslatorMetadata.swift; sourceTree = "<group>"; };
B300B3342429222B00C1FE1E /* TranslatorMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranslatorMetadata.swift; sourceTree = "<group>"; };
B300B3372429254900C1FE1E /* SyncTranslatorsDbRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncTranslatorsDbRequest.swift; sourceTree = "<group>"; };
@@ -2162,6 +2164,7 @@
B3A17D1827FC33B800322CAD /* LowPowerModeController.swift */,
B3C43C1F28589F300007076D /* NotePreviewGenerator.swift */,
B305646C23FC051E003304F2 /* ObjectUserChangeObserver.swift */,
61E24DCB2ABB385E00D75F50 /* OpenItemsController.swift */,
B34A9F6325BF1ABB007C9A4A /* PdfDocumentExporter.swift */,
B3C6D551261C9F2E0068B9FE /* PlaceholderTextViewDelegate.swift */,
B378F4CC242CD45700B88A05 /* RepoParserDelegate.swift */,
@@ -4832,6 +4835,7 @@
B36181EC24C96B0500B30D56 /* SearchableCollection.swift in Sources */,
B3830CDB255451AB00910FE0 /* TagPickerAction.swift in Sources */,
B3593F40241A61C700760E20 /* ItemCell.swift in Sources */,
61E24DCC2ABB385E00D75F50 /* OpenItemsController.swift in Sources */,
B305679023FC1D9B003304F2 /* CollectionDifference+Separated.swift in Sources */,
B34ACC7A2514EAAB00040C17 /* AnnotationColorGenerator.swift in Sources */,
B3ADAE4F2833BEDC00D46271 /* LookupState.swift in Sources */,
1 change: 1 addition & 0 deletions Zotero/Assets/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
@@ -140,6 +140,7 @@
"items.generating_bib" = "Generating Bibliography";
"items.creator_summary.and" = "%@ and %@";
"items.creator_summary.etal" = "%@ et al.";
"items.restore_open" = "Restore Open Items";

"lookup.title" = "Enter ISBNs, DOls, PMIDs, arXiv IDs, or ADS Bibcodes to add to your library:";

2 changes: 2 additions & 0 deletions Zotero/Controllers/Controllers.swift
Original file line number Diff line number Diff line change
@@ -306,6 +306,7 @@ final class UserControllers {
let citationController: CitationController
let webDavController: WebDavController
let customUrlController: CustomURLController
let openItemsController: OpenItemsController
private let isFirstLaunch: Bool
private let lastBuildNumber: Int?
private unowned let translatorsAndStylesController: TranslatorsAndStylesController
@@ -382,6 +383,7 @@ final class UserControllers {
self.translatorsAndStylesController = controllers.translatorsAndStylesController
self.idleTimerController = controllers.idleTimerController
self.customUrlController = CustomURLController(dbStorage: dbStorage, fileStorage: controllers.fileStorage)
openItemsController = OpenItemsController()
self.lastBuildNumber = controllers.lastBuildNumber
self.disposeBag = DisposeBag()
}
40 changes: 40 additions & 0 deletions Zotero/Controllers/OpenItemsController.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
//
// OpenItemsController.swift
// Zotero
//
// Created by Miltiadis Vasilakis on 20/9/23.
// Copyright © 2023 Corporation for Digital Scholarship. All rights reserved.
//

import Foundation
import RxSwift

import CocoaLumberjackSwift

final class OpenItemsController {
// MARK: Types
enum Item: Hashable, Equatable {
case pdf(library: Library, key: String, url: URL)
}

// MARK: Properties
private(set) var items: [Item] = []
let observable: PublishSubject<[Item]>
private let disposeBag: DisposeBag

// MARK: Object Lifecycle
init() {
self.observable = PublishSubject()
self.disposeBag = DisposeBag()
}

// MARK: Actions
func open(_ item: Item) {
// TODO: Use a better data structure, such as an ordered set
// TODO: Keep track of last opened item
guard !items.contains(where: { $0 == item }) else { return }
items.append(item)
DDLogInfo("OpenItemsController: opened \(item)")
observable.on(.next(items))
}
}
2 changes: 2 additions & 0 deletions Zotero/Extensions/Localizable.swift
Original file line number Diff line number Diff line change
@@ -785,6 +785,8 @@ internal enum L10n {
}
/// Remove from Collection
internal static let removeFromCollectionTitle = L10n.tr("Localizable", "items.remove_from_collection_title", fallback: "Remove from Collection")
/// Restore Open Items
internal static let restoreOpen = L10n.tr("Localizable", "items.restore_open", fallback: "Restore Open Items")
/// Search Items
internal static let searchTitle = L10n.tr("Localizable", "items.search_title", fallback: "Search Items")
/// Select All
6 changes: 5 additions & 1 deletion Zotero/Scenes/Detail/DetailCoordinator.swift
Original file line number Diff line number Diff line change
@@ -127,6 +127,7 @@ final class DetailCoordinator: Coordinator {
syncScheduler: userControllers.syncScheduler,
citationController: userControllers.citationController,
fileCleanupController: userControllers.fileCleanupController,
openItemsController: userControllers.openItemsController,
itemsTagFilterDelegate: self.itemsTagFilterDelegate,
htmlAttributedStringConverter: self.controllers.htmlAttributedStringConverter
)
@@ -143,6 +144,7 @@ final class DetailCoordinator: Coordinator {
syncScheduler: SynchronizationScheduler,
citationController: CitationController,
fileCleanupController: AttachmentFileCleanupController,
openItemsController: OpenItemsController,
itemsTagFilterDelegate: ItemsTagFilterDelegate?,
htmlAttributedStringConverter: HtmlAttributedStringConverter
) -> ItemsViewController {
@@ -161,7 +163,8 @@ final class DetailCoordinator: Coordinator {
downloadBatchData: downloadBatchData,
remoteDownloadBatchData: remoteDownloadBatchData,
identifierLookupBatchData: identifierLookupBatchData,
error: nil
error: nil,
openItemsCount: openItemsController.items.count
)
let handler = ItemsActionHandler(
dbStorage: dbStorage,
@@ -293,6 +296,7 @@ final class DetailCoordinator: Coordinator {
self.childCoordinators.append(coordinator)
coordinator.start(animated: false)

controllers.userControllers?.openItemsController.open(.pdf(library: library, key: key, url: url))
self.navigationController?.present(navigationController, animated: true, completion: nil)
}

1 change: 1 addition & 0 deletions Zotero/Scenes/Detail/Items/Models/ItemsAction.swift
Original file line number Diff line number Diff line change
@@ -49,4 +49,5 @@ enum ItemsAction {
case quickCopyBibliography(Set<String>, LibraryIdentifier, WKWebView)
case startSync
case emptyTrash
case updateOpenItems(items: [OpenItemsController.Item])
}
6 changes: 5 additions & 1 deletion Zotero/Scenes/Detail/Items/Models/ItemsState.swift
Original file line number Diff line number Diff line change
@@ -24,6 +24,7 @@ struct ItemsState: ViewModelState {
static let filters = Changes(rawValue: 1 << 5)
static let webViewCleanup = Changes(rawValue: 1 << 6)
static let batchData = Changes(rawValue: 1 << 7)
static let openItems = Changes(rawValue: 1 << 8)
}

struct DownloadBatchData: Equatable {
@@ -112,6 +113,7 @@ struct ItemsState: ViewModelState {
var itemTitleFont: UIFont {
return UIFont.preferredFont(for: .headline, weight: .regular)
}
var openItemsCount: Int

var tagsFilter: Set<String>? {
let tagFilter = self.filters.first(where: { filter in
@@ -134,7 +136,8 @@ struct ItemsState: ViewModelState {
downloadBatchData: DownloadBatchData?,
remoteDownloadBatchData: DownloadBatchData?,
identifierLookupBatchData: IdentifierLookupBatchData,
error: ItemsError?
error: ItemsError?,
openItemsCount: Int
) {
self.collection = collection
self.library = library
@@ -153,6 +156,7 @@ struct ItemsState: ViewModelState {
self.searchTerm = searchTerm
self.processingBibliography = false
self.itemTitles = [:]
self.openItemsCount = openItemsCount
}

mutating func cleanup() {
Original file line number Diff line number Diff line change
@@ -203,6 +203,14 @@ struct ItemsActionHandler: ViewModelActionHandler, BackgroundDbProcessingActionH
self.update(viewModel: viewModel) { state in
state.itemTitles = [:]
}

case .updateOpenItems(let items):
update(viewModel: viewModel) { state in
if state.openItemsCount != items.count {
state.openItemsCount = items.count
state.changes = .openItems
}
}
}
}

218 changes: 128 additions & 90 deletions Zotero/Scenes/Detail/Items/Views/ItemsViewController.swift
Original file line number Diff line number Diff line change
@@ -21,6 +21,7 @@ final class ItemsViewController: UIViewController {
case deselectAll
case add
case emptyTrash
case restoreOpenItems
}

private enum OverlayState {
@@ -87,6 +88,7 @@ final class ItemsViewController: UIViewController {
self.setupOverlay()
self.startObservingSyncProgress()
self.setupAppStateObserver()
setupOpenItemsObserving()

if let term = self.viewModel.state.searchTerm, !term.isEmpty {
navigationItem.searchController?.searchBar.text = term
@@ -183,6 +185,10 @@ final class ItemsViewController: UIViewController {
if state.changes.contains(.filters) || state.changes.contains(.batchData) {
self.toolbarController.reloadToolbarItems(for: state)
}

if state.changes.contains(.openItems) {
setupRightBarButtonItems(for: state)
}

if let key = state.itemKeyToDuplicate {
self.coordinatorDelegate?.showItemDetail(
@@ -405,7 +411,7 @@ final class ItemsViewController: UIViewController {

private func startObserving(results: Results<RItem>) {
self.resultsToken = results.observe(keyPaths: RItem.observableKeypathsForItemList, { [weak self] changes in
guard let self = self else { return }
guard let self else { return }

switch changes {
case .initial(let results):
@@ -434,7 +440,7 @@ final class ItemsViewController: UIViewController {
syncController.progressObservable
.observe(on: MainScheduler.instance)
.subscribe(onNext: { [weak self] progress in
guard let self = self else { return }
guard let self else { return }
switch progress {
case .object(let object, let progress, _, let libraryId):
if self.viewModel.state.library.identifier == libraryId && object == .item {
@@ -551,105 +557,127 @@ final class ItemsViewController: UIViewController {
item.isEnabled = enabled
}

private func updateRestoreOpenItemsButton(withCount count: Int) {
guard let item = self.navigationItem.rightBarButtonItems?.first(where: { button in RightBarButtonItem(rawValue: button.tag) == .restoreOpenItems }) else { return }
item.image = UIImage(systemName: "\(count).square")
}

private func setupRightBarButtonItems(for state: ItemsState) {
defer {
updateRestoreOpenItemsButton(withCount: state.openItemsCount)
}
let currentItems = (self.navigationItem.rightBarButtonItems ?? []).compactMap({ RightBarButtonItem(rawValue: $0.tag) })
let expectedItems = self.rightBarButtonItemTypes(for: state)
let expectedItems = rightBarButtonItemTypes(for: state)
guard currentItems != expectedItems else { return }
self.navigationItem.rightBarButtonItems = expectedItems.map({ self.createRightBarButtonItem($0) }).reversed()
self.navigationItem.rightBarButtonItems = expectedItems.map({ createRightBarButtonItem($0) }).reversed()
self.updateEmptyTrashButton(toEnabled: state.results?.isEmpty == false)
}

private func rightBarButtonItemTypes(for state: ItemsState) -> [RightBarButtonItem] {
let selectItems = self.rightBarButtonSelectItemTypes(for: state)
if state.collection.identifier.isTrash {
return selectItems + [.emptyTrash]
} else {
return [.add] + selectItems
}
}

private func rightBarButtonSelectItemTypes(for state: ItemsState) -> [RightBarButtonItem] {
if !state.isEditing {
return [.select]
}

let allSelected = state.selectedItems.count == (state.results?.count ?? 0)
if allSelected {
return [.deselectAll, .done]
}

return [.selectAll, .done]
}

private func createRightBarButtonItem(_ type: RightBarButtonItem) -> UIBarButtonItem {
var image: UIImage?
var title: String?
let action: (UIBarButtonItem) -> Void
let accessibilityLabel: String

switch type {
case .deselectAll:
title = L10n.Items.deselectAll
accessibilityLabel = L10n.Accessibility.Items.deselectAllItems
action = { [weak self] _ in
self?.viewModel.process(action: .toggleSelectionState)
}

case .selectAll:
title = L10n.Items.selectAll
accessibilityLabel = L10n.Accessibility.Items.selectAllItems
action = { [weak self] _ in
self?.viewModel.process(action: .toggleSelectionState)
}

case .done:
title = L10n.done
accessibilityLabel = L10n.done
action = { [weak self] _ in
self?.viewModel.process(action: .stopEditing)
}

case .select:
title = L10n.select
accessibilityLabel = L10n.Accessibility.Items.selectItems
action = { [weak self] _ in
self?.viewModel.process(action: .startEditing)

func rightBarButtonItemTypes(for state: ItemsState) -> [RightBarButtonItem] {
var items: [RightBarButtonItem]
let selectItems = rightBarButtonSelectItemTypes(for: state)
if state.collection.identifier.isTrash {
items = selectItems + [.emptyTrash]
} else {
items = [.add] + selectItems
}

case .add:
image = UIImage(systemName: "plus")
accessibilityLabel = L10n.Items.new
title = L10n.Items.new
action = { [weak self] item in
guard let self = self else { return }
self.coordinatorDelegate?.showAddActions(viewModel: self.viewModel, button: item)
if state.openItemsCount > 0 {
items = [.restoreOpenItems] + items
}

case .emptyTrash:
title = L10n.Collections.emptyTrash
accessibilityLabel = L10n.Collections.emptyTrash
action = { [weak self] _ in
self?.emptyTrash()
return items

func rightBarButtonSelectItemTypes(for state: ItemsState) -> [RightBarButtonItem] {
if !state.isEditing {
return [.select]
}

let allSelected = state.selectedItems.count == (state.results?.count ?? 0)
if allSelected {
return [.deselectAll, .done]
}

return [.selectAll, .done]
}
}

let item: UIBarButtonItem
if #available(iOS 16.0, *) {
item = UIBarButtonItem(title: title, image: image, target: nil, action: nil)
} else {
if let title = title {
item = UIBarButtonItem(title: title, style: .plain, target: nil, action: nil)
} else if let image = image {
item = UIBarButtonItem(image: image, style: .plain, target: nil, action: nil)

func createRightBarButtonItem(_ type: RightBarButtonItem) -> UIBarButtonItem {
var image: UIImage?
var title: String?
let action: (UIBarButtonItem) -> Void
let accessibilityLabel: String

switch type {
case .deselectAll:
title = L10n.Items.deselectAll
accessibilityLabel = L10n.Accessibility.Items.deselectAllItems
action = { [weak self] _ in
self?.viewModel.process(action: .toggleSelectionState)
}

case .selectAll:
title = L10n.Items.selectAll
accessibilityLabel = L10n.Accessibility.Items.selectAllItems
action = { [weak self] _ in
self?.viewModel.process(action: .toggleSelectionState)
}

case .done:
title = L10n.done
accessibilityLabel = L10n.done
action = { [weak self] _ in
self?.viewModel.process(action: .stopEditing)
}

case .select:
title = L10n.select
accessibilityLabel = L10n.Accessibility.Items.selectItems
action = { [weak self] _ in
self?.viewModel.process(action: .startEditing)
}

case .add:
image = UIImage(systemName: "plus")
accessibilityLabel = L10n.Items.new
title = L10n.Items.new
action = { [weak self] item in
guard let self else { return }
self.coordinatorDelegate?.showAddActions(viewModel: self.viewModel, button: item)
}

case .emptyTrash:
title = L10n.Collections.emptyTrash
accessibilityLabel = L10n.Collections.emptyTrash
action = { [weak self] _ in
self?.emptyTrash()
}

case .restoreOpenItems:
image = UIImage(systemName: "0.square")
accessibilityLabel = L10n.Items.restoreOpen
action = { [weak self] _ in
// TODO: Add action that restores open items via coordinator or delegate
guard let self else { return }
print("Restore Open Items")
}
}

let item: UIBarButtonItem
if #available(iOS 16.0, *) {
item = UIBarButtonItem(title: title, image: image, target: nil, action: nil)
} else {
fatalError("ItemsViewController: you need a title or image!")
if let title = title {
item = UIBarButtonItem(title: title, style: .plain, target: nil, action: nil)
} else if let image = image {
item = UIBarButtonItem(image: image, style: .plain, target: nil, action: nil)
} else {
fatalError("ItemsViewController: you need a title or image!")
}
}

item.tag = type.rawValue
item.accessibilityLabel = accessibilityLabel
item.rx.tap.subscribe(onNext: { _ in action(item) }).disposed(by: self.disposeBag)
return item
}

item.tag = type.rawValue
item.accessibilityLabel = accessibilityLabel
item.rx.tap.subscribe(onNext: { _ in action(item) }).disposed(by: self.disposeBag)
return item
}

private func setupSearchBar() {
@@ -690,6 +718,16 @@ final class ItemsViewController: UIViewController {
private func setupOverlay() {
self.overlayBody.layer.cornerRadius = 16
}

private func setupOpenItemsObserving() {
guard let controller = controllers.userControllers?.openItemsController else { return }
controller.observable
.observe(on: MainScheduler.instance)
.subscribe(onNext: { [weak self] items in
self?.viewModel.process(action: .updateOpenItems(items: items))
})
.disposed(by: disposeBag)
}
}

extension ItemsViewController: ItemsTableViewHandlerDelegate {