// // DataContextSwitcherMenu.swift // Yattee // // The one place the selected data context is switched from. // import SwiftUI // MARK: - Rows /// The switchable contexts, as rows. /// /// 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 row(for: context) } } @ViewBuilder private func row(for context: DataContext) -> some View { // A menu row is a single label — a `VStack` inside one renders as // nothing on macOS — so the status travels in the title rather than as // a second line. let title = DataContextSwitcherLabels.title(for: context, in: appEnvironment) #if os(macOS) // The mark has to be the menu item's own. A `Label`'s `systemImage` is // dropped by a macOS menu row, leaving the selected context // indistinguishable from the rest; a `Toggle` is what SwiftUI renders // as a checked item, mark character included. Toggle(isOn: Binding( get: { isCurrent(context) }, set: { isOn in guard isOn else { return } switchTo(context) } )) { Text(title) } #else Button { switchTo(context) } label: { if isCurrent(context) { Label(title, systemImage: "checkmark") } else { Text(title) } } #if os(tvOS) .buttonStyle(TVSettingsButtonStyle()) #endif #endif } private func switchTo(_ context: DataContext) { appEnvironment?.switchDataContext(to: context) onSwitch?() } 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 let peers = peers(sharing: name, accountsManager: accountsManager, validator: validator) if peers.count > 1 { title = disambiguated( name, for: account, among: peers, accountsManager: accountsManager, username: username ) } let status = accountsManager.displayStatus(for: account) 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. Neither /// is repeated when the name already carries it: the default name for an /// account *is* `identifier@host`, so appending it unguarded produced two /// rows reading `X · X`. /// /// The source is the last resort and the only one that can name what two /// logins of the same server user on two copies of the same server differ /// by — the thing the user chose when adding the second source. static func disambiguated(_ name: String, username: String?, host: String?, source: String? = nil) -> String { var parts: [String] = [] if let username, !username.isEmpty, !name.localizedCaseInsensitiveContains(username) { if let host, !host.isEmpty, !name.localizedCaseInsensitiveContains(host) { parts.append("\(username)@\(host)") } else { parts.append(username) } } else if let host, !host.isEmpty, !name.localizedCaseInsensitiveContains(host) { parts.append(host) } if let source, !source.isEmpty, name.localizedCaseInsensitiveCompare(source) != .orderedSame { parts.append(source) } guard !parts.isEmpty else { return name } return "\(name) · \(parts.joined(separator: " · "))" } /// The identifier and the host first, and the source only once they have /// failed: two logins on one server are told apart by who they are, and /// naming the server as well would add a word that is the same on both /// rows. It is when that suffix still collides — the same server user /// signed in on two sources pointing at the same host — that the source is /// the only thing left that differs. @MainActor private static func disambiguated( _ name: String, for account: Account, among peers: [Account], accountsManager: AccountsManager, username: (Account) -> String? ) -> String { func withoutSource(_ peer: Account) -> String { disambiguated( name, username: username(peer), host: accountsManager.instance(for: peer)?.url.host ) } let own = withoutSource(account) let collides = peers.contains { $0.id != account.id && withoutSource($0) == own } guard collides else { return own } return disambiguated( name, username: username(account), host: accountsManager.instance(for: account)?.url.host, source: accountsManager.instance(for: account)?.displayName ) } @MainActor private static func peers( sharing name: String, accountsManager: AccountsManager, validator: DataContextValidator ) -> [Account] { accountsManager.selectableAccounts.filter { validator.displayName(for: $0) == name } } } // 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. /// /// Hidden from assistive technology on purpose: every presentation names the /// switcher in words beside it, and an unlabelled `Image` announces its symbol /// — a control that read "Account" while healthy started reading /// "person.crop.circle.badge.exclamationmark" the moment it badged. struct DataContextSwitcherIcon: View { @Environment(\.appEnvironment) private var appEnvironment var body: some View { let image = Image(systemName: DataContextSwitcherGlyph.systemImage(in: appEnvironment)) Group { if let tint = DataContextSwitcherGlyph.tint(in: appEnvironment) { image.foregroundStyle(tint) } else { image } } .accessibilityHidden(true) } } // 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(contextTitle) .accessibilityLabel(Text(String(localized: "accounts.switcher.title"))) .accessibilityValue(Text(contextTitle)) } private var contextTitle: String { 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() } #elseif os(macOS) // A macOS menu label renders its first text and nothing else — the // trailing name was dropped at every width, leaving the row silent // about which account is selected. So the name is the menu's *own* // label, and the row carries only what names the control. LabeledContent { Menu { DataContextSwitcherRows() } label: { Text(contextTitle) .lineLimit(1) } .menuStyle(.borderlessButton) .help(contextTitle) .accessibilityLabel(Text(String(localized: "accounts.switcher.title"))) .accessibilityValue(Text(contextTitle)) } label: { Label { Text(String(localized: "accounts.switcher.title")) } icon: { DataContextSwitcherIcon() } } #else Menu { DataContextSwitcherRows() } label: { rowLabel } #endif } #if !os(macOS) private var rowLabel: some View { HStack { Label { Text(String(localized: "accounts.switcher.title")) } icon: { DataContextSwitcherIcon() } Spacer(minLength: 8) Text(contextTitle) .foregroundStyle(.secondary) .lineLimit(1) } } #endif private var contextTitle: String { DataContextSwitcherLabels.title(for: currentContext, in: appEnvironment) } 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