diff --git a/Mlem.xcodeproj/project.pbxproj b/Mlem.xcodeproj/project.pbxproj index 5bed59964..30971fcd5 100644 --- a/Mlem.xcodeproj/project.pbxproj +++ b/Mlem.xcodeproj/project.pbxproj @@ -430,11 +430,13 @@ E47478152AAC3C19001CB1AC /* NavigationContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = E47478142AAC3C19001CB1AC /* NavigationContext.swift */; }; E47B2B762A902DE200629AF7 /* SettingsRoutes.swift in Sources */ = {isa = PBXBuildFile; fileRef = E47B2B752A902DE200629AF7 /* SettingsRoutes.swift */; }; E4902BAB2A9024BF0054FB36 /* SettingsRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4902BAA2A9024BF0054FB36 /* SettingsRouter.swift */; }; + E49E01F42ABD99D300E42BB3 /* Routable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E49E01F32ABD99D300E42BB3 /* Routable.swift */; }; E49F0E762A90395400BC4EE3 /* NavigationPath+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = E49F0E752A90395400BC4EE3 /* NavigationPath+Helpers.swift */; }; E4D4DBA02A7C7B9D00C4F3DE /* Comments.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4D4DB9F2A7C7B9D00C4F3DE /* Comments.swift */; }; E4D4DBA22A7F233200C4F3DE /* FancyTabNavigationSelectionHashValueEnvironmentKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4D4DBA12A7F233200C4F3DE /* FancyTabNavigationSelectionHashValueEnvironmentKey.swift */; }; E4DDB4322A81819300B3A7E0 /* Double.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4DDB4312A81819300B3A7E0 /* Double.swift */; }; E4DDB4342A819C8000B3A7E0 /* QuickLookView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4DDB4332A819C8000B3A7E0 /* QuickLookView.swift */; }; + E4F0B5722AC2581800BC3E4A /* RoutableTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4F0B5712AC2581800BC3E4A /* RoutableTests.swift */; }; E4F0B56F2ABD00A000BC3E4A /* PresentationBackgroundInteraction.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4F0B56E2ABD00A000BC3E4A /* PresentationBackgroundInteraction.swift */; }; /* End PBXBuildFile section */ @@ -877,11 +879,13 @@ E47478142AAC3C19001CB1AC /* NavigationContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationContext.swift; sourceTree = ""; }; E47B2B752A902DE200629AF7 /* SettingsRoutes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsRoutes.swift; sourceTree = ""; }; E4902BAA2A9024BF0054FB36 /* SettingsRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsRouter.swift; sourceTree = ""; }; + E49E01F32ABD99D300E42BB3 /* Routable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Routable.swift; sourceTree = ""; }; E49F0E752A90395400BC4EE3 /* NavigationPath+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NavigationPath+Helpers.swift"; sourceTree = ""; }; E4D4DB9F2A7C7B9D00C4F3DE /* Comments.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Comments.swift; sourceTree = ""; }; E4D4DBA12A7F233200C4F3DE /* FancyTabNavigationSelectionHashValueEnvironmentKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FancyTabNavigationSelectionHashValueEnvironmentKey.swift; sourceTree = ""; }; E4DDB4312A81819300B3A7E0 /* Double.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Double.swift; sourceTree = ""; }; E4DDB4332A819C8000B3A7E0 /* QuickLookView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickLookView.swift; sourceTree = ""; }; + E4F0B5712AC2581800BC3E4A /* RoutableTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoutableTests.swift; sourceTree = ""; }; E4F0B56E2ABD00A000BC3E4A /* PresentationBackgroundInteraction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresentationBackgroundInteraction.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -1511,6 +1515,7 @@ isa = PBXGroup; children = ( 504ECBAF2AB4B0DF006C0B96 /* Model */, + E4F0B5702AC257FD00BC3E4A /* Navigation */, 50CC4A802AA0D5F90074C845 /* Parsers */, 50CC4A7B2A9CFF840074C845 /* Supporting Files */, 50BC1AB72A89741000E3C48B /* Community List */, @@ -2280,6 +2285,7 @@ E47478142AAC3C19001CB1AC /* NavigationContext.swift */, E47478122AAC350E001CB1AC /* NavigationLink+Helpers.swift */, E49F0E752A90395400BC4EE3 /* NavigationPath+Helpers.swift */, + E49E01F32ABD99D300E42BB3 /* Routable.swift */, E47B2B772A902E3C00629AF7 /* Router */, E47B2B742A902DB400629AF7 /* Route */, ); @@ -2294,6 +2300,14 @@ path = Animations; sourceTree = ""; }; + E4F0B5702AC257FD00BC3E4A /* Navigation */ = { + isa = PBXGroup; + children = ( + E4F0B5712AC2581800BC3E4A /* RoutableTests.swift */, + ); + path = Navigation; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -2859,6 +2873,7 @@ CDE6A8182A490AF20062D161 /* Inbox Mention View.swift in Sources */, CD3FBCDD2A4A6F0600B2063F /* GetReplies.swift in Sources */, 6332FDCF27EFDD2E0009A98A /* Accounts Page.swift in Sources */, + E49E01F42ABD99D300E42BB3 /* Routable.swift in Sources */, CDF842682A49FB9000723DA0 /* Inbox View Logic.swift in Sources */, 6D15D74C2A44DC240061B5CB /* Date.swift in Sources */, CDF1EF122A6B672C003594B6 /* Feed View.swift in Sources */, @@ -2886,6 +2901,7 @@ 031BF9552AB25AFB00F4517F /* SiteVersionTests.swift in Sources */, 50CC4A782A9CBDF70074C845 /* TimestampedValueTests.swift in Sources */, 50CC4A822AA0D61F0074C845 /* InstanceMetadataParserTests.swift in Sources */, + E4F0B5722AC2581800BC3E4A /* RoutableTests.swift in Sources */, 50C86ABC2A7E50E200277519 /* PersistenceRepositoryTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Mlem/Extensions/View - Handle Lemmy Links.swift b/Mlem/Extensions/View - Handle Lemmy Links.swift index 16e095acb..b63bb0fd4 100644 --- a/Mlem/Extensions/View - Handle Lemmy Links.swift +++ b/Mlem/Extensions/View - Handle Lemmy Links.swift @@ -153,17 +153,22 @@ struct HandleLemmyLinkResolution: ViewModifier { } return await MainActor.run { - switch resolution { - case let .post(object): - navigationPath.wrappedValue.append(object) - return true - case let .person(object): - navigationPath.wrappedValue.append(object.person) - return true - case let .community(object): - navigationPath.wrappedValue.append(object) - return true - case .comment: + do { + switch resolution { + case let .post(object): + navigationPath.wrappedValue.append(try Path.makeRoute(object)) + return true + case let .person(object): + navigationPath.wrappedValue.append(try Path.makeRoute(object.person)) + return true + case let .community(object): + navigationPath.wrappedValue.append(try Path.makeRoute(object)) + return true + case .comment: + return false + } + } catch { + errorHandler.handle(error) return false } } diff --git a/Mlem/Navigation/AnyNavigationPath.swift b/Mlem/Navigation/AnyNavigationPath.swift index 89fa0feb4..3c5d9fe60 100644 --- a/Mlem/Navigation/AnyNavigationPath.swift +++ b/Mlem/Navigation/AnyNavigationPath.swift @@ -10,6 +10,11 @@ import SwiftUI protocol AnyNavigationPath { + associatedtype Route: Routable + + /// Implementation should make a route that makes sense for the passed-in data value and can be appended to the navigation path. + static func makeRoute(_ value: V) throws -> Route where V: Hashable + /// The number of elements in this path. var count: Int { get } @@ -17,12 +22,10 @@ protocol AnyNavigationPath { var isEmpty: Bool { get } /// Appends a new value to the end of this path. - mutating func append(_ value: V) where V: Hashable + mutating func append(_ value: V) where V: Routable // swiftlint:disable identifier_name /// Removes values from the end of this path. mutating func removeLast(_ k: Int) // swiftlint:enable identifier_name } - -extension NavigationPath: AnyNavigationPath {} diff --git a/Mlem/Navigation/Routable.swift b/Mlem/Navigation/Routable.swift new file mode 100644 index 000000000..34a6a1e80 --- /dev/null +++ b/Mlem/Navigation/Routable.swift @@ -0,0 +1,40 @@ +// +// Routable.swift +// Mlem +// +// Created by Bosco Ho on 2023-09-22. +// + +import Foundation + +/// Conforming types can be added to a `NavigationRouter`'s path. +protocol Routable: Hashable { + + /// - Parameter value: A data type for a given navigation destination. This value could be (but not limited to) some raw data, a view model, or an enum case (representing a route on a navigation path). + /// - Returns: `nil` if data value cannot be mapped to a navigation route. + static func makeRoute(_ value: V) throws -> Self where V: Hashable + + /// Generic error string + static var makeRouteErrorString: String { get } +} + +enum RoutableError: LocalizedError { + case routeNotConfigured(value: V) +} + +extension Routable { + + /// Default implementation. + static func makeRoute(_ value: V) throws -> Self where V: Hashable { + switch value { + case let value as Self: + return value + default: + throw RoutableError.routeNotConfigured(value: value) + } + } + + static var makeRouteErrorString: String { + "`makeRoute(...) implementation must return a valid route for all valid data values." + } +} diff --git a/Mlem/Navigation/Route/NavigationRoutes.swift b/Mlem/Navigation/Route/NavigationRoutes.swift index 1063dfd63..fb53effab 100644 --- a/Mlem/Navigation/Route/NavigationRoutes.swift +++ b/Mlem/Navigation/Route/NavigationRoutes.swift @@ -10,7 +10,7 @@ import Foundation /// Possible routes for navigation links in `Mlem.app`. /// /// See `SettingsRoutes` for settings-related routes. -enum NavigationRoute: Hashable { +enum NavigationRoute: Routable { case apiCommunityView(APICommunityView) case apiCommunity(APICommunity) @@ -25,4 +25,36 @@ enum NavigationRoute: Hashable { case postLinkWithContext(PostLinkWithContext) case lazyLoadPostLinkWithContext(LazyLoadPostLinkWithContext) case userModeratorLink(UserModeratorLink) + + // swiftlint:disable cyclomatic_complexity + static func makeRoute(_ value: V) throws -> NavigationRoute where V: Hashable { + switch value { + case let value as APICommunityView: + return .apiCommunityView(value) + case let value as APICommunity: + return .apiCommunity(value) + case let value as CommunityLinkWithContext: + return .communityLinkWithContext(value) + case let value as CommunitySidebarLinkWithContext: + return .communitySidebarLinkWithContext(value) + case let value as APIPostView: + return .apiPostView(value) + case let value as APIPost: + return .apiPost(value) + case let value as APIPerson: + return .apiPerson(value) + case let value as PostLinkWithContext: + return .postLinkWithContext(value) + case let value as LazyLoadPostLinkWithContext: + return .lazyLoadPostLinkWithContext(value) + case let value as UserModeratorLink: + return .userModeratorLink(value) + case let value as Self: + /// Value is an enum case of type `Self` with either no associated value or pre-populated associated value. + return value + default: + throw RoutableError.routeNotConfigured(value: value) + } + } + // swiftlint:enable cyclomatic_complexity } diff --git a/Mlem/Navigation/Route/SettingsRoutes.swift b/Mlem/Navigation/Route/SettingsRoutes.swift index b4dc96a02..f52a8fff2 100644 --- a/Mlem/Navigation/Route/SettingsRoutes.swift +++ b/Mlem/Navigation/Route/SettingsRoutes.swift @@ -7,7 +7,7 @@ import Foundation -enum SettingsRoute: Hashable { +enum SettingsRoute: Routable { case accountsPage case general case accessibility @@ -21,9 +21,29 @@ enum SettingsRoute: Hashable { case commentPage(CommentSettingsRoute) case postPage(PostSettingsRoute) case licensesPage(LicensesSettingsRoute) + + static func makeRoute(_ value: V) throws -> SettingsRoute where V: Hashable { + switch value { + case let value as AboutSettingsRoute: + return try .aboutPage(AboutSettingsRoute.makeRoute(value)) + case let value as AppearanceSettingsRoute: + return try .appearancePage(AppearanceSettingsRoute.makeRoute(value)) + case let value as CommentSettingsRoute: + return try .commentPage(CommentSettingsRoute.makeRoute(value)) + case let value as PostSettingsRoute: + return try .postPage(PostSettingsRoute.makeRoute(value)) + case let value as LicensesSettingsRoute: + return try .licensesPage(LicensesSettingsRoute.makeRoute(value)) + case let value as Self: + /// Value is an enum case of type `Self` with either no associated value or pre-populated associated value. + return value + default: + throw RoutableError.routeNotConfigured(value: value) + } + } } -enum AppearanceSettingsRoute: Hashable, Codable { +enum AppearanceSettingsRoute: Routable, Codable { case theme case appIcon case posts @@ -33,21 +53,43 @@ enum AppearanceSettingsRoute: Hashable, Codable { case tabBar } -enum CommentSettingsRoute: Hashable, Codable { +enum CommentSettingsRoute: Routable, Codable { case layoutWidget } -enum PostSettingsRoute: Hashable, Codable { +enum PostSettingsRoute: Routable, Codable { case customizeWidgets } -enum AboutSettingsRoute: Hashable { +enum AboutSettingsRoute: Routable { case contributors case privacyPolicy(Document) case eula(Document) case licenses + + static func makeRoute(_ value: V) throws -> AboutSettingsRoute where V: Hashable { + switch value { + case let value as Document: + // return .privacyPolicy(value) + return .eula(value) + case let value as Self: + /// Value is an enum case of type `Self` with either no associated value or pre-populated associated value. + return value + default: + throw RoutableError.routeNotConfigured(value: value) + } + } } -enum LicensesSettingsRoute: Hashable { +enum LicensesSettingsRoute: Routable { case licenseDocument(Document) + + static func makeRoute(_ value: V) throws -> LicensesSettingsRoute where V: Hashable { + switch value { + case let value as Document: + return .licenseDocument(value) + default: + throw RoutableError.routeNotConfigured(value: value) + } + } } diff --git a/Mlem/Navigation/Router/NavigationRouter.swift b/Mlem/Navigation/Router/NavigationRouter.swift index 4642bdc30..a25b066d0 100644 --- a/Mlem/Navigation/Router/NavigationRouter.swift +++ b/Mlem/Navigation/Router/NavigationRouter.swift @@ -7,11 +7,21 @@ import Foundation -final class NavigationRouter: ObservableObject { - @Published var path: [Route] = [] +final class NavigationRouter: ObservableObject { + + /// - Avoid directly manipulating this value, if alternate methods are provided. + @Published var path: [RouteValue] = [] + } - + extension NavigationRouter: AnyNavigationPath { + + typealias Route = RouteValue + + static func makeRoute(_ value: V) throws -> Route where V: Hashable { + try RouteValue.makeRoute(value) + } + var count: Int { path.count } @@ -20,9 +30,9 @@ extension NavigationRouter: AnyNavigationPath { path.isEmpty } - func append(_ value: V) where V: Hashable { - assert(value is Route) + func append(_ value: V) where V: Routable { guard let route = value as? Route else { + assert(value is Route) return } path.append(route) diff --git a/MlemTests/Navigation/RoutableTests.swift b/MlemTests/Navigation/RoutableTests.swift new file mode 100644 index 000000000..b4effc13a --- /dev/null +++ b/MlemTests/Navigation/RoutableTests.swift @@ -0,0 +1,96 @@ +// +// RoutableTests.swift +// MlemTests +// +// Created by Bosco Ho on 2023-09-25. +// + +@testable import Mlem +import XCTest + +final class RoutableTests: XCTestCase { + + private enum MockRoute: Routable { + case routeA + case routeB(Int) + case routeC(MockNestedRoute) + } + + private enum MockNestedRoute: Routable { + case route1(Bool) + } + + // MARK: - MockRoute + + func testMockRoute_handlesNoAssociatedValueEnumCase() throws { + let value = MockRoute.routeA + let route = try MockRoute.makeRoute(value) + XCTAssert(route == value) + } + + func testMockRoute_handlesNonNestedAssociatedValueEnumCase() throws { + let data = 1 + let value = MockRoute.routeB(data) + let route = try MockRoute.makeRoute(value) + XCTAssert(route == value) + } + + func testMockRoute_handlesNestedAssociatedValueEnumCase() throws { + let data = true + let value = MockRoute.routeC(.route1(data)) + let route = try MockRoute.makeRoute(value) + XCTAssert(route == value) + } + + func testMockRoute_handlesUnsupportedValue() throws { + let value = "Mock Unsupported Value" + XCTAssertThrowsError(try MockRoute.makeRoute(value)) + } + + func testMockRoute_handlesNestedUnsupportedValue() throws { + let data = "Mock Unsupported Value" + XCTAssertThrowsError(try MockRoute.routeC(.makeRoute(data))) + } + + // MARK: - NavigationRoutes + + /// Passing in raw data value should return a valid route. + /// Assert `(Data) –> Route`. + func testNavigationRouteHandlesDataValue() throws { + let value = CommunityLinkWithContext(community: nil, feedType: .all) + let route = try NavigationRoute.makeRoute(value) + XCTAssert(route == .communityLinkWithContext(value)) + } + + /// Passing in a route enum with an associated value should return the passed in value. + func testNavigationRouteHandlesNonNestedAssociatedValueEnumCase() throws { + let data = CommunityLinkWithContext(community: nil, feedType: .all) + let value = NavigationRoute.communityLinkWithContext(data) + let route = try NavigationRoute.makeRoute(value) + XCTAssert(route == value) + } + + // MARK: - SettingsRoutes + + /// Passing in a route enum with no associated value should return the passed in value. + func testSettingsRouteHandlesNoAssociatedValueEnumCase() throws { + let value = SettingsRoute.general + let route = try SettingsRoute.makeRoute(value) + XCTAssert(route == value) + } + + /// Passing in a route enum with an associated value should return the passed in value. + func testSettingsRouteHandlesNonNestedAssociatedValueEnumCase() throws { + let value = SettingsRoute.aboutPage(.contributors) + let route = try SettingsRoute.makeRoute(value) + XCTAssert(route == value) + } + + /// Passing in a route enum with an associated value that also has an associated value should return the passed in value. + func testSettingsRouteHandlesNestedAssociatedValueEnumCase() throws { + let nestedValue = Document(body: "Mock EULA") + let value = SettingsRoute.aboutPage(.eula(nestedValue)) + let route = try SettingsRoute.makeRoute(value) + XCTAssert(route == value) + } +}