authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-02 14:10:41-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-02 15:03:17-07:00
log7a9721e96ff69cc09d9bceaca1504d938b96b699
treefe76e0de2573ed2de64d28191dfedbdb3323c56d
parent0289b50606536e89c716af39171ffc698788b1f2
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: iOS lists take iOS 26 chrome: the Add Notebook menu, search in the bottom bar, sync state on its notebook

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

3 files changed, 304 insertions(+), 140 deletions(-)

apps/ios/Snowbound/Lists.swift+146-73
......@@ -21,16 +21,12 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
2121 case section(notebook: String, path: String)
2222 /// Opening, or why the notebook cannot be.
2323 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
2924 }
3025
3126 private var dataSource: UICollectionViewDiffableDataSource<Location, Item>!
3227 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] = [:]
3430 private var selected: Item?
3531 var onOpen: ((Tab, Notebook) -> Void)?
3632
......@@ -77,42 +73,82 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
7773 let add = UIMenu(children: [
7874 UIDeferredMenuElement.uncached { [weak self] provide in
7975 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 }
8588 })
8689 }
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 },
8899 UIAction(title: "Open Folder…", image: UIImage(systemName: "folder")) { [weak self] _ in
89100 self?.openFolder()
90101 },
91 UIAction(title: "Connect to Server…", image: UIImage(systemName: "server.rack")) { [weak self] _ in
92 self?.connect()
93 },
94102 ]
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 ]
96120 if !Notebooks.showsOnDevice {
97 more.append(
121 actions.append(
98122 UIAction(title: "Show \(Self.device)", image: UIImage(systemName: "eye")) { [weak self] _ in
99123 self?.showOnDevice(true)
100124 })
101125 }
102 more.append(
126 actions.append(
103127 UIAction(title: "Personalize…", image: UIImage(systemName: "person.crop.circle")) { [weak self] _ in
104128 guard let self else { return }
105129 Author.ask(from: self) {}
106130 })
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)
110134 }
111135 ])
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 ]
114140 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 }
116152 NotificationCenter.default.addObserver(forName: Notebook.listed, object: nil, queue: .main) { [weak self] _ in
117153 guard let self else { return }
118154 reload()
......@@ -126,7 +162,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
126162 override func viewWillAppear(_ animated: Bool) {
127163 super.viewWillAppear(animated)
128164 navigationController?.navigationBar.prefersLargeTitles = true
129 navigationController?.setToolbarHidden(false, animated: false)
165 navigationController?.setToolbarHidden(toolbarItems?.isEmpty != false, animated: false)
130166 }
131167
132168 private func notebook(_ id: String) -> Notebook? { Notebooks.all.first { $0.id == id } }
......@@ -140,23 +176,32 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
140176 contentUnavailableConfiguration = welcome
141177 if welcome != nil { return dataSource.apply(.init(), animatingDifferences: false) }
142178 }
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 }
152197 var sections = NSDiffableDataSourceSnapshot<Location, Item>()
153198 sections.appendSections(locations.map(\.0))
154199 dataSource.apply(sections, animatingDifferences: false)
155 for (location, notebooks, actions) in locations {
200 for (location, notebooks) in locations {
156201 var list = NSDiffableDataSourceSectionSnapshot<Item>()
157202 let header = Item.location(location)
158203 list.append([header])
159 list.append(notebooks.map { .notebook($0.id) } + actions, to: header)
204 list.append(notebooks.map { .notebook($0.id) }, to: header)
160205 for notebook in notebooks {
161206 let parent = Item.notebook(notebook.id)
162207 guard notebook.handle != nil else {
......@@ -179,6 +224,36 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
179224 dataSource.apply(list, to: location, animatingDifferences: false)
180225 }
181226 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 }
182257 }
183258
184259 /// Lists Documents and iCloud Drive again and opens the notebooks new there; `opened` runs
......@@ -234,13 +309,23 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
234309 content.text = notebook.name
235310 content.image = UIImage(systemName: "book.closed.fill")
236311 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
238323 let more = UIButton(type: .system)
239324 more.setImage(UIImage(systemName: "ellipsis.circle"), for: .normal)
240325 more.accessibilityLabel = "More"
241326 more.showsMenuAsPrimaryAction = true
242327 more.menu = menu(for: notebook)
243 accessories = [
328 accessories += [
244329 .customView(configuration: .init(customView: more, placement: .trailing(displayed: .always))),
245330 .outlineDisclosure(options: .init(style: .cell)),
246331 ]
......@@ -276,32 +361,25 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
276361 spinner.startAnimating()
277362 accessories = [.customView(configuration: .init(customView: spinner, placement: .trailing()))]
278363 }
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
298364 }
299365 cell.contentConfiguration = content
300366 cell.accessories = accessories
301367 }
302368
303369 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 = [
305383 UIAction(title: "New Section…", image: UIImage(systemName: "plus.rectangle.portrait")) {
306384 [weak self] _ in self?.newSection(in: notebook)
307385 },
......@@ -317,7 +395,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
317395 Notebooks.remove(notebook)
318396 self?.reload()
319397 }
320 return UIMenu(children: actions + [close])
398 return UIMenu(children: status + actions + [close])
321399 }
322400 let file = [
323401 UIAction(title: "Rename…", image: UIImage(systemName: "pencil")) { [weak self] _ in
......@@ -337,7 +415,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
337415 let delete = UIAction(title: "Delete", image: UIImage(systemName: "trash"), attributes: .destructive) {
338416 [weak self] _ in self?.delete(folder, of: notebook)
339417 }
340 return UIMenu(children: [
418 return UIMenu(children: status + [
341419 UIMenu(options: .displayInline, children: actions), UIMenu(options: .displayInline, children: file), delete,
342420 ])
343421 }
......@@ -538,13 +616,6 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
538616 collapsed.remove(item)
539617 }
540618 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()
548619 case .location: break
549620 }
550621 return false
......@@ -745,7 +816,6 @@ final class PagesViewController: UITableViewController {
745816
746817 private(set) var section: Section?
747818 private var items: [Item] = []
748 private lazy var sync = SyncIndicator(in: self)
749819 private var selected: String?
750820 private let search = SearchViewController.controller()
751821 /// Opens a page of the section.
......@@ -767,12 +837,15 @@ final class PagesViewController: UITableViewController {
767837 tableView.register(UITableViewCell.self, forCellReuseIdentifier: "page")
768838 navigationItem.searchController = search
769839 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"),
772842 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 }
776849 NotificationCenter.default.addObserver(
777850 self, selector: #selector(changed), name: Section.changed, object: nil)
778851 if Prototype.sectionStrip {
apps/ios/Snowbound/Prototypes.swift+80-2
......@@ -1,8 +1,8 @@
11import UIKit
22
33/// Design prototypes, off unless a launch argument turns one on, as
4/// `-SnowboundReading YES`, `-SnowboundWelcome YES` or `-SnowboundSectionStrip YES` (Xcode's
5/// scheme, `simctl launch`, or `devicectl … --arguments`).
4/// `-SnowboundReading YES`, `-SnowboundWelcome YES`, `-SnowboundSectionStrip YES` or
5/// `-SnowboundQuickNote YES` (Xcode's scheme, `simctl launch`, or `devicectl … --arguments`).
66enum Prototype {
77 /// Page menu: Reading View, the page reflowed into the screen's width, read-only.
88 static var reading: Bool { UserDefaults.standard.bool(forKey: "SnowboundReading") }
......@@ -239,3 +239,81 @@ final class SectionStrip: UIView {
239239 UIImage(systemName: "rectangle.portrait.fill")?.withTintColor(color, renderingMode: .alwaysOriginal)
240240 }
241241}
242
243extension Prototype {
244 /// The notebook list's bottom corner: Quick Note, a new page in a section chosen once.
245 static var quickNote: Bool { UserDefaults.standard.bool(forKey: "SnowboundQuickNote") }
246}
247
248/// OneNote's Quick Notes, which go to its Unfiled Notes section: a page made from the notebook
249/// list goes to the section picked the first time.
250enum QuickNote {
251 private struct Place: Codable, Equatable {
252 let notebook: String
253 let section: String
254 }
255
256 private static let key = "quickNoteSection"
257
258 private static var place: Place? {
259 get { UserDefaults.standard.data(forKey: key).flatMap { try? JSONDecoder().decode(Place.self, from: $0) } }
260 set { UserDefaults.standard.set(try? JSONEncoder().encode(newValue), forKey: key) }
261 }
262
263 /// The chosen section, while its notebook lists it.
264 private static var target: (Tab, Notebook)? {
265 guard let place, let notebook = Notebooks.all.first(where: { $0.id == place.notebook }),
266 let tab = notebook.tabs.first(where: { $0.path == place.section && $0.readable })
267 else { return nil }
268 return (tab, notebook)
269 }
270
271 /// Makes a page in the chosen section and opens it, its title ready for typing; until a
272 /// section is chosen, a tap asks where Quick Notes go.
273 static func item() -> UIBarButtonItem {
274 let item = UIBarButtonItem(title: "Quick Note", image: UIImage(systemName: "square.and.pencil"))
275 let create = UIAction(title: "Quick Note") { _ in
276 guard let (tab, notebook) = target else {
277 scene?.window?.rootViewController?.alert(
278 "Can’t Open the Quick Notes Section", "Touch and hold Quick Note to choose another section.")
279 return
280 }
281 write(in: tab, of: notebook)
282 }
283 item.primaryAction = place == nil ? nil : create
284 item.menu = UIMenu(children: [
285 UIDeferredMenuElement.uncached { [weak item] provide in
286 let notebooks = Notebooks.all.filter { $0.tabs.contains(where: \.readable) }
287 let choices = notebooks.map { notebook in
288 UIMenu(
289 title: notebook.name, image: UIImage(systemName: "book.closed"),
290 children: notebook.tabs.filter(\.readable).map { tab in
291 let chosen = Place(notebook: notebook.id, section: tab.path)
292 let action = UIAction(
293 title: tab.name,
294 image: UIImage(systemName: "rectangle.portrait.fill")?
295 .withTintColor(tab.uiColor, renderingMode: .alwaysOriginal)
296 ) { _ in
297 let first = item?.primaryAction == nil
298 place = chosen
299 item?.primaryAction = create
300 if first { write(in: tab, of: notebook) }
301 }
302 action.state = place == chosen ? .on : .off
303 return action
304 })
305 }
306 provide([UIMenu(title: "Quick Notes Go To", options: .displayInline, children: choices)])
307 }
308 ])
309 return item
310 }
311
312 private static var scene: SceneDelegate? {
313 UIApplication.shared.connectedScenes.lazy.compactMap { $0.delegate as? SceneDelegate }.first
314 }
315
316 private static func write(in tab: Tab, of notebook: Notebook) {
317 scene?.open(tab, of: notebook) { _ in scene?.newPage() }
318 }
319}
apps/ios/Snowbound/Sync.swift+78-65
......@@ -66,7 +66,7 @@ enum Sync {
6666 return switch state {
6767 case .upToDate: "checkmark.circle"
6868 case .syncing, .inUse: "arrow.triangle.2.circlepath"
69 case .notConnected: "wifi.slash"
69 case .notConnected: "slash.circle"
7070 case .protected: "lock"
7171 case .readOnly, .unreadable, .failed: "exclamationmark.triangle"
7272 }
......@@ -85,6 +85,43 @@ enum Sync {
8585 }
8686 }
8787
88 /// A notebook's status as its row shows it, while its sections aren't syncing.
89 struct Attention: Equatable {
90 let state: SectionSync.State
91 let queued: Int
92 /// The oldest of the sections' last syncs, unless one never synced.
93 let synced: Double?
94
95 /// The last sync and the edits waiting, as a line.
96 var detail: String {
97 [synced.map { "Last sync \(Sync.when($0))" }, queued > 0 ? "\(Sync.changes(queued)) waiting" : nil]
98 .compactMap { $0 }.joined(separator: " · ")
99 }
100 }
101
102 static func attention(_ sections: [SectionSync]) -> Attention? {
103 // A protected section says so on its own row.
104 let syncing = sections.filter { $0.state != .protected }
105 guard let worst = syncing.map(\.state).max(), offline || worst >= .notConnected else { return nil }
106 let synced = syncing.map(\.synced)
107 return Attention(
108 state: worst, queued: syncing.map(\.queued).reduce(0, +),
109 synced: synced.contains { $0 == nil } ? nil : synced.compactMap { $0 }.min())
110 }
111
112 /// What a toast says when `notebook` has just stopped syncing with edits waiting; nil
113 /// otherwise, as its row already says so.
114 static func toast(_ notebook: String, from before: Attention?, to after: Attention?) -> (title: String, detail: String)? {
115 guard before == nil, let after, after.queued > 0, !offline else { return nil }
116 let title =
117 switch after.state {
118 case .notConnected: "Can’t reach “\(notebook)”"
119 case .readOnly: "Can’t save to “\(notebook)”"
120 default: "Can’t sync “\(notebook)”"
121 }
122 return (title, "\(changes(after.queued)) waiting on this \(UIDevice.current.model)")
123 }
124
88125 static func changes(_ count: Int) -> String { count == 1 ? "1 change" : "\(count) changes" }
89126
90127 /// When a section was last reached: the time, and the date too before today.
......@@ -96,80 +133,56 @@ enum Sync {
96133 }
97134}
98135
99/// The sync status in a list's toolbar, as Mail shows when it last checked; opens the sync
100/// sheet. The item leaves `owner`'s toolbar while there is nothing to report (hidden, its
101/// custom view would leave the bar's glass behind), so the owner keeps it.
102final class SyncIndicator: UIBarButtonItem {
103 private let button = UIButton(type: .system)
104 private weak var owner: UIViewController?
105 /// Where in the toolbar the item goes back to.
106 private var slot = 0
107
108 init(in owner: UIViewController) {
109 self.owner = owner
110 super.init()
111 var configuration = UIButton.Configuration.plain()
112 configuration.imagePadding = 4
113 configuration.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration(textStyle: .caption1)
114 configuration.baseForegroundColor = .secondaryLabel
115 configuration.titleLineBreakMode = .byTruncatingTail
116 configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer {
117 var attributes = $0
118 attributes.font = .preferredFont(forTextStyle: .caption1)
119 return attributes
136/// A passing note at the top of `window` that opens `details` when tapped, for a change the
137/// reader would otherwise miss.
138enum SyncToast {
139 static func show(_ title: String, _ detail: String, in window: UIWindow, details: @escaping () -> Void) {
140 var configuration: UIButton.Configuration
141 if #available(iOS 26, *) {
142 configuration = .glass()
143 } else {
144 configuration = .gray()
145 configuration.cornerStyle = .capsule
120146 }
121 button.configuration = configuration
122 button.addAction(UIAction { [weak self] _ in self?.present() }, for: .primaryActionTriggered)
123 customView = button
124 for name in [Sync.changed, Section.changed] {
125 NotificationCenter.default.addObserver(self, selector: #selector(refresh), name: name, object: nil)
147 configuration.title = title
148 configuration.subtitle = detail
149 configuration.image = UIImage(systemName: "exclamationmark.icloud")
150 configuration.imagePadding = 10
151 configuration.titleLineBreakMode = .byTruncatingMiddle
152 configuration.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 16, bottom: 10, trailing: 20)
153 let toast = UIButton(configuration: configuration)
154 toast.accessibilityHint = "Shows sync status"
155 toast.translatesAutoresizingMaskIntoConstraints = false
156 toast.alpha = 0
157 let dismiss = { [weak toast] in
158 UIView.animate(withDuration: 0.3, animations: { toast?.alpha = 0 }) { _ in toast?.removeFromSuperview() }
126159 }
127 refresh()
128 }
129
130 required init?(coder: NSCoder) { fatalError() }
131
132 @objc private func refresh() {
133 Sync.status { [weak self] notebooks in
134 let sections = notebooks.flatMap(\.1)
135 guard let self else { return }
136 guard let worst = sections.map(\.state).max() else { return show(false) }
137 let queued = sections.map(\.queued).reduce(0, +)
138 let label = Sync.label(worst)
139 button.configuration?.title = queued > 0 && worst != .syncing ? "\(label), \(Sync.changes(queued))" : label
140 button.configuration?.image = UIImage(systemName: Sync.symbol(worst))
141 button.configuration?.baseForegroundColor =
142 worst >= .notConnected && !Sync.offline ? .systemOrange : .secondaryLabel
143 button.accessibilityLabel = "Sync status: \(button.configuration?.title ?? label)"
144 button.sizeToFit()
145 show(true)
146 }
147 }
148
149 /// Takes the item out of its owner's toolbar, or puts it back where it was.
150 private func show(_ shown: Bool) {
151 guard let owner, var items = owner.toolbarItems else { return }
152 switch (shown, items.firstIndex { $0 === self }) {
153 case (true, nil): items.insert(self, at: min(slot, items.count))
154 case (false, let at?):
155 slot = at
156 items.remove(at: at)
157 default: return
158 }
159 owner.setToolbarItems(items, animated: true)
160 toast.addAction(UIAction { _ in
161 dismiss()
162 details()
163 }, for: .primaryActionTriggered)
164 window.addSubview(toast)
165 NSLayoutConstraint.activate([
166 toast.topAnchor.constraint(equalTo: window.safeAreaLayoutGuide.topAnchor, constant: 8),
167 toast.centerXAnchor.constraint(equalTo: window.centerXAnchor),
168 toast.widthAnchor.constraint(lessThanOrEqualTo: window.widthAnchor, constant: -32),
169 ])
170 UIView.animate(withDuration: 0.3) { toast.alpha = 1 }
171 UIAccessibility.post(notification: .announcement, argument: "\(title). \(detail)")
172 DispatchQueue.main.asyncAfter(deadline: .now() + 6, execute: dismiss)
160173 }
174}
161175
162 private func present() {
163 guard let controller = button.window?.rootViewController else { return }
176/// OneNote's Notebook Sync Status: Work Offline, Sync Now, and each notebook's sections.
177final class SyncViewController: UITableViewController {
178 /// Shows the status as a sheet over `controller`.
179 static func present(from controller: UIViewController) {
164180 let navigation = UINavigationController(rootViewController: SyncViewController())
165181 navigation.sheetPresentationController?.detents = [.medium(), .large()]
166182 navigation.sheetPresentationController?.prefersGrabberVisible = true
167183 (controller.presentedViewController ?? controller).present(navigation, animated: true)
168184 }
169}
170185
171/// OneNote's Notebook Sync Status: Work Offline, Sync Now, and each notebook's sections.
172final class SyncViewController: UITableViewController {
173186 private var notebooks: [(Notebook, [SectionSync])] = []
174187 private var timer: Timer?
175188 private let offline = UISwitch()