| ... | ... | @@ -21,16 +21,12 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke |
| 21 | 21 | case section(notebook: String, path: String) |
| 22 | 22 | /// Opening, or why the notebook cannot be. |
| 23 | 23 | case status(notebook: String) |
| 24 | | case newNotebook, openFolder, connect |
| 25 | | /// Copies the Snowbound Guide to On My iPhone and opens it, until it is there. |
| 26 | | case guide |
| 27 | | /// New Notebook in iCloud Drive, and the way to it while iCloud Drive is off. |
| 28 | | case newInCloud, turnOnICloud |
| 29 | 24 | } |
| 30 | 25 | |
| 31 | 26 | private var dataSource: UICollectionViewDiffableDataSource<Location, Item>! |
| 32 | 27 | private var collapsed: Set<Item> = [] |
| 33 | | private lazy var sync = SyncIndicator(in: self) |
| 28 | /// What each notebook's row shows of its sync status, by notebook id. |
| 29 | private var attention: [String: Sync.Attention] = [:] |
| 34 | 30 | private var selected: Item? |
| 35 | 31 | var onOpen: ((Tab, Notebook) -> Void)? |
| 36 | 32 | |
| ... | ... | @@ -77,42 +73,82 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke |
| 77 | 73 | let add = UIMenu(children: [ |
| 78 | 74 | UIDeferredMenuElement.uncached { [weak self] provide in |
| 79 | 75 | guard let self else { return provide([]) } |
| 80 | | var actions: [UIMenuElement] = [] |
| 81 | | if ICloud.documents != nil || Notebooks.showsOnDevice { |
| 82 | | actions.append( |
| 83 | | UIAction(title: "New Notebook…", image: UIImage(systemName: "plus")) { [weak self] _ in |
| 84 | | self?.newNotebook(inCloud: ICloud.documents != nil) |
| 76 | var places: [UIMenuElement] = [] |
| 77 | if ICloud.documents != nil { |
| 78 | places.append( |
| 79 | UIAction(title: "iCloud Drive", image: UIImage(systemName: "icloud")) { [weak self] _ in |
| 80 | self?.newNotebook(inCloud: true) |
| 81 | }) |
| 82 | } else if !ICloud.signedIn { |
| 83 | places.append( |
| 84 | UIAction(title: "Turn On iCloud Drive", image: UIImage(systemName: "icloud")) { _ in |
| 85 | if let settings = URL(string: UIApplication.openSettingsURLString) { |
| 86 | UIApplication.shared.open(settings) |
| 87 | } |
| 85 | 88 | }) |
| 86 | 89 | } |
| 87 | | actions += [ |
| 90 | places.append( |
| 91 | UIAction(title: Self.device, image: UIImage(systemName: "iphone")) { [weak self] _ in |
| 92 | self?.newNotebook(inCloud: false) |
| 93 | }) |
| 94 | var actions: [UIMenuElement] = [ |
| 95 | UIMenu(title: "New Notebook", options: .displayInline, children: places), |
| 96 | UIAction(title: "Open from Server…", image: UIImage(systemName: "server.rack")) { [weak self] _ in |
| 97 | self?.connect() |
| 98 | }, |
| 88 | 99 | UIAction(title: "Open Folder…", image: UIImage(systemName: "folder")) { [weak self] _ in |
| 89 | 100 | self?.openFolder() |
| 90 | 101 | }, |
| 91 | | UIAction(title: "Connect to Server…", image: UIImage(systemName: "server.rack")) { [weak self] _ in |
| 92 | | self?.connect() |
| 93 | | }, |
| 94 | 102 | ] |
| 95 | | var more: [UIMenuElement] = [] |
| 103 | let copied = Notebooks.onDevice.contains { $0.source == .documents(path: Notebooks.guide) } |
| 104 | if Notebooks.guideOffered && !copied { |
| 105 | actions.append( |
| 106 | UIAction(title: "Open the Snowbound Guide", image: UIImage(systemName: "book")) { [weak self] _ in |
| 107 | self?.openGuide() |
| 108 | }) |
| 109 | } |
| 110 | provide(actions) |
| 111 | } |
| 112 | ]) |
| 113 | let more = UIMenu(children: [ |
| 114 | UIDeferredMenuElement.uncached { [weak self] provide in |
| 115 | var actions: [UIMenuElement] = [ |
| 116 | UIAction(title: "Sync Status…", image: UIImage(systemName: "arrow.triangle.2.circlepath")) { |
| 117 | [weak self] _ in self.map(SyncViewController.present) |
| 118 | } |
| 119 | ] |
| 96 | 120 | if !Notebooks.showsOnDevice { |
| 97 | | more.append( |
| 121 | actions.append( |
| 98 | 122 | UIAction(title: "Show \(Self.device)", image: UIImage(systemName: "eye")) { [weak self] _ in |
| 99 | 123 | self?.showOnDevice(true) |
| 100 | 124 | }) |
| 101 | 125 | } |
| 102 | | more.append( |
| 126 | actions.append( |
| 103 | 127 | UIAction(title: "Personalize…", image: UIImage(systemName: "person.crop.circle")) { [weak self] _ in |
| 104 | 128 | guard let self else { return } |
| 105 | 129 | Author.ask(from: self) {} |
| 106 | 130 | }) |
| 107 | | more.append(Appearance.menu()) |
| 108 | | more.append(Editing.fontMenu()) |
| 109 | | provide(actions + [UIMenu(options: .displayInline, children: more)]) |
| 131 | actions.append(Appearance.menu()) |
| 132 | actions.append(Editing.fontMenu()) |
| 133 | provide(actions) |
| 110 | 134 | } |
| 111 | 135 | ]) |
| 112 | | navigationItem.rightBarButtonItem = UIBarButtonItem( |
| 113 | | title: "Add Notebook", image: UIImage(systemName: "plus"), menu: add) |
| 136 | navigationItem.rightBarButtonItems = [ |
| 137 | UIBarButtonItem(title: "Add Notebook", image: UIImage(systemName: "plus"), menu: add), |
| 138 | UIBarButtonItem(title: "More", image: UIImage(systemName: "ellipsis"), menu: more), |
| 139 | ] |
| 114 | 140 | navigationItem.searchController = SearchViewController.controller() |
| 115 | | toolbarItems = [.flexibleSpace(), sync, .flexibleSpace()] |
| 141 | let corner = Prototype.quickNote ? [QuickNote.item()] : [] |
| 142 | if #available(iOS 26, *) { |
| 143 | // Search sits in the bottom toolbar, as Notes and Files put it. |
| 144 | navigationItem.preferredSearchBarPlacement = .integrated |
| 145 | toolbarItems = [navigationItem.searchBarPlacementBarButtonItem] + (corner.isEmpty ? [] : [.fixedSpace()] + corner) |
| 146 | } else { |
| 147 | toolbarItems = corner.isEmpty ? [] : [.flexibleSpace()] + corner |
| 148 | } |
| 149 | for name in [Sync.changed, Section.changed] { |
| 150 | NotificationCenter.default.addObserver(self, selector: #selector(refreshSync), name: name, object: nil) |
| 151 | } |
| 116 | 152 | NotificationCenter.default.addObserver(forName: Notebook.listed, object: nil, queue: .main) { [weak self] _ in |
| 117 | 153 | guard let self else { return } |
| 118 | 154 | reload() |
| ... | ... | @@ -126,7 +162,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke |
| 126 | 162 | override func viewWillAppear(_ animated: Bool) { |
| 127 | 163 | super.viewWillAppear(animated) |
| 128 | 164 | navigationController?.navigationBar.prefersLargeTitles = true |
| 129 | | navigationController?.setToolbarHidden(false, animated: false) |
| 165 | navigationController?.setToolbarHidden(toolbarItems?.isEmpty != false, animated: false) |
| 130 | 166 | } |
| 131 | 167 | |
| 132 | 168 | private func notebook(_ id: String) -> Notebook? { Notebooks.all.first { $0.id == id } } |
| ... | ... | @@ -140,23 +176,32 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke |
| 140 | 176 | contentUnavailableConfiguration = welcome |
| 141 | 177 | if welcome != nil { return dataSource.apply(.init(), animatingDifferences: false) } |
| 142 | 178 | } |
| 143 | | let copied = Notebooks.onDevice.contains { $0.source == .documents(path: Notebooks.guide) } |
| 144 | | let guide: [Item] = Notebooks.guideOffered && !copied ? [.guide] : [] |
| 145 | | let cloud: [(Location, [Notebook], [Item])] = |
| 146 | | ICloud.documents != nil |
| 147 | | ? [(.icloud, Notebooks.inCloud, [.newInCloud])] |
| 148 | | : ICloud.signedIn ? [] : [(.icloud, [], [.turnOnICloud])] |
| 149 | | let locations: [(Location, [Notebook], [Item])] = |
| 150 | | cloud + (Notebooks.showsOnDevice ? [(.onDevice, Notebooks.onDevice, [.newNotebook])] : []) |
| 151 | | + [(.elsewhere, Notebooks.elsewhere, [.openFolder, .connect] + guide)] |
| 179 | // A place with no notebooks is left out; the Add Notebook menu reaches every place. |
| 180 | let locations: [(Location, [Notebook])] = [ |
| 181 | (.icloud, Notebooks.inCloud), (.onDevice, Notebooks.onDevice), (.elsewhere, Notebooks.elsewhere), |
| 182 | ].filter { !$0.1.isEmpty } |
| 183 | if locations.isEmpty { |
| 184 | var empty = UIContentUnavailableConfiguration.empty() |
| 185 | empty.text = "No Notebooks" |
| 186 | empty.secondaryText = "Create a notebook, or open one from a server or folder." |
| 187 | var button = UIButton.Configuration.borderedProminent() |
| 188 | button.title = "New Notebook" |
| 189 | empty.button = button |
| 190 | empty.buttonProperties.primaryAction = UIAction { [weak self] _ in |
| 191 | self?.newNotebook(inCloud: ICloud.documents != nil) |
| 192 | } |
| 193 | contentUnavailableConfiguration = empty |
| 194 | } else { |
| 195 | contentUnavailableConfiguration = nil |
| 196 | } |
| 152 | 197 | var sections = NSDiffableDataSourceSnapshot<Location, Item>() |
| 153 | 198 | sections.appendSections(locations.map(\.0)) |
| 154 | 199 | dataSource.apply(sections, animatingDifferences: false) |
| 155 | | for (location, notebooks, actions) in locations { |
| 200 | for (location, notebooks) in locations { |
| 156 | 201 | var list = NSDiffableDataSourceSectionSnapshot<Item>() |
| 157 | 202 | let header = Item.location(location) |
| 158 | 203 | list.append([header]) |
| 159 | | list.append(notebooks.map { .notebook($0.id) } + actions, to: header) |
| 204 | list.append(notebooks.map { .notebook($0.id) }, to: header) |
| 160 | 205 | for notebook in notebooks { |
| 161 | 206 | let parent = Item.notebook(notebook.id) |
| 162 | 207 | guard notebook.handle != nil else { |
| ... | ... | @@ -179,6 +224,36 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke |
| 179 | 224 | dataSource.apply(list, to: location, animatingDifferences: false) |
| 180 | 225 | } |
| 181 | 226 | showSelection() |
| 227 | refreshSync() |
| 228 | } |
| 229 | |
| 230 | /// Reads every notebook's sync status, marks the rows it changed, and tells of a notebook |
| 231 | /// that has just stopped syncing with edits waiting. |
| 232 | @objc private func refreshSync() { |
| 233 | Sync.status { [weak self] notebooks in |
| 234 | guard let self else { return } |
| 235 | var changed: [Item] = [] |
| 236 | for (notebook, sections) in notebooks { |
| 237 | let before = attention[notebook.id] |
| 238 | let after = Sync.attention(sections) |
| 239 | guard before != after else { continue } |
| 240 | attention[notebook.id] = after |
| 241 | changed.append(.notebook(notebook.id)) |
| 242 | // The list is off screen on a phone while a page shows. |
| 243 | if let toast = Sync.toast(notebook.name, from: before, to: after), |
| 244 | let window = splitViewController?.view.window ?? view.window |
| 245 | { |
| 246 | SyncToast.show(toast.title, toast.detail, in: window) { [weak self] in |
| 247 | self.map(SyncViewController.present) |
| 248 | } |
| 249 | } |
| 250 | } |
| 251 | var shown = dataSource.snapshot() |
| 252 | let items = changed.filter { shown.indexOfItem($0) != nil } |
| 253 | guard !items.isEmpty else { return } |
| 254 | shown.reconfigureItems(items) |
| 255 | dataSource.apply(shown, animatingDifferences: false) |
| 256 | } |
| 182 | 257 | } |
| 183 | 258 | |
| 184 | 259 | /// Lists Documents and iCloud Drive again and opens the notebooks new there; `opened` runs |
| ... | ... | @@ -234,13 +309,23 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke |
| 234 | 309 | content.text = notebook.name |
| 235 | 310 | content.image = UIImage(systemName: "book.closed.fill") |
| 236 | 311 | content.imageProperties.tintColor = notebook.color |
| 237 | | if case .server(let server) = notebook.source { content.secondaryText = server.host } |
| 312 | var details: [String] = [] |
| 313 | if case .server(let server) = notebook.source { details.append(server.host) } |
| 314 | if let state = attention[id]?.state { |
| 315 | details.append(Sync.label(state)) |
| 316 | let mark = UIImageView(image: UIImage(systemName: Sync.symbol(state))) |
| 317 | mark.tintColor = state == .notConnected || Sync.offline ? .secondaryLabel : .systemOrange |
| 318 | mark.preferredSymbolConfiguration = UIImage.SymbolConfiguration(textStyle: .body) |
| 319 | accessories.append(.customView(configuration: .init(customView: mark, placement: .trailing(displayed: .always)))) |
| 320 | } |
| 321 | content.secondaryText = details.isEmpty ? nil : details.joined(separator: " · ") |
| 322 | content.secondaryTextProperties.color = .secondaryLabel |
| 238 | 323 | let more = UIButton(type: .system) |
| 239 | 324 | more.setImage(UIImage(systemName: "ellipsis.circle"), for: .normal) |
| 240 | 325 | more.accessibilityLabel = "More" |
| 241 | 326 | more.showsMenuAsPrimaryAction = true |
| 242 | 327 | more.menu = menu(for: notebook) |
| 243 | | accessories = [ |
| 328 | accessories += [ |
| 244 | 329 | .customView(configuration: .init(customView: more, placement: .trailing(displayed: .always))), |
| 245 | 330 | .outlineDisclosure(options: .init(style: .cell)), |
| 246 | 331 | ] |
| ... | ... | @@ -276,32 +361,25 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke |
| 276 | 361 | spinner.startAnimating() |
| 277 | 362 | accessories = [.customView(configuration: .init(customView: spinner, placement: .trailing()))] |
| 278 | 363 | } |
| 279 | | case .turnOnICloud: |
| 280 | | content.text = "Turn On iCloud Drive" |
| 281 | | content.secondaryText = "Keep notebooks in iCloud to use them on all your devices." |
| 282 | | content.image = UIImage(systemName: "icloud") |
| 283 | | content.textProperties.color = .tintColor |
| 284 | | case .newInCloud: |
| 285 | | content.text = "New Notebook…" |
| 286 | | content.image = UIImage(systemName: "plus") |
| 287 | | content.textProperties.color = .tintColor |
| 288 | | case .guide: |
| 289 | | content.text = "Open the Snowbound Guide" |
| 290 | | content.image = UIImage(systemName: "book") |
| 291 | | content.textProperties.color = .tintColor |
| 292 | | case .newNotebook, .openFolder, .connect: |
| 293 | | content.text = |
| 294 | | item == .newNotebook ? "New Notebook…" : item == .openFolder ? "Open Folder…" : "Connect to Server…" |
| 295 | | content.image = UIImage( |
| 296 | | systemName: item == .newNotebook ? "plus" : item == .openFolder ? "folder" : "server.rack") |
| 297 | | content.textProperties.color = .tintColor |
| 298 | 364 | } |
| 299 | 365 | cell.contentConfiguration = content |
| 300 | 366 | cell.accessories = accessories |
| 301 | 367 | } |
| 302 | 368 | |
| 303 | 369 | private func menu(for notebook: Notebook) -> UIMenu { |
| 304 | | var actions = [ |
| 370 | // Why the notebook isn't syncing and when it last did, while it isn't. |
| 371 | let status: [UIMenuElement] = |
| 372 | attention[notebook.id].map { attention in |
| 373 | let details = UIAction( |
| 374 | title: Sync.label(attention.state), subtitle: attention.detail.isEmpty ? nil : attention.detail, |
| 375 | image: UIImage(systemName: Sync.symbol(attention.state)) |
| 376 | ) { [weak self] _ in self.map(SyncViewController.present) } |
| 377 | let now = UIAction(title: "Sync Now", image: UIImage(systemName: "arrow.clockwise")) { _ in |
| 378 | if let handle = notebook.handle { sb_library_sync_now(handle) } |
| 379 | } |
| 380 | return [UIMenu(options: .displayInline, children: [details, now])] |
| 381 | } ?? [] |
| 382 | let actions = [ |
| 305 | 383 | UIAction(title: "New Section…", image: UIImage(systemName: "plus.rectangle.portrait")) { |
| 306 | 384 | [weak self] _ in self?.newSection(in: notebook) |
| 307 | 385 | }, |
| ... | ... | @@ -317,7 +395,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke |
| 317 | 395 | Notebooks.remove(notebook) |
| 318 | 396 | self?.reload() |
| 319 | 397 | } |
| 320 | | return UIMenu(children: actions + [close]) |
| 398 | return UIMenu(children: status + actions + [close]) |
| 321 | 399 | } |
| 322 | 400 | let file = [ |
| 323 | 401 | UIAction(title: "Rename…", image: UIImage(systemName: "pencil")) { [weak self] _ in |
| ... | ... | @@ -337,7 +415,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke |
| 337 | 415 | let delete = UIAction(title: "Delete", image: UIImage(systemName: "trash"), attributes: .destructive) { |
| 338 | 416 | [weak self] _ in self?.delete(folder, of: notebook) |
| 339 | 417 | } |
| 340 | | return UIMenu(children: [ |
| 418 | return UIMenu(children: status + [ |
| 341 | 419 | UIMenu(options: .displayInline, children: actions), UIMenu(options: .displayInline, children: file), delete, |
| 342 | 420 | ]) |
| 343 | 421 | } |
| ... | ... | @@ -538,13 +616,6 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke |
| 538 | 616 | collapsed.remove(item) |
| 539 | 617 | } |
| 540 | 618 | dataSource.apply(list, to: location) |
| 541 | | case .newNotebook: newNotebook(inCloud: false) |
| 542 | | case .newInCloud: newNotebook(inCloud: true) |
| 543 | | case .turnOnICloud: |
| 544 | | if let settings = URL(string: UIApplication.openSettingsURLString) { UIApplication.shared.open(settings) } |
| 545 | | case .openFolder: openFolder() |
| 546 | | case .connect: connect() |
| 547 | | case .guide: openGuide() |
| 548 | 619 | case .location: break |
| 549 | 620 | } |
| 550 | 621 | return false |
| ... | ... | @@ -745,7 +816,6 @@ final class PagesViewController: UITableViewController { |
| 745 | 816 | |
| 746 | 817 | private(set) var section: Section? |
| 747 | 818 | private var items: [Item] = [] |
| 748 | | private lazy var sync = SyncIndicator(in: self) |
| 749 | 819 | private var selected: String? |
| 750 | 820 | private let search = SearchViewController.controller() |
| 751 | 821 | /// Opens a page of the section. |
| ... | ... | @@ -767,12 +837,15 @@ final class PagesViewController: UITableViewController { |
| 767 | 837 | tableView.register(UITableViewCell.self, forCellReuseIdentifier: "page") |
| 768 | 838 | navigationItem.searchController = search |
| 769 | 839 | navigationItem.hidesSearchBarWhenScrolling = false |
| 770 | | let compose = UIBarButtonItem( |
| 771 | | title: "New Page", image: UIImage(systemName: "square.and.pencil"), |
| 840 | let add = UIBarButtonItem( |
| 841 | title: "New Page", image: UIImage(systemName: "plus"), |
| 772 | 842 | primaryAction: UIAction { [weak self] _ in self?.newPage(under: nil) }) |
| 773 | | toolbarItems = [ |
| 774 | | .flexibleSpace(), sync, .flexibleSpace(), compose, |
| 775 | | ] |
| 843 | if #available(iOS 26, *) { |
| 844 | navigationItem.preferredSearchBarPlacement = .integrated |
| 845 | toolbarItems = [navigationItem.searchBarPlacementBarButtonItem, .fixedSpace(), add] |
| 846 | } else { |
| 847 | toolbarItems = [.flexibleSpace(), add] |
| 848 | } |
| 776 | 849 | NotificationCenter.default.addObserver( |
| 777 | 850 | self, selector: #selector(changed), name: Section.changed, object: nil) |
| 778 | 851 | if Prototype.sectionStrip { |