From b69fb7fb1f87804299fb3fa5fd47d39d1fb04a20 Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Thu, 10 Sep 2026 22:40:44 +0200 Subject: [PATCH] Let the switcher icon say how the account is doing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only the non-tvOS toolbar menu computed a health-aware glyph; the settings row and the tvOS button hardcoded the plain outline, so on tvOS the icon never changed no matter what broke (TV7). One shared icon now serves all three: outline for Local, filled for a signed-in account, and an orange exclamation badge when the context needs a person — credentials rejected, instance disabled, or unreachable. Transient states like checking stay unbadged so launch doesn't flash a false alarm. --- .../Components/DataContextSwitcherMenu.swift | 115 +++++++++++++++--- YatteeTests/AccountsScreenTests.swift | 39 ++++++ 2 files changed, 136 insertions(+), 18 deletions(-) diff --git a/Yattee/Views/Components/DataContextSwitcherMenu.swift b/Yattee/Views/Components/DataContextSwitcherMenu.swift index 48f1a9cf..a68d510d 100644 --- a/Yattee/Views/Components/DataContextSwitcherMenu.swift +++ b/Yattee/Views/Components/DataContextSwitcherMenu.swift @@ -134,6 +134,88 @@ enum DataContextSwitcherLabels { } } +// MARK: - Glyph + +/// How the switcher's icon reads. +/// +/// Shared by every presentation of the switcher — the toolbar menu, the +/// settings row, the tvOS button — so the same account health cannot look +/// healthy in one of them and not in another. +enum DataContextSwitcherGlyph { + /// Whether the icon should ask to be noticed. + /// + /// Only the states the user can actually clear: signing in again, turning + /// the instance back on, or switching away. A context that is merely still + /// resolving is not one of them — badging `checking` would put an + /// exclamation mark on every cold launch, before the Keychain has answered. + static func needsAttention(_ validity: DataContextValidity) -> Bool { + switch validity { + case .valid: + return false + case .removed: + return true + case let .temporarilyUnavailable(reason): + switch reason { + case .instanceDisabled, .credentialRejected, .unreachable: return true + case .checking, .instancePending, .credentialMissing: return false + } + } + } + + /// The filled glyph says an account is selected; the badge says that + /// account cannot be used right now, which is the whole reason the user + /// would reach for this control. + static func systemImage(for context: DataContext, validity: DataContextValidity) -> String { + guard !context.isLocal else { return "person.crop.circle" } + + if needsAttention(validity) { return "person.crop.circle.badge.exclamationmark" } + return "person.crop.circle.fill" + } + + /// Orange — the colour the settings entry already badges attention with — + /// and no tint at all otherwise, so a healthy switcher keeps whatever + /// styling the toolbar or the list gives it. + static func tint(for context: DataContext, validity: DataContextValidity) -> Color? { + guard !context.isLocal, needsAttention(validity) else { return nil } + return .orange + } + + @MainActor + static func systemImage(in appEnvironment: AppEnvironment?) -> String { + guard let appEnvironment else { return "person.crop.circle" } + + return systemImage( + for: appEnvironment.accountsManager.currentContext, + validity: appEnvironment.dataContextValidator.validity + ) + } + + @MainActor + static func tint(in appEnvironment: AppEnvironment?) -> Color? { + guard let appEnvironment else { return nil } + + return tint( + for: appEnvironment.accountsManager.currentContext, + validity: appEnvironment.dataContextValidator.validity + ) + } +} + +/// The switcher's icon, health included. +struct DataContextSwitcherIcon: View { + @Environment(\.appEnvironment) private var appEnvironment + + var body: some View { + let image = Image(systemName: DataContextSwitcherGlyph.systemImage(in: appEnvironment)) + + if let tint = DataContextSwitcherGlyph.tint(in: appEnvironment) { + image.foregroundStyle(tint) + } else { + image + } + } +} + // MARK: - Menu #if !os(tvOS) @@ -145,7 +227,11 @@ struct DataContextSwitcherMenu: View { Menu { DataContextSwitcherRows() } label: { - Label(String(localized: "accounts.switcher.title"), systemImage: icon) + Label { + Text(String(localized: "accounts.switcher.title")) + } icon: { + DataContextSwitcherIcon() + } } .help(DataContextSwitcherLabels.title(for: currentContext, in: appEnvironment)) } @@ -153,18 +239,6 @@ struct DataContextSwitcherMenu: View { private var currentContext: DataContext { appEnvironment?.accountsManager.currentContext ?? .local } - - /// The filled glyph says an account is selected; the badge says that - /// account cannot be used right now, which is the whole reason the user - /// would reach for this control. - private var icon: String { - guard !currentContext.isLocal else { return "person.crop.circle" } - - if appEnvironment?.dataContextValidator.unavailability != nil { - return "person.crop.circle.badge.exclamationmark" - } - return "person.crop.circle.fill" - } } #endif @@ -203,7 +277,11 @@ struct DataContextSwitcherSettingsRow: View { private var rowLabel: some View { HStack { - Label(String(localized: "accounts.switcher.title"), systemImage: "person.crop.circle") + Label { + Text(String(localized: "accounts.switcher.title")) + } icon: { + DataContextSwitcherIcon() + } Spacer(minLength: 8) Text(DataContextSwitcherLabels.title(for: currentContext, in: appEnvironment)) .foregroundStyle(.secondary) @@ -262,10 +340,11 @@ struct DataContextSwitcherButton: View { Button { showingPicker = true } label: { - Label( - DataContextSwitcherLabels.title(for: currentContext, in: appEnvironment), - systemImage: "person.crop.circle" - ) + Label { + Text(DataContextSwitcherLabels.title(for: currentContext, in: appEnvironment)) + } icon: { + DataContextSwitcherIcon() + } } .buttonStyle(.bordered) .fullScreenCover(isPresented: $showingPicker) { diff --git a/YatteeTests/AccountsScreenTests.swift b/YatteeTests/AccountsScreenTests.swift index c7423534..bc7fd0fa 100644 --- a/YatteeTests/AccountsScreenTests.swift +++ b/YatteeTests/AccountsScreenTests.swift @@ -322,6 +322,45 @@ struct AccountStatusBadgeTests { } } +// MARK: - Switcher glyph + +@Suite("Data context switcher glyph") +struct DataContextSwitcherGlyphTests { + private let account = DataContext.account(UUID()) + + @Test("Local data and a signed-in account read differently") + func healthyStatesAreToldApart() { + #expect(DataContextSwitcherGlyph.systemImage(for: .local, validity: .valid) == "person.crop.circle") + #expect(DataContextSwitcherGlyph.systemImage(for: account, validity: .valid) == "person.crop.circle.fill") + #expect(DataContextSwitcherGlyph.tint(for: account, validity: .valid) == nil) + } + + @Test("A state the user has to act on is badged, in the settings entry's colour") + func actionableStatesAreBadged() { + let actionable: [DataContextUnavailability] = [.instanceDisabled, .credentialRejected, .unreachable] + + for reason in actionable { + let validity = DataContextValidity.temporarilyUnavailable(reason) + #expect( + DataContextSwitcherGlyph.systemImage(for: account, validity: validity) + == "person.crop.circle.badge.exclamationmark" + ) + #expect(DataContextSwitcherGlyph.tint(for: account, validity: validity) == .orange) + } + } + + @Test("A context that is still resolving is not a fault") + func pendingStatesAreNotBadged() { + let pending: [DataContextUnavailability] = [.checking, .instancePending, .credentialMissing] + + for reason in pending { + let validity = DataContextValidity.temporarilyUnavailable(reason) + #expect(DataContextSwitcherGlyph.systemImage(for: account, validity: validity) == "person.crop.circle.fill") + #expect(DataContextSwitcherGlyph.tint(for: account, validity: validity) == nil) + } + } +} + // MARK: - Switcher labels @Suite("Data context switcher labels")