mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 13:12:53 +00:00
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.
360 lines
12 KiB
Swift
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
|