From 7a9721e96ff69cc09d9bceaca1504d938b96b699 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Fri, 2 Oct 2026 14:10:41 -0700 Subject: [PATCH] feat: iOS lists take iOS 26 chrome: the Add Notebook menu, search in the bottom bar, sync state on its notebook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The navigation bar's + is a menu: New Notebook in iCloud Drive or On My iPhone (Turn On iCloud Drive while signed out), Open from Server…, Open Folder…. The action rows at the end of each place are gone, and a place with no notebooks is left out; with none at all the list says so and offers New Notebook. The settings that shared the + menu, and Sync Status…, move to a ⋯ beside it. On iOS 26 the search field sits in the bottom toolbar of the notebooks and of a section's pages, where the pages' + makes a page and opens it. The sync capsule is gone: a notebook whose sections aren't syncing says so on its row (⊘ not connected, ⚠ otherwise), and its ⋯ menu leads with the state, last sync, changes waiting, and Sync Now. A toast tells of a notebook that has just stopped syncing with changes waiting, and opens Sync Status. -SnowboundQuickNote YES prototypes the notebook list's corner button: Quick Note makes a page in a section picked the first time (touch and hold to change it), as OneNote's Quick Notes go to Unfiled Notes. Assisted-by: claude-opus-5.5 --- apps/ios/Snowbound/Lists.swift | 219 ++++++++++++++++++---------- apps/ios/Snowbound/Prototypes.swift | 82 ++++++++++- apps/ios/Snowbound/Sync.swift | 143 +++++++++--------- 3 files changed, 304 insertions(+), 140 deletions(-) diff --git a/apps/ios/Snowbound/Lists.swift b/apps/ios/Snowbound/Lists.swift index accb0a6f23cfb69dc31a18987428ca18b43ca67b..b1412bc185f247520c93397bd42aed5a46e3106f 100644 --- a/apps/ios/Snowbound/Lists.swift +++ b/apps/ios/Snowbound/Lists.swift @@ -21,16 +21,12 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke case section(notebook: String, path: String) /// Opening, or why the notebook cannot be. case status(notebook: String) - case newNotebook, openFolder, connect - /// Copies the Snowbound Guide to On My iPhone and opens it, until it is there. - case guide - /// New Notebook in iCloud Drive, and the way to it while iCloud Drive is off. - case newInCloud, turnOnICloud } private var dataSource: UICollectionViewDiffableDataSource! private var collapsed: Set = [] - private lazy var sync = SyncIndicator(in: self) + /// What each notebook's row shows of its sync status, by notebook id. + private var attention: [String: Sync.Attention] = [:] private var selected: Item? var onOpen: ((Tab, Notebook) -> Void)? @@ -77,42 +73,82 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke let add = UIMenu(children: [ UIDeferredMenuElement.uncached { [weak self] provide in guard let self else { return provide([]) } - var actions: [UIMenuElement] = [] - if ICloud.documents != nil || Notebooks.showsOnDevice { - actions.append( - UIAction(title: "New Notebook…", image: UIImage(systemName: "plus")) { [weak self] _ in - self?.newNotebook(inCloud: ICloud.documents != nil) + var places: [UIMenuElement] = [] + if ICloud.documents != nil { + places.append( + UIAction(title: "iCloud Drive", image: UIImage(systemName: "icloud")) { [weak self] _ in + self?.newNotebook(inCloud: true) + }) + } else if !ICloud.signedIn { + places.append( + UIAction(title: "Turn On iCloud Drive", image: UIImage(systemName: "icloud")) { _ in + if let settings = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(settings) + } }) } - actions += [ + places.append( + UIAction(title: Self.device, image: UIImage(systemName: "iphone")) { [weak self] _ in + self?.newNotebook(inCloud: false) + }) + var actions: [UIMenuElement] = [ + UIMenu(title: "New Notebook", options: .displayInline, children: places), + UIAction(title: "Open from Server…", image: UIImage(systemName: "server.rack")) { [weak self] _ in + self?.connect() + }, UIAction(title: "Open Folder…", image: UIImage(systemName: "folder")) { [weak self] _ in self?.openFolder() }, - UIAction(title: "Connect to Server…", image: UIImage(systemName: "server.rack")) { [weak self] _ in - self?.connect() - }, ] - var more: [UIMenuElement] = [] + let copied = Notebooks.onDevice.contains { $0.source == .documents(path: Notebooks.guide) } + if Notebooks.guideOffered && !copied { + actions.append( + UIAction(title: "Open the Snowbound Guide", image: UIImage(systemName: "book")) { [weak self] _ in + self?.openGuide() + }) + } + provide(actions) + } + ]) + let more = UIMenu(children: [ + UIDeferredMenuElement.uncached { [weak self] provide in + var actions: [UIMenuElement] = [ + UIAction(title: "Sync Status…", image: UIImage(systemName: "arrow.triangle.2.circlepath")) { + [weak self] _ in self.map(SyncViewController.present) + } + ] if !Notebooks.showsOnDevice { - more.append( + actions.append( UIAction(title: "Show \(Self.device)", image: UIImage(systemName: "eye")) { [weak self] _ in self?.showOnDevice(true) }) } - more.append( + actions.append( UIAction(title: "Personalize…", image: UIImage(systemName: "person.crop.circle")) { [weak self] _ in guard let self else { return } Author.ask(from: self) {} }) - more.append(Appearance.menu()) - more.append(Editing.fontMenu()) - provide(actions + [UIMenu(options: .displayInline, children: more)]) + actions.append(Appearance.menu()) + actions.append(Editing.fontMenu()) + provide(actions) } ]) - navigationItem.rightBarButtonItem = UIBarButtonItem( - title: "Add Notebook", image: UIImage(systemName: "plus"), menu: add) + navigationItem.rightBarButtonItems = [ + UIBarButtonItem(title: "Add Notebook", image: UIImage(systemName: "plus"), menu: add), + UIBarButtonItem(title: "More", image: UIImage(systemName: "ellipsis"), menu: more), + ] navigationItem.searchController = SearchViewController.controller() - toolbarItems = [.flexibleSpace(), sync, .flexibleSpace()] + let corner = Prototype.quickNote ? [QuickNote.item()] : [] + if #available(iOS 26, *) { + // Search sits in the bottom toolbar, as Notes and Files put it. + navigationItem.preferredSearchBarPlacement = .integrated + toolbarItems = [navigationItem.searchBarPlacementBarButtonItem] + (corner.isEmpty ? [] : [.fixedSpace()] + corner) + } else { + toolbarItems = corner.isEmpty ? [] : [.flexibleSpace()] + corner + } + for name in [Sync.changed, Section.changed] { + NotificationCenter.default.addObserver(self, selector: #selector(refreshSync), name: name, object: nil) + } NotificationCenter.default.addObserver(forName: Notebook.listed, object: nil, queue: .main) { [weak self] _ in guard let self else { return } reload() @@ -126,7 +162,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) navigationController?.navigationBar.prefersLargeTitles = true - navigationController?.setToolbarHidden(false, animated: false) + navigationController?.setToolbarHidden(toolbarItems?.isEmpty != false, animated: false) } private func notebook(_ id: String) -> Notebook? { Notebooks.all.first { $0.id == id } } @@ -140,23 +176,32 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke contentUnavailableConfiguration = welcome if welcome != nil { return dataSource.apply(.init(), animatingDifferences: false) } } - let copied = Notebooks.onDevice.contains { $0.source == .documents(path: Notebooks.guide) } - let guide: [Item] = Notebooks.guideOffered && !copied ? [.guide] : [] - let cloud: [(Location, [Notebook], [Item])] = - ICloud.documents != nil - ? [(.icloud, Notebooks.inCloud, [.newInCloud])] - : ICloud.signedIn ? [] : [(.icloud, [], [.turnOnICloud])] - let locations: [(Location, [Notebook], [Item])] = - cloud + (Notebooks.showsOnDevice ? [(.onDevice, Notebooks.onDevice, [.newNotebook])] : []) - + [(.elsewhere, Notebooks.elsewhere, [.openFolder, .connect] + guide)] + // A place with no notebooks is left out; the Add Notebook menu reaches every place. + let locations: [(Location, [Notebook])] = [ + (.icloud, Notebooks.inCloud), (.onDevice, Notebooks.onDevice), (.elsewhere, Notebooks.elsewhere), + ].filter { !$0.1.isEmpty } + if locations.isEmpty { + var empty = UIContentUnavailableConfiguration.empty() + empty.text = "No Notebooks" + empty.secondaryText = "Create a notebook, or open one from a server or folder." + var button = UIButton.Configuration.borderedProminent() + button.title = "New Notebook" + empty.button = button + empty.buttonProperties.primaryAction = UIAction { [weak self] _ in + self?.newNotebook(inCloud: ICloud.documents != nil) + } + contentUnavailableConfiguration = empty + } else { + contentUnavailableConfiguration = nil + } var sections = NSDiffableDataSourceSnapshot() sections.appendSections(locations.map(\.0)) dataSource.apply(sections, animatingDifferences: false) - for (location, notebooks, actions) in locations { + for (location, notebooks) in locations { var list = NSDiffableDataSourceSectionSnapshot() let header = Item.location(location) list.append([header]) - list.append(notebooks.map { .notebook($0.id) } + actions, to: header) + list.append(notebooks.map { .notebook($0.id) }, to: header) for notebook in notebooks { let parent = Item.notebook(notebook.id) guard notebook.handle != nil else { @@ -179,6 +224,36 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke dataSource.apply(list, to: location, animatingDifferences: false) } showSelection() + refreshSync() + } + + /// Reads every notebook's sync status, marks the rows it changed, and tells of a notebook + /// that has just stopped syncing with edits waiting. + @objc private func refreshSync() { + Sync.status { [weak self] notebooks in + guard let self else { return } + var changed: [Item] = [] + for (notebook, sections) in notebooks { + let before = attention[notebook.id] + let after = Sync.attention(sections) + guard before != after else { continue } + attention[notebook.id] = after + changed.append(.notebook(notebook.id)) + // The list is off screen on a phone while a page shows. + if let toast = Sync.toast(notebook.name, from: before, to: after), + let window = splitViewController?.view.window ?? view.window + { + SyncToast.show(toast.title, toast.detail, in: window) { [weak self] in + self.map(SyncViewController.present) + } + } + } + var shown = dataSource.snapshot() + let items = changed.filter { shown.indexOfItem($0) != nil } + guard !items.isEmpty else { return } + shown.reconfigureItems(items) + dataSource.apply(shown, animatingDifferences: false) + } } /// Lists Documents and iCloud Drive again and opens the notebooks new there; `opened` runs @@ -234,13 +309,23 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke content.text = notebook.name content.image = UIImage(systemName: "book.closed.fill") content.imageProperties.tintColor = notebook.color - if case .server(let server) = notebook.source { content.secondaryText = server.host } + var details: [String] = [] + if case .server(let server) = notebook.source { details.append(server.host) } + if let state = attention[id]?.state { + details.append(Sync.label(state)) + let mark = UIImageView(image: UIImage(systemName: Sync.symbol(state))) + mark.tintColor = state == .notConnected || Sync.offline ? .secondaryLabel : .systemOrange + mark.preferredSymbolConfiguration = UIImage.SymbolConfiguration(textStyle: .body) + accessories.append(.customView(configuration: .init(customView: mark, placement: .trailing(displayed: .always)))) + } + content.secondaryText = details.isEmpty ? nil : details.joined(separator: " · ") + content.secondaryTextProperties.color = .secondaryLabel let more = UIButton(type: .system) more.setImage(UIImage(systemName: "ellipsis.circle"), for: .normal) more.accessibilityLabel = "More" more.showsMenuAsPrimaryAction = true more.menu = menu(for: notebook) - accessories = [ + accessories += [ .customView(configuration: .init(customView: more, placement: .trailing(displayed: .always))), .outlineDisclosure(options: .init(style: .cell)), ] @@ -276,32 +361,25 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke spinner.startAnimating() accessories = [.customView(configuration: .init(customView: spinner, placement: .trailing()))] } - case .turnOnICloud: - content.text = "Turn On iCloud Drive" - content.secondaryText = "Keep notebooks in iCloud to use them on all your devices." - content.image = UIImage(systemName: "icloud") - content.textProperties.color = .tintColor - case .newInCloud: - content.text = "New Notebook…" - content.image = UIImage(systemName: "plus") - content.textProperties.color = .tintColor - case .guide: - content.text = "Open the Snowbound Guide" - content.image = UIImage(systemName: "book") - content.textProperties.color = .tintColor - case .newNotebook, .openFolder, .connect: - content.text = - item == .newNotebook ? "New Notebook…" : item == .openFolder ? "Open Folder…" : "Connect to Server…" - content.image = UIImage( - systemName: item == .newNotebook ? "plus" : item == .openFolder ? "folder" : "server.rack") - content.textProperties.color = .tintColor } cell.contentConfiguration = content cell.accessories = accessories } private func menu(for notebook: Notebook) -> UIMenu { - var actions = [ + // Why the notebook isn't syncing and when it last did, while it isn't. + let status: [UIMenuElement] = + attention[notebook.id].map { attention in + let details = UIAction( + title: Sync.label(attention.state), subtitle: attention.detail.isEmpty ? nil : attention.detail, + image: UIImage(systemName: Sync.symbol(attention.state)) + ) { [weak self] _ in self.map(SyncViewController.present) } + let now = UIAction(title: "Sync Now", image: UIImage(systemName: "arrow.clockwise")) { _ in + if let handle = notebook.handle { sb_library_sync_now(handle) } + } + return [UIMenu(options: .displayInline, children: [details, now])] + } ?? [] + let actions = [ UIAction(title: "New Section…", image: UIImage(systemName: "plus.rectangle.portrait")) { [weak self] _ in self?.newSection(in: notebook) }, @@ -317,7 +395,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke Notebooks.remove(notebook) self?.reload() } - return UIMenu(children: actions + [close]) + return UIMenu(children: status + actions + [close]) } let file = [ UIAction(title: "Rename…", image: UIImage(systemName: "pencil")) { [weak self] _ in @@ -337,7 +415,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke let delete = UIAction(title: "Delete", image: UIImage(systemName: "trash"), attributes: .destructive) { [weak self] _ in self?.delete(folder, of: notebook) } - return UIMenu(children: [ + return UIMenu(children: status + [ UIMenu(options: .displayInline, children: actions), UIMenu(options: .displayInline, children: file), delete, ]) } @@ -538,13 +616,6 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke collapsed.remove(item) } dataSource.apply(list, to: location) - case .newNotebook: newNotebook(inCloud: false) - case .newInCloud: newNotebook(inCloud: true) - case .turnOnICloud: - if let settings = URL(string: UIApplication.openSettingsURLString) { UIApplication.shared.open(settings) } - case .openFolder: openFolder() - case .connect: connect() - case .guide: openGuide() case .location: break } return false @@ -745,7 +816,6 @@ final class PagesViewController: UITableViewController { private(set) var section: Section? private var items: [Item] = [] - private lazy var sync = SyncIndicator(in: self) private var selected: String? private let search = SearchViewController.controller() /// Opens a page of the section. @@ -767,12 +837,15 @@ final class PagesViewController: UITableViewController { tableView.register(UITableViewCell.self, forCellReuseIdentifier: "page") navigationItem.searchController = search navigationItem.hidesSearchBarWhenScrolling = false - let compose = UIBarButtonItem( - title: "New Page", image: UIImage(systemName: "square.and.pencil"), + let add = UIBarButtonItem( + title: "New Page", image: UIImage(systemName: "plus"), primaryAction: UIAction { [weak self] _ in self?.newPage(under: nil) }) - toolbarItems = [ - .flexibleSpace(), sync, .flexibleSpace(), compose, - ] + if #available(iOS 26, *) { + navigationItem.preferredSearchBarPlacement = .integrated + toolbarItems = [navigationItem.searchBarPlacementBarButtonItem, .fixedSpace(), add] + } else { + toolbarItems = [.flexibleSpace(), add] + } NotificationCenter.default.addObserver( self, selector: #selector(changed), name: Section.changed, object: nil) if Prototype.sectionStrip { diff --git a/apps/ios/Snowbound/Prototypes.swift b/apps/ios/Snowbound/Prototypes.swift index 02fe052512703113e441a14f1c9951a102cdf7c9..cea5b2d3b02cb5f8fbc1eb7a9be78291bc112038 100644 --- a/apps/ios/Snowbound/Prototypes.swift +++ b/apps/ios/Snowbound/Prototypes.swift @@ -1,8 +1,8 @@ import UIKit /// Design prototypes, off unless a launch argument turns one on, as -/// `-SnowboundReading YES`, `-SnowboundWelcome YES` or `-SnowboundSectionStrip YES` (Xcode's -/// scheme, `simctl launch`, or `devicectl … --arguments`). +/// `-SnowboundReading YES`, `-SnowboundWelcome YES`, `-SnowboundSectionStrip YES` or +/// `-SnowboundQuickNote YES` (Xcode's scheme, `simctl launch`, or `devicectl … --arguments`). enum Prototype { /// Page menu: Reading View, the page reflowed into the screen's width, read-only. static var reading: Bool { UserDefaults.standard.bool(forKey: "SnowboundReading") } @@ -239,3 +239,81 @@ final class SectionStrip: UIView { UIImage(systemName: "rectangle.portrait.fill")?.withTintColor(color, renderingMode: .alwaysOriginal) } } + +extension Prototype { + /// The notebook list's bottom corner: Quick Note, a new page in a section chosen once. + static var quickNote: Bool { UserDefaults.standard.bool(forKey: "SnowboundQuickNote") } +} + +/// OneNote's Quick Notes, which go to its Unfiled Notes section: a page made from the notebook +/// list goes to the section picked the first time. +enum QuickNote { + private struct Place: Codable, Equatable { + let notebook: String + let section: String + } + + private static let key = "quickNoteSection" + + private static var place: Place? { + get { UserDefaults.standard.data(forKey: key).flatMap { try? JSONDecoder().decode(Place.self, from: $0) } } + set { UserDefaults.standard.set(try? JSONEncoder().encode(newValue), forKey: key) } + } + + /// The chosen section, while its notebook lists it. + private static var target: (Tab, Notebook)? { + guard let place, let notebook = Notebooks.all.first(where: { $0.id == place.notebook }), + let tab = notebook.tabs.first(where: { $0.path == place.section && $0.readable }) + else { return nil } + return (tab, notebook) + } + + /// Makes a page in the chosen section and opens it, its title ready for typing; until a + /// section is chosen, a tap asks where Quick Notes go. + static func item() -> UIBarButtonItem { + let item = UIBarButtonItem(title: "Quick Note", image: UIImage(systemName: "square.and.pencil")) + let create = UIAction(title: "Quick Note") { _ in + guard let (tab, notebook) = target else { + scene?.window?.rootViewController?.alert( + "Can’t Open the Quick Notes Section", "Touch and hold Quick Note to choose another section.") + return + } + write(in: tab, of: notebook) + } + item.primaryAction = place == nil ? nil : create + item.menu = UIMenu(children: [ + UIDeferredMenuElement.uncached { [weak item] provide in + let notebooks = Notebooks.all.filter { $0.tabs.contains(where: \.readable) } + let choices = notebooks.map { notebook in + UIMenu( + title: notebook.name, image: UIImage(systemName: "book.closed"), + children: notebook.tabs.filter(\.readable).map { tab in + let chosen = Place(notebook: notebook.id, section: tab.path) + let action = UIAction( + title: tab.name, + image: UIImage(systemName: "rectangle.portrait.fill")? + .withTintColor(tab.uiColor, renderingMode: .alwaysOriginal) + ) { _ in + let first = item?.primaryAction == nil + place = chosen + item?.primaryAction = create + if first { write(in: tab, of: notebook) } + } + action.state = place == chosen ? .on : .off + return action + }) + } + provide([UIMenu(title: "Quick Notes Go To", options: .displayInline, children: choices)]) + } + ]) + return item + } + + private static var scene: SceneDelegate? { + UIApplication.shared.connectedScenes.lazy.compactMap { $0.delegate as? SceneDelegate }.first + } + + private static func write(in tab: Tab, of notebook: Notebook) { + scene?.open(tab, of: notebook) { _ in scene?.newPage() } + } +} diff --git a/apps/ios/Snowbound/Sync.swift b/apps/ios/Snowbound/Sync.swift index a779d4b5e08c03c164be71d2b7c0f75584934699..86beaef13f15a90b3644caef5f1104eb2856b84d 100644 --- a/apps/ios/Snowbound/Sync.swift +++ b/apps/ios/Snowbound/Sync.swift @@ -66,7 +66,7 @@ enum Sync { return switch state { case .upToDate: "checkmark.circle" case .syncing, .inUse: "arrow.triangle.2.circlepath" - case .notConnected: "wifi.slash" + case .notConnected: "slash.circle" case .protected: "lock" case .readOnly, .unreadable, .failed: "exclamationmark.triangle" } @@ -85,6 +85,43 @@ enum Sync { } } + /// A notebook's status as its row shows it, while its sections aren't syncing. + struct Attention: Equatable { + let state: SectionSync.State + let queued: Int + /// The oldest of the sections' last syncs, unless one never synced. + let synced: Double? + + /// The last sync and the edits waiting, as a line. + var detail: String { + [synced.map { "Last sync \(Sync.when($0))" }, queued > 0 ? "\(Sync.changes(queued)) waiting" : nil] + .compactMap { $0 }.joined(separator: " · ") + } + } + + static func attention(_ sections: [SectionSync]) -> Attention? { + // A protected section says so on its own row. + let syncing = sections.filter { $0.state != .protected } + guard let worst = syncing.map(\.state).max(), offline || worst >= .notConnected else { return nil } + let synced = syncing.map(\.synced) + return Attention( + state: worst, queued: syncing.map(\.queued).reduce(0, +), + synced: synced.contains { $0 == nil } ? nil : synced.compactMap { $0 }.min()) + } + + /// What a toast says when `notebook` has just stopped syncing with edits waiting; nil + /// otherwise, as its row already says so. + static func toast(_ notebook: String, from before: Attention?, to after: Attention?) -> (title: String, detail: String)? { + guard before == nil, let after, after.queued > 0, !offline else { return nil } + let title = + switch after.state { + case .notConnected: "Can’t reach “\(notebook)”" + case .readOnly: "Can’t save to “\(notebook)”" + default: "Can’t sync “\(notebook)”" + } + return (title, "\(changes(after.queued)) waiting on this \(UIDevice.current.model)") + } + static func changes(_ count: Int) -> String { count == 1 ? "1 change" : "\(count) changes" } /// When a section was last reached: the time, and the date too before today. @@ -96,80 +133,56 @@ enum Sync { } } -/// The sync status in a list's toolbar, as Mail shows when it last checked; opens the sync -/// sheet. The item leaves `owner`'s toolbar while there is nothing to report (hidden, its -/// custom view would leave the bar's glass behind), so the owner keeps it. -final class SyncIndicator: UIBarButtonItem { - private let button = UIButton(type: .system) - private weak var owner: UIViewController? - /// Where in the toolbar the item goes back to. - private var slot = 0 - - init(in owner: UIViewController) { - self.owner = owner - super.init() - var configuration = UIButton.Configuration.plain() - configuration.imagePadding = 4 - configuration.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration(textStyle: .caption1) - configuration.baseForegroundColor = .secondaryLabel - configuration.titleLineBreakMode = .byTruncatingTail - configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { - var attributes = $0 - attributes.font = .preferredFont(forTextStyle: .caption1) - return attributes - } - button.configuration = configuration - button.addAction(UIAction { [weak self] _ in self?.present() }, for: .primaryActionTriggered) - customView = button - for name in [Sync.changed, Section.changed] { - NotificationCenter.default.addObserver(self, selector: #selector(refresh), name: name, object: nil) - } - refresh() - } - - required init?(coder: NSCoder) { fatalError() } - - @objc private func refresh() { - Sync.status { [weak self] notebooks in - let sections = notebooks.flatMap(\.1) - guard let self else { return } - guard let worst = sections.map(\.state).max() else { return show(false) } - let queued = sections.map(\.queued).reduce(0, +) - let label = Sync.label(worst) - button.configuration?.title = queued > 0 && worst != .syncing ? "\(label), \(Sync.changes(queued))" : label - button.configuration?.image = UIImage(systemName: Sync.symbol(worst)) - button.configuration?.baseForegroundColor = - worst >= .notConnected && !Sync.offline ? .systemOrange : .secondaryLabel - button.accessibilityLabel = "Sync status: \(button.configuration?.title ?? label)" - button.sizeToFit() - show(true) +/// A passing note at the top of `window` that opens `details` when tapped, for a change the +/// reader would otherwise miss. +enum SyncToast { + static func show(_ title: String, _ detail: String, in window: UIWindow, details: @escaping () -> Void) { + var configuration: UIButton.Configuration + if #available(iOS 26, *) { + configuration = .glass() + } else { + configuration = .gray() + configuration.cornerStyle = .capsule } - } - - /// Takes the item out of its owner's toolbar, or puts it back where it was. - private func show(_ shown: Bool) { - guard let owner, var items = owner.toolbarItems else { return } - switch (shown, items.firstIndex { $0 === self }) { - case (true, nil): items.insert(self, at: min(slot, items.count)) - case (false, let at?): - slot = at - items.remove(at: at) - default: return + configuration.title = title + configuration.subtitle = detail + configuration.image = UIImage(systemName: "exclamationmark.icloud") + configuration.imagePadding = 10 + configuration.titleLineBreakMode = .byTruncatingMiddle + configuration.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 16, bottom: 10, trailing: 20) + let toast = UIButton(configuration: configuration) + toast.accessibilityHint = "Shows sync status" + toast.translatesAutoresizingMaskIntoConstraints = false + toast.alpha = 0 + let dismiss = { [weak toast] in + UIView.animate(withDuration: 0.3, animations: { toast?.alpha = 0 }) { _ in toast?.removeFromSuperview() } } - owner.setToolbarItems(items, animated: true) + toast.addAction(UIAction { _ in + dismiss() + details() + }, for: .primaryActionTriggered) + window.addSubview(toast) + NSLayoutConstraint.activate([ + toast.topAnchor.constraint(equalTo: window.safeAreaLayoutGuide.topAnchor, constant: 8), + toast.centerXAnchor.constraint(equalTo: window.centerXAnchor), + toast.widthAnchor.constraint(lessThanOrEqualTo: window.widthAnchor, constant: -32), + ]) + UIView.animate(withDuration: 0.3) { toast.alpha = 1 } + UIAccessibility.post(notification: .announcement, argument: "\(title). \(detail)") + DispatchQueue.main.asyncAfter(deadline: .now() + 6, execute: dismiss) } +} - private func present() { - guard let controller = button.window?.rootViewController else { return } +/// OneNote's Notebook Sync Status: Work Offline, Sync Now, and each notebook's sections. +final class SyncViewController: UITableViewController { + /// Shows the status as a sheet over `controller`. + static func present(from controller: UIViewController) { let navigation = UINavigationController(rootViewController: SyncViewController()) navigation.sheetPresentationController?.detents = [.medium(), .large()] navigation.sheetPresentationController?.prefersGrabberVisible = true (controller.presentedViewController ?? controller).present(navigation, animated: true) } -} -/// OneNote's Notebook Sync Status: Work Offline, Sync Now, and each notebook's sections. -final class SyncViewController: UITableViewController { private var notebooks: [(Notebook, [SectionSync])] = [] private var timer: Timer? private let offline = UISwitch() -- 2.54.0