Files
yattee/Yattee/Views/Components/DataContextSwitcherMenu.swift
Arkadiusz Fal 175b98097a Put the account switcher where the data is, and retire the old setting
Switching accounts was buried in Subscriptions settings behind a picker
and a confirmation dialog, and it wrote two stores that could disagree.
One shared switcher now sits in the Subscriptions and Playlists
toolbars, the macOS sidebar footer and the Settings root, and every path
goes through AppEnvironment.switchDataContext(to:) so a switch behaves
the same wherever it is made. Switching is one tap: it is reversible, so
there is nothing to confirm.

Rows come from DataContextValidator.availableContexts, name themselves
through displayName(for:), and carry a status suffix when the account is
not signed in — plus the instance host when two accounts share a name.
tvOS gets the same rows as a full-screen picker.

Subscriptions also explains itself when the selected account cannot
answer: a thin banner names the cause and says the data below is local.
This is the first consumer of the validator's unavailability, and
correctIfContextRemoved() now runs on foreground activation, where
SubscriptionAccountValidator used to auto-correct any selection it could
not immediately verify.

With the switcher reading the context directly, the legacy
subscriptionAccount setting has no readers left: the setting, its
validator, the mirror that kept it in step and the feed cache's
instance-keyed fallbacks all go. The cache invalidation the mirror also
carried stays, under its own name. AccountsManager still reads the raw
legacy key for the one-way context migration.
2026-09-03 23:59:32 +02:00

252 lines
8.0 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.
///
/// The host is appended only when several accounts share a display name:
/// two logins on the same instance are told apart by their username, and a
/// name that already carries the host would otherwise repeat it.
@MainActor
static func title(for context: DataContext, in appEnvironment: AppEnvironment?) -> String {
guard let appEnvironment else { return String(localized: "subscriptions.account.local") }
let name = appEnvironment.dataContextValidator.displayName(for: context)
guard case let .account(id) = context, let account = appEnvironment.accountsManager.account(id: id) else {
return name
}
var title = name
if isAmbiguous(name, in: appEnvironment),
let host = appEnvironment.accountsManager.instance(for: account)?.url.host,
!name.localizedCaseInsensitiveContains(host) {
title = "\(name) · \(host)"
}
let status = appEnvironment.accountsManager.status(for: id)
guard status != .signedIn else { return title }
return "\(title) — \(statusText(for: status))"
}
@MainActor
private static func isAmbiguous(_ name: String, in appEnvironment: AppEnvironment) -> Bool {
appEnvironment.accountsManager.selectableAccounts
.filter { appEnvironment.dataContextValidator.displayName(for: $0) == name }
.count > 1
}
static func statusText(for status: AccountStatus) -> String {
switch status {
case .checking: String(localized: "accounts.status.checking")
case .signedIn: String(localized: "accounts.status.signedIn")
case .credentialMissing: String(localized: "accounts.status.credentialMissing")
case .credentialRejected: String(localized: "accounts.status.credentialRejected")
case .unreachable: String(localized: "accounts.status.unreachable")
}
}
}
// 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(String(localized: "accounts.switcher.title"), systemImage: icon)
}
.help(DataContextSwitcherLabels.title(for: currentContext, in: appEnvironment))
}
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
/// 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(String(localized: "accounts.switcher.title"), systemImage: "person.crop.circle")
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(
DataContextSwitcherLabels.title(for: currentContext, in: appEnvironment),
systemImage: "person.crop.circle"
)
}
.buttonStyle(.bordered)
.fullScreenCover(isPresented: $showingPicker) {
DataContextSwitcherPicker()
}
}
private var currentContext: DataContext {
appEnvironment?.accountsManager.currentContext ?? .local
}
}
#endif