Files
yattee/Yattee/Views/Components/DataContextSwitcherMenu.swift
Arkadiusz Fal 69179c9fe9 Give every login a screen, including the ones with nowhere to go
An account whose source is gone was listed nowhere: `orphanedAccounts` had
no readers, and the credential behind it could not be reached at all —
`AppEnvironment.accountCredentialsManager(for:)` resolves the store from
the account's instance, and an orphan has none. The new accounts screen
lists them and removes them by account ID, asking every account-keyed
store because the backend is no longer knowable. The pre-revamp
instance-keyed item is deliberately left behind: identifying it needs the
instance too.

Deleting a source has always signed out and removed the accounts on it.
Both confirmations now say so, and name them.

The sign-in that files a credential against an account is now one
implementation instead of a private method on the source editor, so the
accounts screen and the new optional sign-in step after adding a source
attach a login exactly as the editor does. Extracted verbatim — the
identity-scope guard, the account re-resolve, the write-then-read-back
and the rollback are what keep a half-written login from looking real.

Signing out from the per-instance section now asks first, and both
sign-out paths run one cascade rather than two copies of it.
2026-09-04 23:35:51 +02:00

242 lines
7.5 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) — \(AccountStatusBadge.text(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
}
}
// 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