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.
This commit is contained in:
Arkadiusz Fal
2026-09-03 23:59:32 +02:00
parent afc016c5ce
commit 175b98097a
14 changed files with 525 additions and 576 deletions

View File

@@ -20,10 +20,6 @@ private struct ExportFile: Identifiable {
struct SubscriptionsSettingsView: View {
@Environment(\.appEnvironment) private var appEnvironment
// Account selection state
@State private var pendingAccountChange: SubscriptionAccount?
@State private var showingAccountSwitchConfirmation = false
// Import state
@State private var showingImportPicker = false
@State private var isImporting = false
@@ -47,14 +43,6 @@ struct SubscriptionsSettingsView: View {
appEnvironment?.dataManager
}
private var settingsManager: SettingsManager? {
appEnvironment?.settingsManager
}
private var validator: SubscriptionAccountValidator? {
appEnvironment?.subscriptionAccountValidator
}
private var subscriptionService: SubscriptionService? {
appEnvironment?.subscriptionService
}
@@ -69,20 +57,18 @@ struct SubscriptionsSettingsView: View {
dataManager?.subscriptionCount ?? 0
}
private var currentAccount: SubscriptionAccount {
settingsManager?.subscriptionAccount ?? .local
private var currentContext: DataContext {
appEnvironment?.accountsManager.currentContext ?? .local
}
var body: some View {
List {
accountSection
if validator?.hasAvailableAccounts == true {
importSection
exportSection
localDataSection
}
importSection
exportSection
localDataSection
}
.task(id: currentAccount) {
.task(id: currentContext) {
await refreshAccountSubscriptionCount()
}
.navigationTitle(String(localized: "settings.subscriptions.title"))
@@ -117,20 +103,6 @@ struct SubscriptionsSettingsView: View {
Text(error)
}
}
.confirmationDialog(
String(localized: "settings.subscriptions.account.switch.title"),
isPresented: $showingAccountSwitchConfirmation,
titleVisibility: .visible
) {
Button(String(localized: "settings.subscriptions.account.switch.action")) {
confirmAccountSwitch()
}
Button(String(localized: "common.cancel"), role: .cancel) {
cancelAccountSwitch()
}
} message: {
Text(String(localized: "settings.subscriptions.account.switch.message"))
}
.confirmationDialog(
String(localized: "settings.subscriptions.localData.delete.confirmation.title \(localSubscriptionCount)"),
isPresented: $showingDeleteLocalConfirmation,
@@ -150,81 +122,12 @@ struct SubscriptionsSettingsView: View {
@ViewBuilder
private var accountSection: some View {
Section {
if let validator, validator.hasAvailableAccounts {
Picker(
String(localized: "settings.subscriptions.account.label"),
selection: Binding(
get: { currentAccount },
set: { newAccount in
if newAccount != currentAccount {
pendingAccountChange = newAccount
showingAccountSwitchConfirmation = true
}
}
)
) {
ForEach(validator.availableAccounts, id: \.self) { account in
Text(validator.displayName(for: account))
.tag(account)
}
}
} else {
// No accounts available - show setup prompt
VStack(alignment: .leading, spacing: 8) {
Label(
String(localized: "settings.subscriptions.account.noAccounts.title"),
systemImage: "exclamationmark.triangle"
)
.foregroundStyle(.secondary)
Text(String(localized: "settings.subscriptions.account.noAccounts.message"))
.font(.caption)
.foregroundStyle(.secondary)
Button(String(localized: "settings.subscriptions.account.noAccounts.action")) {
appEnvironment?.navigationCoordinator.navigate(to: .settings)
}
.font(.caption)
.padding(.top, 4)
}
.padding(.vertical, 4)
}
DataContextSwitcherSettingsRow()
} footer: {
if validator?.hasAvailableAccounts == true {
Text(String(localized: "settings.subscriptions.account.footer"))
}
Text(String(localized: "settings.subscriptions.account.footer"))
}
}
// MARK: - Account Switch Actions
private func confirmAccountSwitch() {
guard let newAccount = pendingAccountChange else { return }
// Both stores are written: the context is what the data layer reads,
// the legacy setting is kept in step until the Phase 3 switcher removes
// the last readers of it. Letting them diverge would show one account
// in the UI while serving another's data.
settingsManager?.subscriptionAccount = newAccount
appEnvironment?.accountsManager.setContext(
appEnvironment?.accountsManager.context(forLegacyInstanceID: newAccount.instanceID) ?? .local
)
SubscriptionFeedCache.shared.handleAccountChange()
// Trigger feed refresh in background
Task {
guard let appEnvironment else { return }
await SubscriptionFeedCache.shared.refresh(using: appEnvironment)
}
pendingAccountChange = nil
LoggingService.shared.logSubscriptions("Switched subscription account to: \(String(describing: newAccount.type))")
}
private func cancelAccountSwitch() {
pendingAccountChange = nil
}
// MARK: - Import Section
@ViewBuilder
@@ -285,7 +188,7 @@ struct SubscriptionsSettingsView: View {
/// is active. Hidden in local mode, where the account IS the local store.
@ViewBuilder
private var localDataSection: some View {
if currentAccount.type != .local && localSubscriptionCount > 0 {
if !currentContext.isLocal && localSubscriptionCount > 0 {
Section {
Button(role: .destructive) {
showingDeleteLocalConfirmation = true
@@ -383,7 +286,7 @@ struct SubscriptionsSettingsView: View {
/// mode, or the server's list for Invidious/Piped accounts.
private func performExport() async {
let subscriptions: [Subscription]
if currentAccount.type == .local {
if currentContext.isLocal {
guard let dataManager else { return }
subscriptions = dataManager.allSubscriptions
} else {