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
3030
3131 private var dataSource: UICollectionViewDiffableDataSource<Location, Item>!
3232 private var collapsed: Set<Item> = []
33 private lazy var sync = SyncIndicator(in: self)
3334 private var selected: Item?
3435 var onOpen: ((Tab, Notebook) -> Void)?
3536
......@@ -111,7 +112,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
111112 navigationItem.rightBarButtonItem = UIBarButtonItem(
112113 title: "Add Notebook", image: UIImage(systemName: "plus"), menu: add)
113114 navigationItem.searchController = SearchViewController.controller()
114 toolbarItems = [.flexibleSpace(), UIBarButtonItem(customView: SyncIndicator()), .flexibleSpace()]
115 toolbarItems = [.flexibleSpace(), sync, .flexibleSpace()]
115116 NotificationCenter.default.addObserver(forName: Notebook.listed, object: nil, queue: .main) { [weak self] _ in
116117 guard let self else { return }
117118 reload()
......@@ -714,6 +715,7 @@ final class PagesViewController: UITableViewController {
714715
715716 private(set) var section: Section?
716717 private var items: [Item] = []
718 private lazy var sync = SyncIndicator(in: self)
717719 private var selected: String?
718720 private let search = SearchViewController.controller()
719721 /// Opens a page of the section.
......@@ -734,7 +736,7 @@ final class PagesViewController: UITableViewController {
734736 title: "New Page", image: UIImage(systemName: "square.and.pencil"),
735737 primaryAction: UIAction { [weak self] _ in self?.newPage(under: nil) })
736738 toolbarItems = [
737 .flexibleSpace(), UIBarButtonItem(customView: SyncIndicator()), .flexibleSpace(), compose,
739 .flexibleSpace(), sync, .flexibleSpace(), compose,
738740 ]
739741 NotificationCenter.default.addObserver(
740742 self, selector: #selector(changed), name: Section.changed, object: nil)
apps/ios/Snowbound/Sync.swift+37-17
......@@ -97,9 +97,17 @@ enum Sync {
9797}
9898
9999/// The sync status in a list's toolbar, as Mail shows when it last checked; opens the sync
100/// sheet.
101final class SyncIndicator: UIButton {
102 init() {
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()
103111 var configuration = UIButton.Configuration.plain()
104112 configuration.imagePadding = 4
105113 configuration.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration(textStyle: .caption1)
......@@ -110,9 +118,9 @@ final class SyncIndicator: UIButton {
110118 attributes.font = .preferredFont(forTextStyle: .caption1)
111119 return attributes
112120 }
113 super.init(frame: .zero)
114 self.configuration = configuration
115 addAction(UIAction { [weak self] _ in self?.present() }, for: .primaryActionTriggered)
121 button.configuration = configuration
122 button.addAction(UIAction { [weak self] _ in self?.present() }, for: .primaryActionTriggered)
123 customView = button
116124 for name in [Sync.changed, Section.changed] {
117125 NotificationCenter.default.addObserver(self, selector: #selector(refresh), name: name, object: nil)
118126 }
......@@ -124,23 +132,35 @@ final class SyncIndicator: UIButton {
124132 @objc private func refresh() {
125133 Sync.status { [weak self] notebooks in
126134 let sections = notebooks.flatMap(\.1)
127 guard let self, let worst = sections.map(\.state).max() else {
128 self?.isHidden = true
129 return
130 }
131 isHidden = false
135 guard let self else { return }
136 guard let worst = sections.map(\.state).max() else { return show(false) }
132137 let queued = sections.map(\.queued).reduce(0, +)
133138 let label = Sync.label(worst)
134 configuration?.title = queued > 0 && worst != .syncing ? "\(label), \(Sync.changes(queued))" : label
135 configuration?.image = UIImage(systemName: Sync.symbol(worst))
136 configuration?.baseForegroundColor = worst >= .notConnected && !Sync.offline ? .systemOrange : .secondaryLabel
137 accessibilityLabel = "Sync status: \(configuration?.title ?? label)"
138 sizeToFit()
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
139158 }
159 owner.setToolbarItems(items, animated: true)
140160 }
141161
142162 private func present() {
143 guard let controller = window?.rootViewController else { return }
163 guard let controller = button.window?.rootViewController else { return }
144164 let navigation = UINavigationController(rootViewController: SyncViewController())
145165 navigation.sheetPresentationController?.detents = [.medium(), .large()]
146166 navigation.sheetPresentationController?.prefersGrabberVisible = true