Files
yattee/Yattee/Views/Components/DataContextSwitcherMenu.swift
Arkadiusz Fal b69fb7fb1f 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.
2026-09-10 22:40:44 +02:00

360 lines
12 KiB
Swift

//
// DataContextSwitcherMenu.swift
// Yattee
//
// The one place the selected data context is switched from.
//
import SwiftUI
// MARK: - Rows
/// The switchable contexts, as buttons.
///
/// Shared by every presentation of the switcher so a context that is offered in
/// one place is offered — and named — identically in all of them.
struct DataContextSwitcherRows: View {
@Environment(\.appEnvironment) private var appEnvironment
/// Called after a switch, for presentations that have to close themselves.
var onSwitch: (() -> Void)?
private var validator: DataContextValidator? { appEnvironment?.dataContextValidator }
var body: some View {
ForEach(validator?.availableContexts ?? [.local], id: \.self) { context in
Button {
appEnvironment?.switchDataContext(to: context)
onSwitch?()
} label: {
rowLabel(for: context)
}
#if os(tvOS)
.buttonStyle(TVSettingsButtonStyle())
#endif
}
}
@ViewBuilder
private func rowLabel(for context: DataContext) -> some View {
let title = DataContextSwitcherLabels.title(for: context, in: appEnvironment)
// A menu row is a single label on macOS — a `VStack` inside one renders
// as nothing there — so the status travels in the title rather than as
// a second line.
if isCurrent(context) {
Label(title, systemImage: "checkmark")
} else {
Text(title)
}
}
private func isCurrent(_ context: DataContext) -> Bool {
appEnvironment?.accountsManager.currentContext == context
}
}
// MARK: - Labels
/// Names the switchable contexts.
enum DataContextSwitcherLabels {
/// The context's name, plus whatever the user needs to tell it apart.
@MainActor
static func title(for context: DataContext, in appEnvironment: AppEnvironment?) -> String {
guard let appEnvironment else { return String(localized: "subscriptions.account.local") }
return title(
for: context,
accountsManager: appEnvironment.accountsManager,
validator: appEnvironment.dataContextValidator,
username: { appEnvironment.accountCredentialsManager(for: $0)?.username(for: $0) }
)
}
/// The same label, built from only what it needs.
///
/// Taking the pieces rather than the environment is what lets the naming
/// rules be exercised on their own: which suffix a name gets depends on the
/// other accounts, the stored identifier and the host, and on nothing else.
@MainActor
static func title(
for context: DataContext,
accountsManager: AccountsManager,
validator: DataContextValidator,
username: (Account) -> String?
) -> String {
let name = validator.displayName(for: context)
guard case let .account(id) = context, let account = accountsManager.account(id: id) else {
return name
}
var title = name
if isAmbiguous(name, accountsManager: accountsManager, validator: validator) {
title = disambiguated(
name,
username: username(account),
host: accountsManager.instance(for: account)?.url.host
)
}
let status = accountsManager.status(for: id)
guard status != .signedIn else { return title }
return "\(title) — \(AccountStatusBadge.text(for: status))"
}
/// What a shared name needs added to it before it names one account.
///
/// The identifier comes first: two logins on the *same* instance share a
/// host, so a host suffix would repeat the server and still leave the user
/// choosing between two identical rows. The host alone is what is left when
/// the identifier is not on this device — tvOS never receives one — and it
/// is dropped when the name already carries it.
static func disambiguated(_ name: String, username: String?, host: String?) -> String {
if let username, !username.isEmpty {
guard let host, !host.isEmpty else { return "\(name) · \(username)" }
return "\(name) · \(username)@\(host)"
}
guard let host, !host.isEmpty, !name.localizedCaseInsensitiveContains(host) else { return name }
return "\(name) · \(host)"
}
@MainActor
private static func isAmbiguous(
_ name: String,
accountsManager: AccountsManager,
validator: DataContextValidator
) -> Bool {
accountsManager.selectableAccounts
.filter { validator.displayName(for: $0) == name }
.count > 1
}
}
// 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)
/// The toolbar switcher: one tap, no confirmation — switching is reversible.
struct DataContextSwitcherMenu: View {
@Environment(\.appEnvironment) private var appEnvironment
var body: some View {
Menu {
DataContextSwitcherRows()
} label: {
Label {
Text(String(localized: "accounts.switcher.title"))
} icon: {
DataContextSwitcherIcon()
}
}
.help(DataContextSwitcherLabels.title(for: currentContext, in: appEnvironment))
}
private var currentContext: DataContext {
appEnvironment?.accountsManager.currentContext ?? .local
}
}
#endif
/// A settings row naming the current context, with the switcher attached.
struct DataContextSwitcherSettingsRow: View {
@Environment(\.appEnvironment) private var appEnvironment
#if os(tvOS)
@State private var showingPicker = false
#endif
var body: some View {
#if os(tvOS)
Button {
showingPicker = true
} label: {
rowLabel
}
.buttonStyle(TVSettingsButtonStyle())
.fullScreenCover(isPresented: $showingPicker) {
DataContextSwitcherPicker()
}
#else
Menu {
DataContextSwitcherRows()
} label: {
rowLabel
}
#if os(macOS)
// Both callers are full-width rows, so the default style's stretching
// is wanted here — only its chrome is not.
.menuStyle(.borderlessButton)
#endif
#endif
}
private var rowLabel: some View {
HStack {
Label {
Text(String(localized: "accounts.switcher.title"))
} icon: {
DataContextSwitcherIcon()
}
Spacer(minLength: 8)
Text(DataContextSwitcherLabels.title(for: currentContext, in: appEnvironment))
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
private var currentContext: DataContext {
appEnvironment?.accountsManager.currentContext ?? .local
}
}
// MARK: - tvOS picker
#if os(tvOS)
/// The tvOS switcher, presented in a `fullScreenCover`: menus are unusable
/// there, and a sheet is a card too small to read.
struct DataContextSwitcherPicker: View {
@Environment(\.appEnvironment) private var appEnvironment
@Environment(\.dismiss) private var dismiss
var body: some View {
ZStack {
Color.black.ignoresSafeArea()
VStack(alignment: .leading, spacing: 24) {
Text(String(localized: "accounts.switcher.title"))
.font(.title2.bold())
ScrollView {
VStack(alignment: .leading, spacing: 12) {
DataContextSwitcherRows {
dismiss()
}
}
}
Button(String(localized: "common.close")) {
dismiss()
}
.buttonStyle(TVSettingsButtonStyle())
}
.frame(maxWidth: 900)
.padding(.horizontal, 80)
.padding(.vertical, 60)
}
}
}
/// The button that presents the picker, for placing beside other tvOS controls.
struct DataContextSwitcherButton: View {
@Environment(\.appEnvironment) private var appEnvironment
@State private var showingPicker = false
var body: some View {
Button {
showingPicker = true
} label: {
Label {
Text(DataContextSwitcherLabels.title(for: currentContext, in: appEnvironment))
} icon: {
DataContextSwitcherIcon()
}
}
.buttonStyle(.bordered)
.fullScreenCover(isPresented: $showingPicker) {
DataContextSwitcherPicker()
}
}
private var currentContext: DataContext {
appEnvironment?.accountsManager.currentContext ?? .local
}
}
#endif