mirror of
https://github.com/yattee/yattee.git
synced 2026-10-31 00:47:17 +00:00
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:
251
Yattee/Views/Components/DataContextSwitcherMenu.swift
Normal file
251
Yattee/Views/Components/DataContextSwitcherMenu.swift
Normal file
@@ -0,0 +1,251 @@
|
||||
//
|
||||
// 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
|
||||
69
Yattee/Views/Components/DataContextUnavailableBanner.swift
Normal file
69
Yattee/Views/Components/DataContextUnavailableBanner.swift
Normal file
@@ -0,0 +1,69 @@
|
||||
//
|
||||
// DataContextUnavailableBanner.swift
|
||||
// Yattee
|
||||
//
|
||||
// Says why the selected account is not the one answering right now.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// A thin strip shown while the selected account context cannot be used.
|
||||
///
|
||||
/// Informational only: the selection is deliberately kept — it comes back on
|
||||
/// its own when the credential or instance arrives — and reads fall back to
|
||||
/// local data meanwhile, which is exactly what the user would otherwise have to
|
||||
/// guess at. The switcher sits in the toolbar beside it for anyone who wants to
|
||||
/// choose something else.
|
||||
struct DataContextUnavailableBanner: View {
|
||||
let reason: DataContextUnavailability
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: icon)
|
||||
.foregroundStyle(tint)
|
||||
|
||||
Text(message)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.thinMaterial)
|
||||
}
|
||||
|
||||
private var icon: String {
|
||||
switch reason {
|
||||
case .checking: "clock"
|
||||
case .instancePending: "clock"
|
||||
case .instanceDisabled: "server.rack"
|
||||
case .credentialMissing: "person.badge.key"
|
||||
case .credentialRejected: "exclamationmark.triangle.fill"
|
||||
case .unreachable: "wifi.exclamationmark"
|
||||
}
|
||||
}
|
||||
|
||||
private var tint: Color {
|
||||
switch reason {
|
||||
case .checking, .instancePending: .secondary
|
||||
case .instanceDisabled: .secondary
|
||||
case .credentialMissing: .secondary
|
||||
case .credentialRejected: .red
|
||||
case .unreachable: .orange
|
||||
}
|
||||
}
|
||||
|
||||
private var message: String {
|
||||
switch reason {
|
||||
case .checking: String(localized: "subscriptions.banner.checking")
|
||||
case .instancePending: String(localized: "subscriptions.banner.instancePending")
|
||||
case .instanceDisabled: String(localized: "subscriptions.banner.instanceDisabled")
|
||||
case .credentialMissing: String(localized: "subscriptions.banner.credentialMissing")
|
||||
case .credentialRejected: String(localized: "subscriptions.banner.credentialRejected")
|
||||
case .unreachable: String(localized: "subscriptions.banner.unreachable")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -101,6 +101,9 @@ struct PlaylistsListView: View {
|
||||
ToolbarSpacer(.flexible, placement: .primaryAction)
|
||||
}
|
||||
#endif
|
||||
ToolbarItem(placement: viewOptionsPlacement) {
|
||||
DataContextSwitcherMenu()
|
||||
}
|
||||
ToolbarItem(placement: viewOptionsPlacement) {
|
||||
Button {
|
||||
showViewOptions = true
|
||||
@@ -169,6 +172,8 @@ struct PlaylistsListView: View {
|
||||
} label: {
|
||||
Label(String(localized: "viewOptions.title"), systemImage: "slider.horizontal.3")
|
||||
}
|
||||
|
||||
DataContextSwitcherButton()
|
||||
}
|
||||
.focusSection()
|
||||
.padding(.horizontal, 48)
|
||||
|
||||
@@ -423,6 +423,23 @@ struct UnifiedTabView: View {
|
||||
// MARK: - Sidebar
|
||||
|
||||
private var sidebar: some View {
|
||||
#if os(macOS)
|
||||
VStack(spacing: 0) {
|
||||
sidebarList
|
||||
|
||||
Divider()
|
||||
// The window has no toolbar of its own to hang this off, and the
|
||||
// sidebar is the one chrome every tab shares.
|
||||
DataContextSwitcherSettingsRow()
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
#else
|
||||
sidebarList
|
||||
#endif
|
||||
}
|
||||
|
||||
private var sidebarList: some View {
|
||||
List(selection: $selection) {
|
||||
ForEach(visibleMainItems) { item in
|
||||
sidebarRow(for: item)
|
||||
|
||||
@@ -128,6 +128,8 @@ struct SettingsView: View {
|
||||
}
|
||||
.accessibilityIdentifier("settings.row.sources")
|
||||
|
||||
DataContextSwitcherSettingsRow()
|
||||
|
||||
NavigationLink {
|
||||
TVSidebarDetailContainer(systemImage: "icloud", title: String(localized: "settings.icloud.title")) { iCloudSettingsView() }
|
||||
} label: {
|
||||
@@ -229,6 +231,8 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
.accessibilityIdentifier("settings.row.sources")
|
||||
|
||||
DataContextSwitcherSettingsRow()
|
||||
}
|
||||
|
||||
Section {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -324,6 +324,15 @@ struct SubscriptionsView: View {
|
||||
return progress > 0 && progress < 1 ? progress : nil
|
||||
}
|
||||
|
||||
/// Why the selected account is not the one answering, when it is not.
|
||||
///
|
||||
/// Only for an account context: local data is never unavailable, and the
|
||||
/// fallback the banner explains is local data itself.
|
||||
private var contextUnavailability: DataContextUnavailability? {
|
||||
guard appEnvironment?.accountsManager.currentContext.isLocal == false else { return nil }
|
||||
return appEnvironment?.dataContextValidator.unavailability
|
||||
}
|
||||
|
||||
/// View options button lives on the leading edge on macOS, trailing elsewhere.
|
||||
private var viewOptionsPlacement: ToolbarItemPlacement {
|
||||
#if os(macOS)
|
||||
@@ -430,6 +439,13 @@ struct SubscriptionsView: View {
|
||||
}
|
||||
#endif
|
||||
}
|
||||
// Above the feed rather than inside it: the feed is local data
|
||||
// while this is showing, and nothing else on screen says so.
|
||||
.safeAreaInset(edge: .top, spacing: 0) {
|
||||
if let unavailability = contextUnavailability {
|
||||
DataContextUnavailableBanner(reason: unavailability)
|
||||
}
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.navigationTitle(String(localized: "tabs.subscriptions"))
|
||||
#if os(iOS)
|
||||
@@ -438,6 +454,9 @@ struct SubscriptionsView: View {
|
||||
.toolbarTitleDisplayMode(.inlineLarge)
|
||||
#endif
|
||||
.toolbar {
|
||||
ToolbarItem(placement: viewOptionsPlacement) {
|
||||
DataContextSwitcherMenu()
|
||||
}
|
||||
ToolbarItem(placement: viewOptionsPlacement) {
|
||||
Button {
|
||||
showViewOptions = true
|
||||
@@ -812,12 +831,16 @@ struct SubscriptionsView: View {
|
||||
|
||||
#if os(tvOS)
|
||||
private var tvOSViewOptionsButton: some View {
|
||||
Button {
|
||||
showViewOptions = true
|
||||
} label: {
|
||||
Label(String(localized: "viewOptions.title"), systemImage: "slider.horizontal.3")
|
||||
HStack(spacing: 16) {
|
||||
Button {
|
||||
showViewOptions = true
|
||||
} label: {
|
||||
Label(String(localized: "viewOptions.title"), systemImage: "slider.horizontal.3")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
DataContextSwitcherButton()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
|
||||
private var tvOSChannelsSidebar: some View {
|
||||
|
||||
Reference in New Issue
Block a user