authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-01 10:46:17-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-01 10:47:41-07:00
log23a1ac633199364044fafb816fa6026524ff62f7
tree5aa4575c2aa130e8cb5e075cf80a4533db4e8c5a
parentdee97ed97715760ac31584170eef95a7aa509ae5
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

fix: take the iOS sync status out of list toolbars while it has nothing to say

A hidden custom view still left its toolbar a glass capsule, empty at the bottom of every list with no notebooks. The indicator is now the bar item itself and leaves its owner's toolbar instead, returning to the same place when a section reports. Assisted-by: claude-opus-5.5

2 files changed, 41 insertions(+), 19 deletions(-)

apps/ios/Snowbound/Lists.swift+4-2
...@@ -30,6 +30,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke...@@ -30,6 +30,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
3030
31 private var dataSource: UICollectionViewDiffableDataSource<Location, Item>!31 private var dataSource: UICollectionViewDiffableDataSource<Location, Item>!
32 private var collapsed: Set<Item> = []32 private var collapsed: Set<Item> = []
33 private lazy var sync = SyncIndicator(in: self)
33 private var selected: Item?34 private var selected: Item?
34 var onOpen: ((Tab, Notebook) -> Void)?35 var onOpen: ((Tab, Notebook) -> Void)?
3536
...@@ -111,7 +112,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke...@@ -111,7 +112,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
111 navigationItem.rightBarButtonItem = UIBarButtonItem(112 navigationItem.rightBarButtonItem = UIBarButtonItem(
112 title: "Add Notebook", image: UIImage(systemName: "plus"), menu: add)113 title: "Add Notebook", image: UIImage(systemName: "plus"), menu: add)
113 navigationItem.searchController = SearchViewController.controller()114 navigationItem.searchController = SearchViewController.controller()
114 toolbarItems = [.flexibleSpace(), UIBarButtonItem(customView: SyncIndicator()), .flexibleSpace()]115 toolbarItems = [.flexibleSpace(), sync, .flexibleSpace()]
115 NotificationCenter.default.addObserver(forName: Notebook.listed, object: nil, queue: .main) { [weak self] _ in116 NotificationCenter.default.addObserver(forName: Notebook.listed, object: nil, queue: .main) { [weak self] _ in
116 guard let self else { return }117 guard let self else { return }
117 reload()118 reload()
...@@ -714,6 +715,7 @@ final class PagesViewController: UITableViewController {...@@ -714,6 +715,7 @@ final class PagesViewController: UITableViewController {
714715
715 private(set) var section: Section?716 private(set) var section: Section?
716 private var items: [Item] = []717 private var items: [Item] = []
718 private lazy var sync = SyncIndicator(in: self)
717 private var selected: String?719 private var selected: String?
718 private let search = SearchViewController.controller()720 private let search = SearchViewController.controller()
719 /// Opens a page of the section.721 /// Opens a page of the section.
...@@ -734,7 +736,7 @@ final class PagesViewController: UITableViewController {...@@ -734,7 +736,7 @@ final class PagesViewController: UITableViewController {
734 title: "New Page", image: UIImage(systemName: "square.and.pencil"),736 title: "New Page", image: UIImage(systemName: "square.and.pencil"),
735 primaryAction: UIAction { [weak self] _ in self?.newPage(under: nil) })737 primaryAction: UIAction { [weak self] _ in self?.newPage(under: nil) })
736 toolbarItems = [738 toolbarItems = [
737 .flexibleSpace(), UIBarButtonItem(customView: SyncIndicator()), .flexibleSpace(), compose,739 .flexibleSpace(), sync, .flexibleSpace(), compose,
738 ]740 ]
739 NotificationCenter.default.addObserver(741 NotificationCenter.default.addObserver(
740 self, selector: #selector(changed), name: Section.changed, object: nil)742 self, selector: #selector(changed), name: Section.changed, object: nil)
apps/ios/Snowbound/Sync.swift+37-17
...@@ -97,9 +97,17 @@ enum Sync {...@@ -97,9 +97,17 @@ enum Sync {
97}97}
9898
99/// The sync status in a list's toolbar, as Mail shows when it last checked; opens the sync99/// The sync status in a list's toolbar, as Mail shows when it last checked; opens the sync
100/// sheet.100/// sheet. The item leaves `owner`'s toolbar while there is nothing to report (hidden, its
101final class SyncIndicator: UIButton {101/// custom view would leave the bar's glass behind), so the owner keeps it.
102 init() {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()
103 var configuration = UIButton.Configuration.plain()111 var configuration = UIButton.Configuration.plain()
104 configuration.imagePadding = 4112 configuration.imagePadding = 4
105 configuration.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration(textStyle: .caption1)113 configuration.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration(textStyle: .caption1)
...@@ -110,9 +118,9 @@ final class SyncIndicator: UIButton {...@@ -110,9 +118,9 @@ final class SyncIndicator: UIButton {
110 attributes.font = .preferredFont(forTextStyle: .caption1)118 attributes.font = .preferredFont(forTextStyle: .caption1)
111 return attributes119 return attributes
112 }120 }
113 super.init(frame: .zero)121 button.configuration = configuration
114 self.configuration = configuration122 button.addAction(UIAction { [weak self] _ in self?.present() }, for: .primaryActionTriggered)
115 addAction(UIAction { [weak self] _ in self?.present() }, for: .primaryActionTriggered)123 customView = button
116 for name in [Sync.changed, Section.changed] {124 for name in [Sync.changed, Section.changed] {
117 NotificationCenter.default.addObserver(self, selector: #selector(refresh), name: name, object: nil)125 NotificationCenter.default.addObserver(self, selector: #selector(refresh), name: name, object: nil)
118 }126 }
...@@ -124,23 +132,35 @@ final class SyncIndicator: UIButton {...@@ -124,23 +132,35 @@ final class SyncIndicator: UIButton {
124 @objc private func refresh() {132 @objc private func refresh() {
125 Sync.status { [weak self] notebooks in133 Sync.status { [weak self] notebooks in
126 let sections = notebooks.flatMap(\.1)134 let sections = notebooks.flatMap(\.1)
127 guard let self, let worst = sections.map(\.state).max() else {135 guard let self else { return }
128 self?.isHidden = true136 guard let worst = sections.map(\.state).max() else { return show(false) }
129 return
130 }
131 isHidden = false
132 let queued = sections.map(\.queued).reduce(0, +)137 let queued = sections.map(\.queued).reduce(0, +)
133 let label = Sync.label(worst)138 let label = Sync.label(worst)
134 configuration?.title = queued > 0 && worst != .syncing ? "\(label), \(Sync.changes(queued))" : label139 button.configuration?.title = queued > 0 && worst != .syncing ? "\(label), \(Sync.changes(queued))" : label
135 configuration?.image = UIImage(systemName: Sync.symbol(worst))140 button.configuration?.image = UIImage(systemName: Sync.symbol(worst))
136 configuration?.baseForegroundColor = worst >= .notConnected && !Sync.offline ? .systemOrange : .secondaryLabel141 button.configuration?.baseForegroundColor =
137 accessibilityLabel = "Sync status: \(configuration?.title ?? label)"142 worst >= .notConnected && !Sync.offline ? .systemOrange : .secondaryLabel
138 sizeToFit()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
139 }158 }
159 owner.setToolbarItems(items, animated: true)
140 }160 }
141161
142 private func present() {162 private func present() {
143 guard let controller = window?.rootViewController else { return }163 guard let controller = button.window?.rootViewController else { return }
144 let navigation = UINavigationController(rootViewController: SyncViewController())164 let navigation = UINavigationController(rootViewController: SyncViewController())
145 navigation.sheetPresentationController?.detents = [.medium(), .large()]165 navigation.sheetPresentationController?.detents = [.medium(), .large()]
146 navigation.sheetPresentationController?.prefersGrabberVisible = true166 navigation.sheetPresentationController?.prefersGrabberVisible = true