diff --git a/Yattee/ContentView.swift b/Yattee/ContentView.swift index dc01fc8a..0bfa8a71 100644 --- a/Yattee/ContentView.swift +++ b/Yattee/ContentView.swift @@ -246,7 +246,11 @@ struct AdaptiveTabView: View { } private var usesSidebar: Bool { - NavigationLayout.usesSidebar(horizontalSizeClass: horizontalSizeClass, verticalSizeClass: verticalSizeClass) + NavigationLayout.usesSidebar( + horizontalSizeClass: horizontalSizeClass, + verticalSizeClass: verticalSizeClass, + style: appEnvironment.settingsManager.navigationStyle + ) } private static var usesTabBottomAccessory: Bool { diff --git a/Yattee/Core/Settings/SettingsKey.swift b/Yattee/Core/Settings/SettingsKey.swift index f1659807..e0602aca 100644 --- a/Yattee/Core/Settings/SettingsKey.swift +++ b/Yattee/Core/Settings/SettingsKey.swift @@ -88,6 +88,9 @@ enum SettingsKey: String, CaseIterable { // Top Shelf (tvOS) case topShelfSections + // Navigation layout (iPad, iPhone Duo inner screen) + case navigationStyle + // Tab Bar (compact size class) case tabBarItemOrder case tabBarItemVisibility @@ -162,7 +165,7 @@ enum SettingsKey: String, CaseIterable { // Top Shelf — tvOS only .topShelfSections, // Tab bar (compact size class) layout - .tabBarItemOrder, .tabBarItemVisibility, .tabBarStartupTab, + .navigationStyle, .tabBarItemOrder, .tabBarItemVisibility, .tabBarStartupTab, // Sidebar layout/selection .sidebarMainItemOrder, .sidebarMainItemVisibility, .sidebarStartupTab, .sidebarSourcesEnabled, .sidebarSourceSort, .sidebarSourcesLimitEnabled, .sidebarMaxSources, diff --git a/Yattee/Core/Settings/SettingsManager+Home.swift b/Yattee/Core/Settings/SettingsManager+Home.swift index d59bd206..efc882b5 100644 --- a/Yattee/Core/Settings/SettingsManager+Home.swift +++ b/Yattee/Core/Settings/SettingsManager+Home.swift @@ -964,6 +964,23 @@ extension SettingsManager { } } + // MARK: - Navigation Style + + #if os(iOS) + /// Whether screens that fit the sidebar (iPad, iPhone Duo inner screen) + /// use it, or keep the tab bar. Default is automatic. + var navigationStyle: NavigationStyle { + get { + if let cached = _navigationStyle { return cached } + return NavigationStyle(rawValue: string(for: .navigationStyle) ?? "") ?? .automatic + } + set { + _navigationStyle = newValue + set(newValue.rawValue, for: .navigationStyle) + } + } + #endif + // MARK: - Startup Tab Settings /// The startup tab for tab bar mode (compact/iPhone). Default is home. diff --git a/Yattee/Core/Settings/SettingsTypes.swift b/Yattee/Core/Settings/SettingsTypes.swift index c2916700..f894fb3c 100644 --- a/Yattee/Core/Settings/SettingsTypes.swift +++ b/Yattee/Core/Settings/SettingsTypes.swift @@ -386,6 +386,27 @@ enum MiniPlayerVideoTapAction: String, CaseIterable, Codable { } } +// MARK: - Navigation Style + +/// Navigation layout on screens that fit the sidebar (iPad, iPhone Duo inner screen). +#if os(iOS) +enum NavigationStyle: String, CaseIterable, Codable { + /// Sidebar where it fits, tab bar elsewhere. + case automatic + /// Tab bar everywhere. + case tabBar + + var displayName: String { + switch self { + case .automatic: + return String(localized: "settings.navigationStyle.automatic") + case .tabBar: + return String(localized: "settings.navigationStyle.tabBar") + } + } +} +#endif + // MARK: - Mini Player Minimize Behavior /// Behavior for minimizing the mini player (iOS 26+ only). diff --git a/Yattee/Core/SettingsManager.swift b/Yattee/Core/SettingsManager.swift index e8f7147d..ee704863 100644 --- a/Yattee/Core/SettingsManager.swift +++ b/Yattee/Core/SettingsManager.swift @@ -146,6 +146,9 @@ final class SettingsManager { // Tab bar startup var _tabBarStartupTab: SidebarMainItem? + #if os(iOS) + var _navigationStyle: NavigationStyle? + #endif var _sidebarSourcesEnabled: Bool? var _sidebarSourceSort: SidebarSourceSort? var _sidebarSourcesLimitEnabled: Bool? @@ -509,6 +512,9 @@ final class SettingsManager { _sidebarMainItemVisibility = nil _sidebarStartupTab = nil _tabBarStartupTab = nil + #if os(iOS) + _navigationStyle = nil + #endif _sidebarSourcesEnabled = nil _sidebarSourceSort = nil _sidebarSourcesLimitEnabled = nil diff --git a/Yattee/Localizable.xcstrings b/Yattee/Localizable.xcstrings index 527367b5..0468f6db 100644 --- a/Yattee/Localizable.xcstrings +++ b/Yattee/Localizable.xcstrings @@ -13974,6 +13974,83 @@ } } }, + "settings.navigationStyle" : { + "comment" : "Picker label for choosing sidebar or tab bar navigation", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Navigation" + } + } + } + }, + "settings.navigationStyle.automatic" : { + "comment" : "Navigation style option: sidebar on large screens, tab bar elsewhere", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatic" + } + } + } + }, + "settings.navigationStyle.confirm.change" : { + "comment" : "Confirm button for changing the navigation style", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Change" + } + } + } + }, + "settings.navigationStyle.confirm.message" : { + "comment" : "Alert message before changing the navigation style", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The app's navigation will be rebuilt for the new layout. Settings will close and you'll return to Home." + } + } + } + }, + "settings.navigationStyle.confirm.title" : { + "comment" : "Alert title before changing the navigation style", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Change Navigation?" + } + } + } + }, + "settings.navigationStyle.footer" : { + "comment" : "Footer explaining the navigation style setting", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatic uses the sidebar when the screen is large enough. Tab Bar always uses the tab bar." + } + } + } + }, + "settings.navigationStyle.tabBar" : { + "comment" : "Navigation style option: always use the tab bar", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tab Bar" + } + } + } + }, "settings.notifications.defaultForNew" : { "comment" : "Toggle for enabling notifications on new subscriptions", "localizations" : { diff --git a/Yattee/Services/Navigation/NavigationRouter.swift b/Yattee/Services/Navigation/NavigationRouter.swift index 6570f55e..931b40e1 100644 --- a/Yattee/Services/Navigation/NavigationRouter.swift +++ b/Yattee/Services/Navigation/NavigationRouter.swift @@ -170,12 +170,13 @@ enum NavigationLayout { /// 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. + /// even in a short Stage Manager window. The `.tabBar` style opts out. static func usesSidebar( horizontalSizeClass: UserInterfaceSizeClass?, - verticalSizeClass: UserInterfaceSizeClass? + verticalSizeClass: UserInterfaceSizeClass?, + style: NavigationStyle = .automatic ) -> Bool { - guard horizontalSizeClass == .regular else { return false } + guard style == .automatic, horizontalSizeClass == .regular else { return false } return verticalSizeClass == .regular || UIDevice.current.userInterfaceIdiom == .pad } } diff --git a/Yattee/Views/Settings/LayoutNavigationSettingsView.swift b/Yattee/Views/Settings/LayoutNavigationSettingsView.swift index 9c027173..1f6c72ff 100644 --- a/Yattee/Views/Settings/LayoutNavigationSettingsView.swift +++ b/Yattee/Views/Settings/LayoutNavigationSettingsView.swift @@ -13,7 +13,7 @@ struct LayoutNavigationSettingsView: View { var body: some View { SettingsFormContainer { if let settings = appEnvironment?.settingsManager { - CustomizationSection() + CustomizationSection(settings: settings) #if os(iOS) HapticsSection(settings: settings) #endif @@ -78,11 +78,62 @@ private struct HapticsSection: View { // MARK: - Customization Section private struct CustomizationSection: View { + @Bindable var settings: SettingsManager + #if os(iOS) + @Environment(\.appEnvironment) private var appEnvironment @State private var hasHinge = HingeLayoutObserver.forceTabletop + /// Style picked but not yet confirmed: switching rebuilds the navigation + /// Settings lives in, so the user agrees to leaving it first. + @State private var pendingNavigationStyle: NavigationStyle? #endif + /// iPad and foldable iPhones (inner screen): where the sidebar layout can appear. + private var canUseSidebar: Bool { + #if os(iOS) + UIDevice.current.userInterfaceIdiom == .pad || hasHinge + #else + true + #endif + } + var body: some View { + #if os(iOS) + if canUseSidebar { + SettingsFormSection(footer: "settings.navigationStyle.footer") { + Picker( + String(localized: "settings.navigationStyle"), + selection: Binding( + get: { settings.navigationStyle }, + set: { style in + guard style != settings.navigationStyle else { return } + pendingNavigationStyle = style + } + ) + ) { + ForEach(NavigationStyle.allCases, id: \.self) { style in + Text(style.displayName).tag(style) + } + } + } + .alert( + String(localized: "settings.navigationStyle.confirm.title"), + isPresented: Binding( + get: { pendingNavigationStyle != nil }, + set: { if !$0 { pendingNavigationStyle = nil } } + ), + presenting: pendingNavigationStyle + ) { style in + Button(String(localized: "settings.navigationStyle.confirm.change")) { + applyNavigationStyle(style) + } + Button(String(localized: "common.cancel"), role: .cancel) {} + } message: { _ in + Text(String(localized: "settings.navigationStyle.confirm.message")) + } + } + #endif + SettingsFormSection { #if os(tvOS) NavigationLink { @@ -104,9 +155,7 @@ private struct CustomizationSection: View { TabBarSettingsView() } - // Sidebar settings where the sidebar layout can appear: iPad and - // foldable iPhones (inner screen) - if UIDevice.current.userInterfaceIdiom == .pad || hasHinge { + if canUseSidebar && settings.navigationStyle == .automatic { SettingsNavigationRow("settings.sidebar.title", systemImage: "sidebar.leading") { SidebarSettingsView() } @@ -132,6 +181,18 @@ private struct CustomizationSection: View { .observeHinge(hasHinge: $hasHinge) #endif } + + #if os(iOS) + /// Lands on Home before the layout swap, so neither layout remaps the + /// Settings selection (the tab bar would reopen it as a sheet). + private func applyNavigationStyle(_ style: NavigationStyle) { + if let router = appEnvironment?.navigationCoordinator.router { + router.popToRoot(router.selection) + router.selection = .home + } + settings.navigationStyle = style + } + #endif } // MARK: - Link Action Section