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...@@ -21,16 +21,12 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
21 case section(notebook: String, path: String)21 case section(notebook: String, path: String)
22 /// Opening, or why the notebook cannot be.22 /// Opening, or why the notebook cannot be.
23 case status(notebook: String)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 }
3025
31 private var dataSource: UICollectionViewDiffableDataSource<Location, Item>!26 private var dataSource: UICollectionViewDiffableDataSource<Location, Item>!
32 private var collapsed: Set<Item> = []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 private var selected: Item?30 private var selected: Item?
35 var onOpen: ((Tab, Notebook) -> Void)?31 var onOpen: ((Tab, Notebook) -> Void)?
3632
...@@ -77,42 +73,82 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke...@@ -77,42 +73,82 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
77 let add = UIMenu(children: [73 let add = UIMenu(children: [
78 UIDeferredMenuElement.uncached { [weak self] provide in74 UIDeferredMenuElement.uncached { [weak self] provide in
79 guard let self else { return provide([]) }75 guard let self else { return provide([]) }
80 var actions: [UIMenuElement] = []76 var places: [UIMenuElement] = []
81 if ICloud.documents != nil || Notebooks.showsOnDevice {77 if ICloud.documents != nil {
82 actions.append(78 places.append(
83 UIAction(title: "New Notebook…", image: UIImage(systemName: "plus")) { [weak self] _ in79 UIAction(title: "iCloud Drive", image: UIImage(systemName: "icloud")) { [weak self] _ in
84 self?.newNotebook(inCloud: ICloud.documents != nil)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 UIAction(title: "Open Folder…", image: UIImage(systemName: "folder")) { [weak self] _ in99 UIAction(title: "Open Folder…", image: UIImage(systemName: "folder")) { [weak self] _ in
89 self?.openFolder()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 if !Notebooks.showsOnDevice {120 if !Notebooks.showsOnDevice {
97 more.append(121 actions.append(
98 UIAction(title: "Show \(Self.device)", image: UIImage(systemName: "eye")) { [weak self] _ in122 UIAction(title: "Show \(Self.device)", image: UIImage(systemName: "eye")) { [weak self] _ in
99 self?.showOnDevice(true)123 self?.showOnDevice(true)
100 })124 })
101 }125 }
102 more.append(126 actions.append(
103 UIAction(title: "Personalize…", image: UIImage(systemName: "person.crop.circle")) { [weak self] _ in127 UIAction(title: "Personalize…", image: UIImage(systemName: "person.crop.circle")) { [weak self] _ in
104 guard let self else { return }128 guard let self else { return }
105 Author.ask(from: self) {}129 Author.ask(from: self) {}
106 })130 })
107 more.append(Appearance.menu())131 actions.append(Appearance.menu())
108 more.append(Editing.fontMenu())132 actions.append(Editing.fontMenu())
109 provide(actions + [UIMenu(options: .displayInline, children: more)])133 provide(actions)
110 }134 }
111 ])135 ])
112 navigationItem.rightBarButtonItem = UIBarButtonItem(136 navigationItem.rightBarButtonItems = [
113 title: "Add Notebook", image: UIImage(systemName: "plus"), menu: add)137 UIBarButtonItem(title: "Add Notebook", image: UIImage(systemName: "plus"), menu: add),
138 UIBarButtonItem(title: "More", image: UIImage(systemName: "ellipsis"), menu: more),
139 ]
114 navigationItem.searchController = SearchViewController.controller()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 NotificationCenter.default.addObserver(forName: Notebook.listed, object: nil, queue: .main) { [weak self] _ in152 NotificationCenter.default.addObserver(forName: Notebook.listed, object: nil, queue: .main) { [weak self] _ in
117 guard let self else { return }153 guard let self else { return }
118 reload()154 reload()
...@@ -126,7 +162,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke...@@ -126,7 +162,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
126 override func viewWillAppear(_ animated: Bool) {162 override func viewWillAppear(_ animated: Bool) {
127 super.viewWillAppear(animated)163 super.viewWillAppear(animated)
128 navigationController?.navigationBar.prefersLargeTitles = true164 navigationController?.navigationBar.prefersLargeTitles = true
129 navigationController?.setToolbarHidden(false, animated: false)165 navigationController?.setToolbarHidden(toolbarItems?.isEmpty != false, animated: false)
130 }166 }
131167
132 private func notebook(_ id: String) -> Notebook? { Notebooks.all.first { $0.id == id } }168 private func notebook(_ id: String) -> Notebook? { Notebooks.all.first { $0.id == id } }
...@@ -140,23 +176,32 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke...@@ -140,23 +176,32 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
140 contentUnavailableConfiguration = welcome176 contentUnavailableConfiguration = welcome
141 if welcome != nil { return dataSource.apply(.init(), animatingDifferences: false) }177 if welcome != nil { return dataSource.apply(.init(), animatingDifferences: false) }
142 }178 }
143 let copied = Notebooks.onDevice.contains { $0.source == .documents(path: Notebooks.guide) }179 // A place with no notebooks is left out; the Add Notebook menu reaches every place.
144 let guide: [Item] = Notebooks.guideOffered && !copied ? [.guide] : []180 let locations: [(Location, [Notebook])] = [
145 let cloud: [(Location, [Notebook], [Item])] =181 (.icloud, Notebooks.inCloud), (.onDevice, Notebooks.onDevice), (.elsewhere, Notebooks.elsewhere),
146 ICloud.documents != nil182 ].filter { !$0.1.isEmpty }
147 ? [(.icloud, Notebooks.inCloud, [.newInCloud])]183 if locations.isEmpty {
148 : ICloud.signedIn ? [] : [(.icloud, [], [.turnOnICloud])]184 var empty = UIContentUnavailableConfiguration.empty()
149 let locations: [(Location, [Notebook], [Item])] =185 empty.text = "No Notebooks"
150 cloud + (Notebooks.showsOnDevice ? [(.onDevice, Notebooks.onDevice, [.newNotebook])] : [])186 empty.secondaryText = "Create a notebook, or open one from a server or folder."
151 + [(.elsewhere, Notebooks.elsewhere, [.openFolder, .connect] + guide)]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 var sections = NSDiffableDataSourceSnapshot<Location, Item>()197 var sections = NSDiffableDataSourceSnapshot<Location, Item>()
153 sections.appendSections(locations.map(\.0))198 sections.appendSections(locations.map(\.0))
154 dataSource.apply(sections, animatingDifferences: false)199 dataSource.apply(sections, animatingDifferences: false)
155 for (location, notebooks, actions) in locations {200 for (location, notebooks) in locations {
156 var list = NSDiffableDataSourceSectionSnapshot<Item>()201 var list = NSDiffableDataSourceSectionSnapshot<Item>()
157 let header = Item.location(location)202 let header = Item.location(location)
158 list.append([header])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 for notebook in notebooks {205 for notebook in notebooks {
161 let parent = Item.notebook(notebook.id)206 let parent = Item.notebook(notebook.id)
162 guard notebook.handle != nil else {207 guard notebook.handle != nil else {
...@@ -179,6 +224,36 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke...@@ -179,6 +224,36 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
179 dataSource.apply(list, to: location, animatingDifferences: false)224 dataSource.apply(list, to: location, animatingDifferences: false)
180 }225 }
181 showSelection()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 }
183258
184 /// Lists Documents and iCloud Drive again and opens the notebooks new there; `opened` runs259 /// Lists Documents and iCloud Drive again and opens the notebooks new there; `opened` runs
...@@ -234,13 +309,23 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke...@@ -234,13 +309,23 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
234 content.text = notebook.name309 content.text = notebook.name
235 content.image = UIImage(systemName: "book.closed.fill")310 content.image = UIImage(systemName: "book.closed.fill")
236 content.imageProperties.tintColor = notebook.color311 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 let more = UIButton(type: .system)323 let more = UIButton(type: .system)
239 more.setImage(UIImage(systemName: "ellipsis.circle"), for: .normal)324 more.setImage(UIImage(systemName: "ellipsis.circle"), for: .normal)
240 more.accessibilityLabel = "More"325 more.accessibilityLabel = "More"
241 more.showsMenuAsPrimaryAction = true326 more.showsMenuAsPrimaryAction = true
242 more.menu = menu(for: notebook)327 more.menu = menu(for: notebook)
243 accessories = [328 accessories += [
244 .customView(configuration: .init(customView: more, placement: .trailing(displayed: .always))),329 .customView(configuration: .init(customView: more, placement: .trailing(displayed: .always))),
245 .outlineDisclosure(options: .init(style: .cell)),330 .outlineDisclosure(options: .init(style: .cell)),
246 ]331 ]
...@@ -276,32 +361,25 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke...@@ -276,32 +361,25 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
276 spinner.startAnimating()361 spinner.startAnimating()
277 accessories = [.customView(configuration: .init(customView: spinner, placement: .trailing()))]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 cell.contentConfiguration = content365 cell.contentConfiguration = content
300 cell.accessories = accessories366 cell.accessories = accessories
301 }367 }
302368
303 private func menu(for notebook: Notebook) -> UIMenu {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 UIAction(title: "New Section…", image: UIImage(systemName: "plus.rectangle.portrait")) {383 UIAction(title: "New Section…", image: UIImage(systemName: "plus.rectangle.portrait")) {
306 [weak self] _ in self?.newSection(in: notebook)384 [weak self] _ in self?.newSection(in: notebook)
307 },385 },
...@@ -317,7 +395,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke...@@ -317,7 +395,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
317 Notebooks.remove(notebook)395 Notebooks.remove(notebook)
318 self?.reload()396 self?.reload()
319 }397 }
320 return UIMenu(children: actions + [close])398 return UIMenu(children: status + actions + [close])
321 }399 }
322 let file = [400 let file = [
323 UIAction(title: "Rename…", image: UIImage(systemName: "pencil")) { [weak self] _ in401 UIAction(title: "Rename…", image: UIImage(systemName: "pencil")) { [weak self] _ in
...@@ -337,7 +415,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke...@@ -337,7 +415,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
337 let delete = UIAction(title: "Delete", image: UIImage(systemName: "trash"), attributes: .destructive) {415 let delete = UIAction(title: "Delete", image: UIImage(systemName: "trash"), attributes: .destructive) {
338 [weak self] _ in self?.delete(folder, of: notebook)416 [weak self] _ in self?.delete(folder, of: notebook)
339 }417 }
340 return UIMenu(children: [418 return UIMenu(children: status + [
341 UIMenu(options: .displayInline, children: actions), UIMenu(options: .displayInline, children: file), delete,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,13 +616,6 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
538 collapsed.remove(item)616 collapsed.remove(item)
539 }617 }
540 dataSource.apply(list, to: location)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 case .location: break619 case .location: break
549 }620 }
550 return false621 return false
...@@ -745,7 +816,6 @@ final class PagesViewController: UITableViewController {...@@ -745,7 +816,6 @@ final class PagesViewController: UITableViewController {
745816
746 private(set) var section: Section?817 private(set) var section: Section?
747 private var items: [Item] = []818 private var items: [Item] = []
748 private lazy var sync = SyncIndicator(in: self)
749 private var selected: String?819 private var selected: String?
750 private let search = SearchViewController.controller()820 private let search = SearchViewController.controller()
751 /// Opens a page of the section.821 /// Opens a page of the section.
...@@ -767,12 +837,15 @@ final class PagesViewController: UITableViewController {...@@ -767,12 +837,15 @@ final class PagesViewController: UITableViewController {
767 tableView.register(UITableViewCell.self, forCellReuseIdentifier: "page")837 tableView.register(UITableViewCell.self, forCellReuseIdentifier: "page")
768 navigationItem.searchController = search838 navigationItem.searchController = search
769 navigationItem.hidesSearchBarWhenScrolling = false839 navigationItem.hidesSearchBarWhenScrolling = false
770 let compose = UIBarButtonItem(840 let add = UIBarButtonItem(
771 title: "New Page", image: UIImage(systemName: "square.and.pencil"),841 title: "New Page", image: UIImage(systemName: "plus"),
772 primaryAction: UIAction { [weak self] _ in self?.newPage(under: nil) })842 primaryAction: UIAction { [weak self] _ in self?.newPage(under: nil) })
773 toolbarItems = [843 if #available(iOS 26, *) {
774 .flexibleSpace(), sync, .flexibleSpace(), compose,844 navigationItem.preferredSearchBarPlacement = .integrated
775 ]845 toolbarItems = [navigationItem.searchBarPlacementBarButtonItem, .fixedSpace(), add]
846 } else {
847 toolbarItems = [.flexibleSpace(), add]
848 }
776 NotificationCenter.default.addObserver(849 NotificationCenter.default.addObserver(
777 self, selector: #selector(changed), name: Section.changed, object: nil)850 self, selector: #selector(changed), name: Section.changed, object: nil)
778 if Prototype.sectionStrip {851 if Prototype.sectionStrip {
apps/ios/Snowbound/Prototypes.swift+80-2
...@@ -1,8 +1,8 @@...@@ -1,8 +1,8 @@
1import UIKit1import UIKit
22
3/// Design prototypes, off unless a launch argument turns one on, as3/// Design prototypes, off unless a launch argument turns one on, as
4/// `-SnowboundReading YES`, `-SnowboundWelcome YES` or `-SnowboundSectionStrip YES` (Xcode's4/// `-SnowboundReading YES`, `-SnowboundWelcome YES`, `-SnowboundSectionStrip YES` or
5/// scheme, `simctl launch`, or `devicectl … --arguments`).5/// `-SnowboundQuickNote YES` (Xcode's scheme, `simctl launch`, or `devicectl … --arguments`).
6enum Prototype {6enum Prototype {
7 /// Page menu: Reading View, the page reflowed into the screen's width, read-only.7 /// Page menu: Reading View, the page reflowed into the screen's width, read-only.
8 static var reading: Bool { UserDefaults.standard.bool(forKey: "SnowboundReading") }8 static var reading: Bool { UserDefaults.standard.bool(forKey: "SnowboundReading") }
...@@ -239,3 +239,81 @@ final class SectionStrip: UIView {...@@ -239,3 +239,81 @@ final class SectionStrip: UIView {
239 UIImage(systemName: "rectangle.portrait.fill")?.withTintColor(color, renderingMode: .alwaysOriginal)239 UIImage(systemName: "rectangle.portrait.fill")?.withTintColor(color, renderingMode: .alwaysOriginal)
240 }240 }
241}241}
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 {...@@ -66,7 +66,7 @@ enum Sync {
66 return switch state {66 return switch state {
67 case .upToDate: "checkmark.circle"67 case .upToDate: "checkmark.circle"
68 case .syncing, .inUse: "arrow.triangle.2.circlepath"68 case .syncing, .inUse: "arrow.triangle.2.circlepath"
69 case .notConnected: "wifi.slash"69 case .notConnected: "slash.circle"
70 case .protected: "lock"70 case .protected: "lock"
71 case .readOnly, .unreadable, .failed: "exclamationmark.triangle"71 case .readOnly, .unreadable, .failed: "exclamationmark.triangle"
72 }72 }
...@@ -85,6 +85,43 @@ enum Sync {...@@ -85,6 +85,43 @@ enum Sync {
85 }85 }
86 }86 }
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
88 static func changes(_ count: Int) -> String { count == 1 ? "1 change" : "\(count) changes" }125 static func changes(_ count: Int) -> String { count == 1 ? "1 change" : "\(count) changes" }
89126
90 /// When a section was last reached: the time, and the date too before today.127 /// When a section was last reached: the time, and the date too before today.
...@@ -96,80 +133,56 @@ enum Sync {...@@ -96,80 +133,56 @@ enum Sync {
96 }133 }
97}134}
98135
99/// The sync status in a list's toolbar, as Mail shows when it last checked; opens the sync136/// A passing note at the top of `window` that opens `details` when tapped, for a change the
100/// sheet. The item leaves `owner`'s toolbar while there is nothing to report (hidden, its137/// reader would otherwise miss.
101/// custom view would leave the bar's glass behind), so the owner keeps it.138enum SyncToast {
102final class SyncIndicator: UIBarButtonItem {139 static func show(_ title: String, _ detail: String, in window: UIWindow, details: @escaping () -> Void) {
103 private let button = UIButton(type: .system)140 var configuration: UIButton.Configuration
104 private weak var owner: UIViewController?141 if #available(iOS 26, *) {
105 /// Where in the toolbar the item goes back to.142 configuration = .glass()
106 private var slot = 0143 } else {
107144 configuration = .gray()
108 init(in owner: UIViewController) {145 configuration.cornerStyle = .capsule
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
120 }146 }
121 button.configuration = configuration147 configuration.title = title
122 button.addAction(UIAction { [weak self] _ in self?.present() }, for: .primaryActionTriggered)148 configuration.subtitle = detail
123 customView = button149 configuration.image = UIImage(systemName: "exclamationmark.icloud")
124 for name in [Sync.changed, Section.changed] {150 configuration.imagePadding = 10
125 NotificationCenter.default.addObserver(self, selector: #selector(refresh), name: name, object: nil)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() }
126 }159 }
127 refresh()160 toast.addAction(UIAction { _ in
128 }161 dismiss()
129162 details()
130 required init?(coder: NSCoder) { fatalError() }163 }, for: .primaryActionTriggered)
131164 window.addSubview(toast)
132 @objc private func refresh() {165 NSLayoutConstraint.activate([
133 Sync.status { [weak self] notebooks in166 toast.topAnchor.constraint(equalTo: window.safeAreaLayoutGuide.topAnchor, constant: 8),
134 let sections = notebooks.flatMap(\.1)167 toast.centerXAnchor.constraint(equalTo: window.centerXAnchor),
135 guard let self else { return }168 toast.widthAnchor.constraint(lessThanOrEqualTo: window.widthAnchor, constant: -32),
136 guard let worst = sections.map(\.state).max() else { return show(false) }169 ])
137 let queued = sections.map(\.queued).reduce(0, +)170 UIView.animate(withDuration: 0.3) { toast.alpha = 1 }
138 let label = Sync.label(worst)171 UIAccessibility.post(notification: .announcement, argument: "\(title). \(detail)")
139 button.configuration?.title = queued > 0 && worst != .syncing ? "\(label), \(Sync.changes(queued))" : label172 DispatchQueue.main.asyncAfter(deadline: .now() + 6, execute: dismiss)
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 }173 }
174}
161175
162 private func present() {176/// OneNote's Notebook Sync Status: Work Offline, Sync Now, and each notebook's sections.
163 guard let controller = button.window?.rootViewController else { return }177final class SyncViewController: UITableViewController {
178 /// Shows the status as a sheet over `controller`.
179 static func present(from controller: UIViewController) {
164 let navigation = UINavigationController(rootViewController: SyncViewController())180 let navigation = UINavigationController(rootViewController: SyncViewController())
165 navigation.sheetPresentationController?.detents = [.medium(), .large()]181 navigation.sheetPresentationController?.detents = [.medium(), .large()]
166 navigation.sheetPresentationController?.prefersGrabberVisible = true182 navigation.sheetPresentationController?.prefersGrabberVisible = true
167 (controller.presentedViewController ?? controller).present(navigation, animated: true)183 (controller.presentedViewController ?? controller).present(navigation, animated: true)
168 }184 }
169}
170185
171/// OneNote's Notebook Sync Status: Work Offline, Sync Now, and each notebook's sections.
172final class SyncViewController: UITableViewController {
173 private var notebooks: [(Notebook, [SectionSync])] = []186 private var notebooks: [(Notebook, [SectionSync])] = []
174 private var timer: Timer?187 private var timer: Timer?
175 private let offline = UISwitch()188 private let offline = UISwitch()