mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 21:22:53 +00:00
MAC1: switcher rows are Toggles on macOS so the checkmark really renders, and the settings row names its context via LabeledContent with a tooltip. MAC4: accessible names are words, never raw SF Symbol identifiers. MAC14: new displayStatus(for:) surfaces instanceDisabled on account rows, badges, and the switcher tooltip without disturbing status(for:) consumers. MAC16: disambiguated labels guard the username branch and fall back to the source name only when rows would still collide. Also carries the new Localizable strings for the whole macOS fix batch.
470 lines
16 KiB
Swift
470 lines
16 KiB
Swift
//
|
|
// 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
|