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()