From 23a1ac633199364044fafb816fa6026524ff62f7 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Thu, 1 Oct 2026 10:46:17 -0700 Subject: [PATCH] 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 --- apps/ios/Snowbound/Lists.swift | 6 ++-- apps/ios/Snowbound/Sync.swift | 54 +++++++++++++++++++++++----------- 2 files changed, 41 insertions(+), 19 deletions(-) diff --git a/apps/ios/Snowbound/Lists.swift b/apps/ios/Snowbound/Lists.swift index ce578f1d86747a218eeb30943494deb8173a1249..136f9b12ccc0bf434edc0a7652c94372f5dbd8c5 100644 --- a/apps/ios/Snowbound/Lists.swift +++ b/apps/ios/Snowbound/Lists.swift @@ -30,6 +30,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke private var dataSource: UICollectionViewDiffableDataSource! private var collapsed: Set = [] + private lazy var sync = SyncIndicator(in: self) private var selected: Item? var onOpen: ((Tab, Notebook) -> Void)? @@ -111,7 +112,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke navigationItem.rightBarButtonItem = UIBarButtonItem( title: "Add Notebook", image: UIImage(systemName: "plus"), menu: add) navigationItem.searchController = SearchViewController.controller() - toolbarItems = [.flexibleSpace(), UIBarButtonItem(customView: SyncIndicator()), .flexibleSpace()] + toolbarItems = [.flexibleSpace(), sync, .flexibleSpace()] NotificationCenter.default.addObserver(forName: Notebook.listed, object: nil, queue: .main) { [weak self] _ in guard let self else { return } reload() @@ -714,6 +715,7 @@ 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. @@ -734,7 +736,7 @@ final class PagesViewController: UITableViewController { title: "New Page", image: UIImage(systemName: "square.and.pencil"), primaryAction: UIAction { [weak self] _ in self?.newPage(under: nil) }) toolbarItems = [ - .flexibleSpace(), UIBarButtonItem(customView: SyncIndicator()), .flexibleSpace(), compose, + .flexibleSpace(), sync, .flexibleSpace(), compose, ] NotificationCenter.default.addObserver( self, selector: #selector(changed), name: Section.changed, object: nil) diff --git a/apps/ios/Snowbound/Sync.swift b/apps/ios/Snowbound/Sync.swift index 6aa4f2e6b783952d0964f578acdd170ae7b5f3ba..a779d4b5e08c03c164be71d2b7c0f75584934699 100644 --- a/apps/ios/Snowbound/Sync.swift +++ b/apps/ios/Snowbound/Sync.swift @@ -97,9 +97,17 @@ enum Sync { } /// The sync status in a list's toolbar, as Mail shows when it last checked; opens the sync -/// sheet. -final class SyncIndicator: UIButton { - init() { +/// 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) @@ -110,9 +118,9 @@ final class SyncIndicator: UIButton { attributes.font = .preferredFont(forTextStyle: .caption1) return attributes } - super.init(frame: .zero) - self.configuration = configuration - addAction(UIAction { [weak self] _ in self?.present() }, for: .primaryActionTriggered) + 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) } @@ -124,23 +132,35 @@ final class SyncIndicator: UIButton { @objc private func refresh() { Sync.status { [weak self] notebooks in let sections = notebooks.flatMap(\.1) - guard let self, let worst = sections.map(\.state).max() else { - self?.isHidden = true - return - } - isHidden = false + 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) - configuration?.title = queued > 0 && worst != .syncing ? "\(label), \(Sync.changes(queued))" : label - configuration?.image = UIImage(systemName: Sync.symbol(worst)) - configuration?.baseForegroundColor = worst >= .notConnected && !Sync.offline ? .systemOrange : .secondaryLabel - accessibilityLabel = "Sync status: \(configuration?.title ?? label)" - sizeToFit() + 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) } } + /// 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 + } + owner.setToolbarItems(items, animated: true) + } + private func present() { - guard let controller = window?.rootViewController else { return } + guard let controller = button.window?.rootViewController else { return } let navigation = UINavigationController(rootViewController: SyncViewController()) navigation.sheetPresentationController?.detents = [.medium(), .large()] navigation.sheetPresentationController?.prefersGrabberVisible = true -- 2.54.0