Files
yattee/Yattee/Views/Settings/NotificationSettingsView.swift
Arkadiusz Fal 9065234606 Key subscription providers by account rather than backend type
SubscriptionService held one provider per backend and each resolved its
instance from settingsManager.subscriptionAccount, falling back to "the
first enabled Invidious instance" when that was unset. With several logins
possible on one instance that model breaks twice: two accounts would share
a single in-memory subscription cache, and the fallback could send an
account's request to a server it holds no credential for.

Providers are now built per account and cached by account ID, and each
resolves its instance and credential from the account it was built for -
no fallback. The protocol's accountType becomes a DataContext, which is
what actually identifies a provider now.

Views that branched on "is the subscription account local" read the context
instead, and the settings picker writes both stores. Keeping the legacy
setting in step is deliberate for now: several views still read it, and
letting the two diverge would show one account in the UI while serving
another's data. The Phase 3 switcher removes the last readers.

Signing out discards the account's provider so its cached subscriptions
cannot be served after the account is gone.
2026-08-24 23:56:40 +02:00

350 lines
12 KiB
Swift

//
// NotificationSettingsView.swift
// Yattee
//
// Settings view for background notifications configuration.
//
import SwiftUI
import NukeUI
struct NotificationSettingsView: View {
@Environment(\.appEnvironment) private var appEnvironment
@State private var authorizationChecked = false
var body: some View {
SettingsFormContainer {
if let settings = appEnvironment?.settingsManager,
let notificationManager = appEnvironment?.notificationManager {
// Master toggle section
EnableSection(
settings: settings,
notificationManager: notificationManager,
appEnvironment: appEnvironment
)
// Permission status section
if authorizationChecked {
PermissionSection(notificationManager: notificationManager)
}
// Default for new subscriptions
if settings.backgroundNotificationsEnabled {
DefaultsSection(settings: settings)
// Manage channels section
ManageChannelsSection()
}
}
}
.navigationTitle(String(localized: "settings.notifications.title"))
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.task {
await appEnvironment?.notificationManager.refreshAuthorizationStatus()
authorizationChecked = true
}
}
}
// MARK: - Enable Section
private struct EnableSection: View {
@Bindable var settings: SettingsManager
let notificationManager: NotificationManager
let appEnvironment: AppEnvironment?
var body: some View {
SettingsFormSection(footer: "settings.notifications.footer") {
Toggle(
String(localized: "settings.notifications.enable"),
isOn: Binding(
get: { settings.backgroundNotificationsEnabled },
set: { newValue in
if newValue {
enableNotifications()
} else {
disableNotifications()
}
}
)
)
}
}
private func enableNotifications() {
Task {
let granted = await notificationManager.requestAuthorization()
if granted {
settings.backgroundNotificationsEnabled = true
appEnvironment?.backgroundRefreshManager.handleNotificationsEnabledChanged(true)
}
}
}
private func disableNotifications() {
settings.backgroundNotificationsEnabled = false
appEnvironment?.backgroundRefreshManager.handleNotificationsEnabledChanged(false)
}
}
// MARK: - Permission Section
private struct PermissionSection: View {
let notificationManager: NotificationManager
var body: some View {
SettingsFormSection {
HStack {
Text(String(localized: "settings.notifications.permission"))
Spacer()
if notificationManager.isAuthorized {
HStack(spacing: 4) {
Text(String(localized: "settings.notifications.permission.granted"))
Image(systemName: "checkmark.circle.fill")
}
.foregroundStyle(.green)
} else {
Button(String(localized: "settings.notifications.openSettings")) {
notificationManager.openNotificationSettings()
}
}
}
}
}
}
// MARK: - Defaults Section
private struct DefaultsSection: View {
@Bindable var settings: SettingsManager
var body: some View {
SettingsFormSection("settings.notifications.defaults.header", footer: "settings.notifications.defaultForNew.footer") {
Toggle(
String(localized: "settings.notifications.defaultForNew"),
isOn: $settings.defaultNotificationsForNewChannels
)
}
}
}
// MARK: - Manage Channels Section
private struct ManageChannelsSection: View {
var body: some View {
SettingsFormSection {
#if os(tvOS)
NavigationLink {
TVSidebarDetailContainer(
systemImage: "bell.badge",
title: String(localized: "settings.notifications.manageChannels"),
showsDismissButton: true
) {
ManageChannelNotificationsView()
}
} label: {
Label(
String(localized: "settings.notifications.manageChannels"),
systemImage: "bell.badge"
)
}
#else
SettingsNavigationRow("settings.notifications.manageChannels", systemImage: "bell.badge") {
ManageChannelNotificationsView()
}
#endif
}
}
}
// MARK: - Manage Channel Notifications View
struct ManageChannelNotificationsView: View {
@Environment(\.appEnvironment) private var appEnvironment
@State private var subscriptions: [Subscription] = []
@State private var isLoading = false
@State private var errorMessage: String?
@State private var refreshID = UUID()
private var subscriptionService: SubscriptionService? { appEnvironment?.subscriptionService }
var body: some View {
Group {
if isLoading {
HStack {
Spacer()
ProgressView()
Spacer()
}
.frame(maxHeight: .infinity)
} else if let errorMessage {
ContentUnavailableView {
Label(
String(localized: "settings.notifications.loadError.title"),
systemImage: "exclamationmark.triangle"
)
} description: {
Text(errorMessage)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if subscriptions.isEmpty {
ContentUnavailableView {
Label(
String(localized: "settings.notifications.noSubscriptions.title"),
systemImage: "person.2.slash"
)
} description: {
Text(String(localized: "settings.notifications.noSubscriptions.description"))
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
SettingsFormContainer {
SettingsFormSection {
ForEach(subscriptions, id: \.channelID) { subscription in
ChannelNotificationToggle(subscription: subscription)
}
.id(refreshID)
}
}
}
}
.navigationTitle(String(localized: "settings.notifications.manageChannels.title"))
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
if !subscriptions.isEmpty {
ToolbarItem(placement: .primaryAction) {
Menu {
Button {
setAllNotifications(enabled: true)
} label: {
Label(String(localized: "settings.notifications.enableAll"), systemImage: "bell.fill")
}
Button {
setAllNotifications(enabled: false)
} label: {
Label(String(localized: "settings.notifications.disableAll"), systemImage: "bell.slash")
}
} label: {
Image(systemName: "ellipsis.circle")
}
}
}
}
.task {
await loadSubscriptionsAsync()
}
}
private func setAllNotifications(enabled: Bool) {
guard let dataManager = appEnvironment?.dataManager else { return }
for subscription in subscriptions {
dataManager.setNotificationsEnabled(enabled, for: subscription.channelID)
}
refreshID = UUID()
}
/// Loads subscriptions from the current subscription account provider.
/// For local accounts, loads from DataManager.
/// For Invidious/Piped accounts, fetches from the respective API.
private func loadSubscriptionsAsync() async {
guard let appEnvironment, let subscriptionService else { return }
isLoading = true
errorMessage = nil
defer { isLoading = false }
// For local account, load from DataManager
if appEnvironment.accountsManager.currentContext.isLocal {
subscriptions = appEnvironment.dataManager.subscriptions()
return
}
// For Invidious/Piped, fetch from service
do {
let channels = try await subscriptionService.fetchSubscriptions()
subscriptions = channels.map { Subscription.from(channel: $0) }
} catch {
LoggingService.shared.error(
"Failed to load subscriptions for notifications: \(error.localizedDescription)",
category: .general
)
errorMessage = error.localizedDescription
subscriptions = []
}
}
}
// MARK: - Channel Notification Toggle
private struct ChannelNotificationToggle: View {
@Environment(\.appEnvironment) private var appEnvironment
let subscription: Subscription
@State private var notificationsEnabled: Bool = false
private var yatteeServer: Instance? {
appEnvironment?.instancesManager.enabledYatteeServerInstances.first
}
private var effectiveAvatarURL: URL? {
AvatarURLBuilder.avatarURL(
channelID: subscription.channelID,
directURL: subscription.avatarURL,
serverURL: yatteeServer?.url,
size: 28
)
}
private var authHeader: String? {
guard let server = yatteeServer else { return nil }
return appEnvironment?.basicAuthCredentialsManager.basicAuthHeader(for: server)
}
private var toggleBinding: Binding<Bool> {
Binding(
get: { notificationsEnabled },
set: { newValue in
notificationsEnabled = newValue
appEnvironment?.dataManager.setNotificationsEnabled(newValue, for: subscription.channelID)
}
)
}
var body: some View {
Toggle(isOn: toggleBinding) {
HStack(spacing: 10) {
// Channel avatar
LazyImage(request: AvatarURLBuilder.imageRequest(url: effectiveAvatarURL, authHeader: authHeader)) { state in
if let image = state.image {
image
.resizable()
.aspectRatio(contentMode: .fill)
} else {
Circle()
.fill(.quaternary)
}
}
.frame(width: 28, height: 28)
.clipShape(Circle())
// Channel name
Text(subscription.name)
.lineLimit(1)
}
}
.onAppear {
// Load initial value from ChannelNotificationSettings
notificationsEnabled = appEnvironment?.dataManager.notificationsEnabled(for: subscription.channelID) ?? false
}
}
}
#Preview {
NavigationStack {
NotificationSettingsView()
}
}