mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 21:22:53 +00:00
Let the switcher icon say how the account is doing
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.
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user