diff --git a/.DS_Store b/.DS_Store index 63a5f92c..508eefa8 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/Ruddarr.xcodeproj/project.pbxproj b/Ruddarr.xcodeproj/project.pbxproj index 1e9a16e5..02ffc8fb 100644 --- a/Ruddarr.xcodeproj/project.pbxproj +++ b/Ruddarr.xcodeproj/project.pbxproj @@ -12,7 +12,7 @@ 79159D712B5955A800F7F997 /* DummyAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79159D702B5955A800F7F997 /* DummyAPI.swift */; }; 794BD7822B5ED48E003819AB /* Binding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 794BD7812B5ED48E003819AB /* Binding.swift */; }; 795B7AD72B5AFA8C00A13DB3 /* AppError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 795B7AD62B5AFA8C00A13DB3 /* AppError.swift */; }; - 798010BA2B61719C00BBC056 /* Router.swift in Sources */ = {isa = PBXBuildFile; fileRef = 798010B92B61719C00BBC056 /* Router.swift */; }; + 796C766B2B628E6000EF3DB8 /* SwitchToNewInstance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 796C766A2B628E6000EF3DB8 /* SwitchToNewInstance.swift */; }; BB456D202B58B7E700C29B00 /* Network.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB456D1F2B58B7E700C29B00 /* Network.swift */; }; BB456D232B58C71300C29B00 /* NoInternet.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB456D222B58C71300C29B00 /* NoInternet.swift */; }; BB456D272B58E4B900C29B00 /* system-status.json in Resources */ = {isa = PBXBuildFile; fileRef = BB456D262B58E4B900C29B00 /* system-status.json */; }; @@ -49,7 +49,7 @@ 79159D702B5955A800F7F997 /* DummyAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DummyAPI.swift; sourceTree = ""; }; 794BD7812B5ED48E003819AB /* Binding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Binding.swift; sourceTree = ""; }; 795B7AD62B5AFA8C00A13DB3 /* AppError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppError.swift; sourceTree = ""; }; - 798010B92B61719C00BBC056 /* Router.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Router.swift; sourceTree = ""; }; + 796C766A2B628E6000EF3DB8 /* SwitchToNewInstance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwitchToNewInstance.swift; sourceTree = ""; }; BB456D1F2B58B7E700C29B00 /* Network.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Network.swift; sourceTree = ""; }; BB456D222B58C71300C29B00 /* NoInternet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoInternet.swift; sourceTree = ""; }; BB456D262B58E4B900C29B00 /* system-status.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "system-status.json"; sourceTree = ""; }; @@ -98,7 +98,6 @@ children = ( 79159D6F2B59540000F7F997 /* API */, 79159D6B2B5953D700F7F997 /* Dependencies.swift */, - 798010B92B61719C00BBC056 /* Router.swift */, ); path = Dependencies; sourceTree = ""; @@ -121,6 +120,7 @@ BBC94DE22B5F3DE600504568 /* CloudStorage.swift */, BBC94DE42B5F3E0B00504568 /* CloudStorageSync.swift */, BBC94DE82B5F63A000504568 /* Telemetry.swift */, + 796C766A2B628E6000EF3DB8 /* SwitchToNewInstance.swift */, ); path = Utilities; sourceTree = ""; @@ -321,10 +321,10 @@ BBC45B962B572A8600AB258F /* PreviewData.swift in Sources */, 795B7AD72B5AFA8C00A13DB3 /* AppError.swift in Sources */, BB456D202B58B7E700C29B00 /* Network.swift in Sources */, - 798010BA2B61719C00BBC056 /* Router.swift in Sources */, BBE1E43F2B51F61700946222 /* MovieLookup.swift in Sources */, 79159D712B5955A800F7F997 /* DummyAPI.swift in Sources */, BBF94F652B50C88300300EBA /* Instance.swift in Sources */, + 796C766B2B628E6000EF3DB8 /* SwitchToNewInstance.swift in Sources */, BBE8286A2B4F325400C1E1D9 /* ShowsView.swift in Sources */, 79159D6C2B5953D700F7F997 /* Dependencies.swift in Sources */, BBF94F632B508F8B00300EBA /* MovieView.swift in Sources */, diff --git a/Ruddarr/Dependencies/Dependencies.swift b/Ruddarr/Dependencies/Dependencies.swift index 06241262..b14ddf36 100644 --- a/Ruddarr/Dependencies/Dependencies.swift +++ b/Ruddarr/Dependencies/Dependencies.swift @@ -3,7 +3,6 @@ import SwiftUI struct Dependencies { var api: API - @Bindable var router = Router.shared } extension Dependencies { diff --git a/Ruddarr/Dependencies/Router.swift b/Ruddarr/Dependencies/Router.swift deleted file mode 100644 index 3d8dc0ed..00000000 --- a/Ruddarr/Dependencies/Router.swift +++ /dev/null @@ -1,32 +0,0 @@ -import Foundation -import SwiftUI - -@Observable -final class Router { - static let shared = Router() - - var selectedTab: Tab = .movies - - var moviesPath: NavigationPath = .init() - var settingsPath: NavigationPath = .init() -} - -enum Tab: Hashable, CaseIterable, Identifiable { - var id: Self { self } - - case movies - case shows - case settings - - @ViewBuilder - var label: some View { - switch self { - case .movies: - Label("Movies", systemImage: "popcorn.fill") - case .shows: - Label("Shows", systemImage: "tv.inset.filled") - case .settings: - Label("Settings", systemImage: "gear") - } - } -} diff --git a/Ruddarr/Utilities/SwitchToNewInstance.swift b/Ruddarr/Utilities/SwitchToNewInstance.swift new file mode 100644 index 00000000..2fa29615 --- /dev/null +++ b/Ruddarr/Utilities/SwitchToNewInstance.swift @@ -0,0 +1,28 @@ +import Foundation +import SwiftUI + +extension EnvironmentValues { + subscript(key key: Key.Type = Key.self) -> Key where Key.Value == Key { + get { self[Key.self] } + set { self[Key.self] = newValue } + } +} + +extension EnvironmentValues { + var switchToNewInstance: () -> Void { + get { + // we can make some of this syntax more tolerable but its extra work + let tabRouter = self[key: TabRouter.self] + let settingsRouter = self[key: SettingsView.Router.self] + return { + tabRouter.selectedTab = .settings + Task { @MainActor in + // delay is just for UX reasons (so user realizes where they went) + try await Task.sleep(until: .now + .seconds(0.1)) + assert(settingsRouter.path.isEmpty) // FUTURE: make a decision on whether its safe to switch to newInstance regardless of the fact that settings tab already had active navigation + settingsRouter.path = .init([SettingsView.Path.createInstance]) + } + } + } + } +} diff --git a/Ruddarr/Views/ContentView.swift b/Ruddarr/Views/ContentView.swift index dc952dce..aeda609e 100644 --- a/Ruddarr/Views/ContentView.swift +++ b/Ruddarr/Views/ContentView.swift @@ -1,12 +1,72 @@ import SwiftUI +extension EnvironmentValues { + subscript(key _: KeyPath = \Key.self) -> Key.Value { + get { self[Key.self] } + set { self[Key.self] = newValue } + } +} +extension Environment where Value: EnvironmentKey, Value.Value == Value { + init() { + self.init(\.[key: \Value.self]) + } +} + +protocol EmptyInitilizable { + init() +} +protocol DefaultKey: EnvironmentKey, EmptyInitilizable { +} +fileprivate var singletonCache: [ObjectIdentifier: Any] = [:] + +extension DefaultKey where Value == Self { + // unfortunately this generic context doesn't support stored static properties, so we need our own external cache (to make sure defaultValue is always same instance). Not a big deal. + static var defaultValue: Self { + singletonCache[ObjectIdentifier(Self.self)] as? Self ?? { + let instance = Self() + singletonCache[ObjectIdentifier(Self.self)] = instance + return instance + }() + } +} + +@Observable final class TabRouter: DefaultKey { + var selectedTab: Tab = .movies +} + +enum Tab: Hashable, CaseIterable, Identifiable { + var id: Self { self } + + case movies + case shows + case settings + + @ViewBuilder + var label: some View { + switch self { + case .movies: + Label("Movies", systemImage: "popcorn.fill") + case .shows: + Label("Shows", systemImage: "tv.inset.filled") + case .settings: + Label("Settings", systemImage: "gear") + } + } +} + + struct ContentView: View { + + @Environment() var tabRouter: TabRouter + @Environment() var moviesRouter: MoviesView.Router + @State private var columnVisibility: NavigationSplitViewVisibility = .detailOnly var body: some View { + @Bindable var tabRouter = tabRouter if UIDevice.current.userInterfaceIdiom == .pad { NavigationSplitView(columnVisibility: $columnVisibility) { - List(selection: dependencies.$router.selectedTab.optional) { + List(selection: $tabRouter.selectedTab.optional) { Text("Ruddarr") .font(.title) .fontWeight(.bold) @@ -14,7 +74,7 @@ struct ContentView: View { ForEach(Tab.allCases) { tab in let button = Button { - dependencies.router.selectedTab = tab + tabRouter.selectedTab = tab columnVisibility = .detailOnly } label: { tab.label @@ -30,11 +90,11 @@ struct ContentView: View { } } } detail: { - screen(for: dependencies.router.selectedTab) + screen(for: tabRouter.selectedTab) } } else { - TabView(selection: dependencies.$router.selectedTab.onSet { - if $0 == dependencies.router.selectedTab { + TabView(selection: $tabRouter.selectedTab.onSet { + if $0 == tabRouter.selectedTab { pop(tab: $0) } }) { @@ -50,9 +110,10 @@ struct ContentView: View { func pop(tab: Tab) { switch tab { case .movies: - dependencies.router.moviesPath = .init() + moviesRouter.path = .init() case .settings: - dependencies.router.settingsPath = .init() + break +// router.settingsPath = .init() default: break } @@ -63,9 +124,9 @@ struct ContentView: View { switch tab { case .movies: MoviesView( - onSettingsLinkTapped: { - dependencies.router.selectedTab = .settings - } +// onSettingsLinkTapped: { +// router.selectedTab = .settings +// } ) case .shows: ShowsView() diff --git a/Ruddarr/Views/InstanceView.swift b/Ruddarr/Views/InstanceView.swift index 1e4af007..374adc7a 100644 --- a/Ruddarr/Views/InstanceView.swift +++ b/Ruddarr/Views/InstanceView.swift @@ -233,11 +233,11 @@ extension ValidationError: LocalizedError { } #Preview { - dependencies.router.selectedTab = .settings +// dependencies.router.selectedTab = .settings - dependencies.router.settingsPath.append( - SettingsView.Path.createInstance - ) +// dependencies.router.settingsPath.append( +// SettingsView.Path.createInstance +// ) return ContentView() } diff --git a/Ruddarr/Views/MovieSearchView.swift b/Ruddarr/Views/MovieSearchView.swift index 83da934d..8a4703b5 100644 --- a/Ruddarr/Views/MovieSearchView.swift +++ b/Ruddarr/Views/MovieSearchView.swift @@ -115,8 +115,8 @@ struct MovieLookupSheet: View { } #Preview { - dependencies.router.selectedTab = .movies - dependencies.router.moviesPath.append(MoviesView.Path.search) +// dependencies.router.selectedTab = .movies +// dependencies.router.moviesPath.append(MoviesView.Path.search) return ContentView() } diff --git a/Ruddarr/Views/MovieView.swift b/Ruddarr/Views/MovieView.swift index c7a1236c..8aa6a1ec 100644 --- a/Ruddarr/Views/MovieView.swift +++ b/Ruddarr/Views/MovieView.swift @@ -11,11 +11,11 @@ struct MovieView: View { #Preview { let movies: [Movie] = PreviewData.load(name: "movies") - dependencies.router.selectedTab = .movies +// dependencies.router.selectedTab = .movies - dependencies.router.moviesPath.append( - MoviesView.Path.movie(movies[2].id) - ) +// dependencies.router.moviesPath.append( +// MoviesView.Path.movie(movies[2].id) +// ) return ContentView() } diff --git a/Ruddarr/Views/MoviesView.swift b/Ruddarr/Views/MoviesView.swift index adffd4e4..fc2cead2 100644 --- a/Ruddarr/Views/MoviesView.swift +++ b/Ruddarr/Views/MoviesView.swift @@ -1,6 +1,14 @@ import SwiftUI struct MoviesView: View { + + @Observable final class Router: DefaultKey { + static var singletonCache: MoviesView.Router? + + var path: NavigationPath = .init() + } + @Environment() var router: Router + @Environment(\.switchToNewInstance) var switchToNewInstance @State private var searchQuery = "" @State private var searchPresented = false @@ -20,14 +28,13 @@ struct MoviesView: View { case movie(Movie.ID) } - var onSettingsLinkTapped: () -> Void = { } - var body: some View { + @Bindable var router = router let gridItemLayout = [ GridItem(.adaptive(minimum: 250), spacing: 15) ] - NavigationStack(path: dependencies.$router.moviesPath) { + NavigationStack(path: $router.path) { Group { if let radarrInstance { ScrollView { @@ -110,7 +117,7 @@ struct MoviesView: View { description: Text("Connect a Radarr instance under [Settings](#view).") ) .environment(\.openURL, .init { _ in - onSettingsLinkTapped() + switchToNewInstance() return .handled }) } @@ -123,7 +130,7 @@ struct MoviesView: View { ).environment(\.openURL, .init { _ in searchQuery = "" searchPresented = false - dependencies.router.moviesPath.append(MoviesView.Path.search) + router.path.append(MoviesView.Path.search) return .handled }) } diff --git a/Ruddarr/Views/SettingsView.swift b/Ruddarr/Views/SettingsView.swift index 7fb03d11..f0ff8ec6 100644 --- a/Ruddarr/Views/SettingsView.swift +++ b/Ruddarr/Views/SettingsView.swift @@ -3,6 +3,10 @@ import SwiftUI import Nuke struct SettingsView: View { + @Observable final class Router: DefaultKey { + var path: NavigationPath = .init() + } + @Environment() var router: Router private let log: Logger = logger("settings") @CloudStorage("instances") private var instances: [Instance] = [] @@ -14,7 +18,8 @@ struct SettingsView: View { } var body: some View { - NavigationStack(path: dependencies.$router.settingsPath) { + @Bindable var router = router + NavigationStack(path: $router.path) { List { instanceSection aboutSection @@ -234,17 +239,17 @@ struct ThridPartyLibraries: View { } #Preview { - dependencies.router.selectedTab = .settings +// dependencies.router.selectedTab = .settings return ContentView() } #Preview("Libraries") { - dependencies.router.selectedTab = .settings +// dependencies.router.selectedTab = .settings - dependencies.router.settingsPath.append( - SettingsView.Path.libraries - ) +// dependencies.router.settingsPath.append( +// SettingsView.Path.libraries +// ) return ContentView() } diff --git a/Ruddarr/Views/ShowsView.swift b/Ruddarr/Views/ShowsView.swift index 05c7a58e..91bac66e 100644 --- a/Ruddarr/Views/ShowsView.swift +++ b/Ruddarr/Views/ShowsView.swift @@ -7,7 +7,6 @@ struct ShowsView: View { } #Preview { - dependencies.router.selectedTab = .shows - - return ContentView() +// dependencies.router.selectedTab = .shows + ContentView().environment(\.[key: \TabRouter], .init()) }