Files
yattee/Yattee/Services/Navigation/NavigationRouter.swift
Arkadiusz Fal 498a282396 Open Settings from yattee://settings
SettingsView owns its navigation stack, so pushing it onto a section's
stack rendered nothing. Select the Settings section where the layout has
one, otherwise present Home's settings sheet; open the Settings window on
macOS.
2026-10-05 23:37:14 +02:00

183 lines
6.7 KiB
Swift

//
// NavigationRouter.swift
// Yattee
//
// Layout-independent navigation state: which section is selected and what is
// pushed in each section's stack. The tab bar and the sidebar layouts both
// render this state, so swapping between them (iPad Stage Manager resize,
// iPhone Duo fold/unfold) keeps the user where they were.
//
import SwiftUI
@Observable
@MainActor
final class NavigationRouter {
/// The selected section. Both the tab bar and the sidebar bind to this.
var selection: SidebarItem = .home {
didSet {
guard selection != oldValue else { return }
onSelectionChange?(selection)
}
}
/// Pushed destinations per section, keyed by `SidebarItem.id` so that a
/// renamed channel or playlist keeps its stack.
private(set) var stacks: [String: [NavigationDestination]] = [:]
/// Search field text, shared so it survives a layout swap.
var searchText = ""
/// Sidebar channels, playlists and sources. Owned here rather than by the
/// sidebar layout so it is created and loaded once.
let sidebarManager = SidebarManager()
/// Set when Settings was requested in a layout without a Settings section
/// (the iPhone tab bar, unless the user added the tab). Home presents its
/// Settings sheet and clears it.
var pendingSettingsSheet = false
@ObservationIgnored private var hasAppliedStartupTab = false
@ObservationIgnored private var isSidebarManagerConfigured = false
/// Called after every selection change (Handoff updates).
@ObservationIgnored var onSelectionChange: ((SidebarItem) -> Void)?
// MARK: - Stacks
func path(for item: SidebarItem) -> Binding<[NavigationDestination]> {
let key = item.id
return Binding(
get: { self.stacks[key] ?? [] },
set: {
// Only the selected section's stack is on screen, so only it
// edits its path (Back, swipe back). A NavigationSplitView
// detail stack that is being replaced on a selection change
// writes an empty path on its way out, which would throw
// away what the section had pushed.
guard key == self.selection.id else { return }
self.stacks[key] = $0
}
)
}
func stack(for item: SidebarItem) -> [NavigationDestination] {
stacks[item.id] ?? []
}
/// Pushes a destination onto the selected section's stack.
func push(_ destination: NavigationDestination) {
stacks[selection.id, default: []].append(destination)
}
func popToRoot(_ item: SidebarItem? = nil) {
stacks[(item ?? selection).id] = []
}
func select(_ item: SidebarItem) {
selection = item
}
// MARK: - Startup
/// Applies the configured startup section once per app run. Later layout
/// swaps keep the current selection instead of jumping back to it.
///
/// A destination pushed before the first layout appeared (a cold-launch
/// deep link or Handoff) wins over the startup preference.
func applyStartupTabIfNeeded(_ startupTab: SidebarMainItem) {
guard !hasAppliedStartupTab else { return }
hasAppliedStartupTab = true
guard stack(for: selection).isEmpty else { return }
selection = startupTab.sidebarItem
}
func configureSidebarManagerIfNeeded(_ appEnvironment: AppEnvironment) {
guard !isSidebarManagerConfigured else { return }
isSidebarManagerConfigured = true
sidebarManager.configure(
dataManager: appEnvironment.dataManager,
settingsManager: appEnvironment.settingsManager,
mediaSourcesManager: appEnvironment.mediaSourcesManager,
instancesManager: appEnvironment.instancesManager,
accountsManager: appEnvironment.accountsManager,
subscriptionService: appEnvironment.subscriptionService,
remotePlaylistsCache: appEnvironment.remotePlaylistsCache,
contextValidator: appEnvironment.dataContextValidator
)
}
// MARK: - Layout Mapping
/// Makes the selection one the current layout can show.
///
/// When the selected section has no tab or row of its own in this layout
/// (a sidebar channel shown in the tab bar, a tab hidden by customization),
/// the user lands on the closest section that can host it, with the
/// section's own screen pushed first and its stack kept after it.
///
/// - Parameters:
/// - isAvailable: whether the layout shows `item` as a tab or row.
/// - instance: resolves an instance ID for `.instance` items.
func ensureSelectionIsAvailable(
isAvailable: (SidebarItem) -> Bool,
instance: (UUID) -> Instance? = { _ in nil }
) {
let current = selection
guard !isAvailable(current) else { return }
let host: SidebarItem
let root: NavigationDestination?
switch current {
case .channel(let channelID, _, let source):
host = isAvailable(.subscriptionsFeed) ? .subscriptionsFeed : .home
root = .channel(channelID, source)
case .playlist, .remotePlaylist:
host = isAvailable(.playlistsList) ? .playlistsList : .home
root = current.navigationDestination()
case .mediaSource(let id, _, _):
host = isAvailable(.sources) ? .sources : .home
root = .mediaSource(id)
case .instance(let id, _, _):
host = isAvailable(.sources) ? .sources : .home
root = instance(id).map { .instanceBrowse($0) }
case .sources:
host = .home
root = .mediaSources
case .settings:
host = .home
root = nil
pendingSettingsSheet = true
default:
host = .home
root = current.navigationDestination()
}
let carried = stack(for: current)
if let root {
stacks[host.id] = [root] + carried
}
stacks[current.id] = []
selection = host
}
}
// MARK: - Layout Choice
#if os(iOS)
enum NavigationLayout {
/// Whether the sidebar layout fits: regular width and height (iPad, the
/// iPhone Duo inner screen in either orientation). A large iPhone in
/// landscape is regular width but compact height, so it keeps the tab
/// bar. An iPad keeps the sidebar for as long as its width is regular,
/// even in a short Stage Manager window.
static func usesSidebar(
horizontalSizeClass: UserInterfaceSizeClass?,
verticalSizeClass: UserInterfaceSizeClass?
) -> Bool {
guard horizontalSizeClass == .regular else { return false }
return verticalSizeClass == .regular || UIDevice.current.userInterfaceIdiom == .pad
}
}
#endif