Refactoring

This commit is contained in:
Justin Mazzocchi 2020-08-02 15:23:01 -07:00
parent 24dd407caa
commit 827c3cfc77
No known key found for this signature in database
GPG Key ID: E223E6937AAFB01C
16 changed files with 311 additions and 195 deletions

View File

@ -6,22 +6,47 @@ import Combine
// swiftlint:disable force_try
private let decoder = MastodonDecoder()
private var cancellables = Set<AnyCancellable>()
private let devInstanceURL = URL(string: "https://mastodon.social")!
private let devIdentityID = "DEVELOPMENT_IDENTITY_ID"
private let devAccessToken = "DEVELOPMENT_ACCESS_TOKEN"
extension Secrets {
static func fresh() -> Secrets { Secrets(keychain: FakeKeychain()) }
static let development: Secrets = {
let secrets = Secrets(keychain: FakeKeychain())
let secrets = Secrets.fresh()
try! secrets.set("DEVELOPMENT_CLIENT_ID", forItem: .clientID, forIdentityID: devIdentityID)
try! secrets.set("DEVELOPMENT_CLIENT_SECRET", forItem: .clientSecret, forIdentityID: devIdentityID)
try! secrets.set("DEVELOPMENT_ACCESS_TOKEN", forItem: .accessToken, forIdentityID: devIdentityID)
try! secrets.set(devAccessToken, forItem: .accessToken, forIdentityID: devIdentityID)
return secrets
}()
}
extension Preferences {
static func fresh() -> Preferences { Preferences(userDefaults: FakeUserDefaults()) }
static let development: Preferences = {
let preferences = Preferences.fresh()
preferences[.recentIdentityID] = devIdentityID
return preferences
}()
}
extension MastodonClient {
static let development = MastodonClient(configuration: .stubbing)
static func fresh() -> MastodonClient { MastodonClient(configuration: .stubbing) }
static let development: MastodonClient = {
let client = MastodonClient.fresh()
client.instanceURL = devInstanceURL
client.accessToken = devAccessToken
return client
}()
}
extension Account {
@ -33,10 +58,12 @@ extension Instance {
}
extension IdentityDatabase {
static var development: IdentityDatabase = {
let db = try! IdentityDatabase(inMemory: true)
static func fresh() -> IdentityDatabase { try! IdentityDatabase(inMemory: true) }
db.createIdentity(id: devIdentityID, url: URL(string: "https://mastodon.social")!)
static var development: IdentityDatabase = {
let db = IdentityDatabase.fresh()
db.createIdentity(id: devIdentityID, url: devInstanceURL)
.receive(on: ImmediateScheduler.shared)
.sink(receiveCompletion: { _ in }, receiveValue: { _ in })
.store(in: &cancellables)
@ -68,11 +95,28 @@ extension Identity {
}()
}
extension SceneViewModel {
static let development = SceneViewModel(
networkClient: .development,
extension AppEnvironment {
static func fresh(
identityDatabase: IdentityDatabase = .fresh(),
preferences: Preferences = .fresh(),
secrets: Secrets = .fresh(),
webAuthSessionType: WebAuthSession.Type = SuccessfulStubbingWebAuthSession.self) -> AppEnvironment {
AppEnvironment(
identityDatabase: identityDatabase,
preferences: preferences,
secrets: secrets,
webAuthSessionType: webAuthSessionType)
}
static let development = AppEnvironment(
identityDatabase: .development,
secrets: .development)
preferences: .development,
secrets: .development,
webAuthSessionType: SuccessfulStubbingWebAuthSession.self)
}
extension SceneViewModel {
static let development = SceneViewModel(networkClient: .development, environment: .development)
}
// swiftlint:enable force_try

View File

@ -0,0 +1,21 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Foundation
class FakeUserDefaults: UserDefaults {
convenience init() {
self.init(suiteName: Self.suiteName)!
}
override init?(suiteName suitename: String?) {
guard let suitename = suitename else { return nil }
UserDefaults().removePersistentDomain(forName: suitename)
super.init(suiteName: suitename)
}
}
private extension FakeUserDefaults {
private static let suiteName = "com.metatext.metabolist.fake-user-defaults"
}

View File

@ -1,18 +1,17 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Foundation
import AuthenticationServices
class StubbingWebAuthenticationSession: WebAuthenticationSessionType {
let completionHandler: ASWebAuthenticationSession.CompletionHandler
class StubbingWebAuthSession: WebAuthSession {
let completionHandler: WebAuthSessionCompletionHandler
let url: URL
let callbackURLScheme: String?
var presentationContextProvider: ASWebAuthenticationPresentationContextProviding?
var presentationContextProvider: WebAuthPresentationContextProviding?
required init(
url URL: URL,
callbackURLScheme: String?,
completionHandler: @escaping ASWebAuthenticationSession.CompletionHandler) {
completionHandler: @escaping WebAuthSessionCompletionHandler) {
self.url = URL
self.callbackURLScheme = callbackURLScheme
self.completionHandler = completionHandler
@ -33,15 +32,13 @@ class StubbingWebAuthenticationSession: WebAuthenticationSessionType {
}
}
// swiftlint:disable type_name
class SuccessfulStubbingWebAuthenticationSession: StubbingWebAuthenticationSession {
// swiftlint:enable type_name
class SuccessfulStubbingWebAuthSession: StubbingWebAuthSession {
private let redirectURL: URL
required init(
url URL: URL,
callbackURLScheme: String?,
completionHandler: @escaping ASWebAuthenticationSession.CompletionHandler) {
completionHandler: @escaping WebAuthSessionCompletionHandler) {
redirectURL = Foundation.URL(
string: URLComponents(url: URL, resolvingAgainstBaseURL: true)!
.queryItems!.first(where: { $0.name == "redirect_uri" })!.value!)!
@ -62,10 +59,8 @@ class SuccessfulStubbingWebAuthenticationSession: StubbingWebAuthenticationSessi
}
}
// swiftlint:disable type_name
class CanceledLoginStubbingWebAuthenticationSession: StubbingWebAuthenticationSession {
// swiftlint:enable type_name
class CanceledLoginStubbingWebAuthSession: StubbingWebAuthSession {
override var completionHandlerError: Error? {
ASWebAuthenticationSessionError(.canceledLogin)
WebAuthSessionError(.canceledLogin)
}
}

View File

@ -21,6 +21,13 @@
D04FD73D24D4A83A007D572D /* InstanceEndpoint+Stubbing.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04FD73B24D4A83A007D572D /* InstanceEndpoint+Stubbing.swift */; };
D04FD74224D4AA34007D572D /* DevelopmentModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04FD74124D4AA34007D572D /* DevelopmentModels.swift */; };
D04FD74324D4AA34007D572D /* DevelopmentModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04FD74124D4AA34007D572D /* DevelopmentModels.swift */; };
D052BBC724D749C800A80A7A /* SceneViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D052BBC624D749C800A80A7A /* SceneViewModelTests.swift */; };
D052BBCA24D74C9200A80A7A /* FakeUserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = D052BBC824D74B6400A80A7A /* FakeUserDefaults.swift */; };
D052BBCB24D74C9300A80A7A /* FakeUserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = D052BBC824D74B6400A80A7A /* FakeUserDefaults.swift */; };
D052BBCF24D750C000A80A7A /* Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = D052BBCE24D750C000A80A7A /* Preferences.swift */; };
D052BBD024D750C000A80A7A /* Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = D052BBCE24D750C000A80A7A /* Preferences.swift */; };
D052BBD124D750CA00A80A7A /* AppEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D052BBCC24D750A100A80A7A /* AppEnvironment.swift */; };
D052BBD224D750CB00A80A7A /* AppEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D052BBCC24D750A100A80A7A /* AppEnvironment.swift */; };
D065F53924D37E5100741304 /* CombineExpectations in Frameworks */ = {isa = PBXBuildFile; productRef = D065F53824D37E5100741304 /* CombineExpectations */; };
D065F53B24D3B33A00741304 /* View+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D065F53A24D3B33A00741304 /* View+Extensions.swift */; };
D065F53C24D3B33A00741304 /* View+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D065F53A24D3B33A00741304 /* View+Extensions.swift */; };
@ -54,8 +61,8 @@
D06B492024D3FB8000642749 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = D06B491E24D3F7FE00642749 /* Localizable.strings */; };
D06B492324D4611300642749 /* KingfisherSwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = D06B492224D4611300642749 /* KingfisherSwiftUI */; };
D06B492524D4612400642749 /* KingfisherSwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = D06B492424D4612400642749 /* KingfisherSwiftUI */; };
D074577724D29006004758DB /* StubbingWebAuthenticationSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = D074577624D29006004758DB /* StubbingWebAuthenticationSession.swift */; };
D074577824D29006004758DB /* StubbingWebAuthenticationSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = D074577624D29006004758DB /* StubbingWebAuthenticationSession.swift */; };
D074577724D29006004758DB /* StubbingWebAuthSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = D074577624D29006004758DB /* StubbingWebAuthSession.swift */; };
D074577824D29006004758DB /* StubbingWebAuthSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = D074577624D29006004758DB /* StubbingWebAuthSession.swift */; };
D074577A24D29366004758DB /* URLSessionConfiguration+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D074577924D29366004758DB /* URLSessionConfiguration+Extensions.swift */; };
D074577B24D29366004758DB /* URLSessionConfiguration+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D074577924D29366004758DB /* URLSessionConfiguration+Extensions.swift */; };
D081A40524D0F1A8001B016E /* String+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D081A40424D0F1A8001B016E /* String+Extensions.swift */; };
@ -103,8 +110,8 @@
D0DC177724D0CF2600A75C65 /* FakeKeychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0DC177624D0CF2600A75C65 /* FakeKeychain.swift */; };
D0DC177824D0CF2600A75C65 /* FakeKeychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0DC177624D0CF2600A75C65 /* FakeKeychain.swift */; };
D0ED1B6E24CE100C00B4899C /* AddIdentityViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0ED1B6D24CE100C00B4899C /* AddIdentityViewModelTests.swift */; };
D0ED1BB724CE47F400B4899C /* WebAuthenticationSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0ED1BB624CE47F400B4899C /* WebAuthenticationSession.swift */; };
D0ED1BB824CE47F400B4899C /* WebAuthenticationSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0ED1BB624CE47F400B4899C /* WebAuthenticationSession.swift */; };
D0ED1BB724CE47F400B4899C /* WebAuthSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0ED1BB624CE47F400B4899C /* WebAuthSession.swift */; };
D0ED1BB824CE47F400B4899C /* WebAuthSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0ED1BB624CE47F400B4899C /* WebAuthSession.swift */; };
D0ED1BC124CED48800B4899C /* HTTPClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0ED1BC024CED48800B4899C /* HTTPClient.swift */; };
D0ED1BC224CED48800B4899C /* HTTPClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0ED1BC024CED48800B4899C /* HTTPClient.swift */; };
D0ED1BC424CED54D00B4899C /* HTTPTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0ED1BC324CED54D00B4899C /* HTTPTarget.swift */; };
@ -150,6 +157,10 @@
D04FD73824D4A7B4007D572D /* AccountEndpoint+Stubbing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AccountEndpoint+Stubbing.swift"; sourceTree = "<group>"; };
D04FD73B24D4A83A007D572D /* InstanceEndpoint+Stubbing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "InstanceEndpoint+Stubbing.swift"; sourceTree = "<group>"; };
D04FD74124D4AA34007D572D /* DevelopmentModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevelopmentModels.swift; sourceTree = "<group>"; };
D052BBC624D749C800A80A7A /* SceneViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneViewModelTests.swift; sourceTree = "<group>"; };
D052BBC824D74B6400A80A7A /* FakeUserDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FakeUserDefaults.swift; sourceTree = "<group>"; };
D052BBCC24D750A100A80A7A /* AppEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppEnvironment.swift; sourceTree = "<group>"; };
D052BBCE24D750C000A80A7A /* Preferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Preferences.swift; sourceTree = "<group>"; };
D065F53A24D3B33A00741304 /* View+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Extensions.swift"; sourceTree = "<group>"; };
D065F53D24D3D20300741304 /* InstanceEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceEndpoint.swift; sourceTree = "<group>"; };
D0666A2124C677B400F3F04B /* Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = Tests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
@ -166,7 +177,7 @@
D0666A6E24C6DFB300F3F04B /* AccessToken.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessToken.swift; sourceTree = "<group>"; };
D0666A7124C6E0D300F3F04B /* Secrets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Secrets.swift; sourceTree = "<group>"; };
D06B491E24D3F7FE00642749 /* Localizable.strings */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; path = Localizable.strings; sourceTree = "<group>"; };
D074577624D29006004758DB /* StubbingWebAuthenticationSession.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StubbingWebAuthenticationSession.swift; sourceTree = "<group>"; };
D074577624D29006004758DB /* StubbingWebAuthSession.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StubbingWebAuthSession.swift; sourceTree = "<group>"; };
D074577924D29366004758DB /* URLSessionConfiguration+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URLSessionConfiguration+Extensions.swift"; sourceTree = "<group>"; };
D081A40424D0F1A8001B016E /* String+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Extensions.swift"; sourceTree = "<group>"; };
D0B23F0C24D210E90066F411 /* NSError+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSError+Extensions.swift"; sourceTree = "<group>"; };
@ -191,7 +202,7 @@
D0DC177324D0B58800A75C65 /* Keychain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Keychain.swift; sourceTree = "<group>"; };
D0DC177624D0CF2600A75C65 /* FakeKeychain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FakeKeychain.swift; sourceTree = "<group>"; };
D0ED1B6D24CE100C00B4899C /* AddIdentityViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddIdentityViewModelTests.swift; sourceTree = "<group>"; };
D0ED1BB624CE47F400B4899C /* WebAuthenticationSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebAuthenticationSession.swift; sourceTree = "<group>"; };
D0ED1BB624CE47F400B4899C /* WebAuthSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebAuthSession.swift; sourceTree = "<group>"; };
D0ED1BC024CED48800B4899C /* HTTPClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPClient.swift; sourceTree = "<group>"; };
D0ED1BC324CED54D00B4899C /* HTTPTarget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPTarget.swift; sourceTree = "<group>"; };
D0ED1BCA24CF744200B4899C /* MastodonClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonClient.swift; sourceTree = "<group>"; };
@ -309,6 +320,7 @@
D0666A5024C6C3BC00F3F04B /* Account.swift */,
D0C963FA24CC359D003BD330 /* AlertItem.swift */,
D0666A6224C6DC6C00F3F04B /* AppAuthorization.swift */,
D052BBCC24D750A100A80A7A /* AppEnvironment.swift */,
D0ED1BD624CF94B200B4899C /* Application.swift */,
D0666A4424C6BC0A00F3F04B /* DatabaseError.swift */,
D0666A5324C6C3E500F3F04B /* Emoji.swift */,
@ -317,6 +329,7 @@
D0666A4D24C6C39600F3F04B /* Instance.swift */,
D0DC177324D0B58800A75C65 /* Keychain.swift */,
D0ED1BE224CFA84400B4899C /* MastodonError.swift */,
D052BBCE24D750C000A80A7A /* Preferences.swift */,
D0666A7124C6E0D300F3F04B /* Secrets.swift */,
);
path = Model;
@ -357,7 +370,7 @@
D0ED1BC024CED48800B4899C /* HTTPClient.swift */,
D0666A5624C6C63400F3F04B /* MastodonDecoder.swift */,
D0666A5924C6C64100F3F04B /* MastodonEncoder.swift */,
D0ED1BB624CE47F400B4899C /* WebAuthenticationSession.swift */,
D0ED1BB624CE47F400B4899C /* WebAuthSession.swift */,
);
path = Networking;
sourceTree = "<group>";
@ -400,6 +413,7 @@
isa = PBXGroup;
children = (
D0ED1B6D24CE100C00B4899C /* AddIdentityViewModelTests.swift */,
D052BBC624D749C800A80A7A /* SceneViewModelTests.swift */,
);
path = "View Models";
sourceTree = "<group>";
@ -409,11 +423,12 @@
children = (
D04FD74124D4AA34007D572D /* DevelopmentModels.swift */,
D0DC177624D0CF2600A75C65 /* FakeKeychain.swift */,
D052BBC824D74B6400A80A7A /* FakeUserDefaults.swift */,
D0DC175724D0130800A75C65 /* HTTPStubs.swift */,
D0DC174824CFF13700A75C65 /* Mastodon API Stubs */,
D0DC174C24CFF1F100A75C65 /* Stubbing.swift */,
D0DC174524CFEC2000A75C65 /* StubbingURLProtocol.swift */,
D074577624D29006004758DB /* StubbingWebAuthenticationSession.swift */,
D074577624D29006004758DB /* StubbingWebAuthSession.swift */,
D074577924D29366004758DB /* URLSessionConfiguration+Extensions.swift */,
);
path = "Development Assets";
@ -630,6 +645,7 @@
D0BEC94724CA22C400E864C4 /* TimelineViewModel.swift in Sources */,
D0666A4E24C6C39600F3F04B /* Instance.swift in Sources */,
D0ED1BDA24CF963E00B4899C /* AppAuthorizationEndpoint.swift in Sources */,
D052BBCF24D750C000A80A7A /* Preferences.swift in Sources */,
D0ED1BE324CFA84400B4899C /* MastodonError.swift in Sources */,
D0666A6324C6DC6C00F3F04B /* AppAuthorization.swift in Sources */,
D065F53B24D3B33A00741304 /* View+Extensions.swift in Sources */,
@ -639,6 +655,7 @@
D0666A5124C6C3BC00F3F04B /* Account.swift in Sources */,
D0ED1BE024CF98FB00B4899C /* AccountEndpoint.swift in Sources */,
D0B93B3024D55098007AF646 /* Screen.swift in Sources */,
D052BBD224D750CB00A80A7A /* AppEnvironment.swift in Sources */,
D081A40524D0F1A8001B016E /* String+Extensions.swift in Sources */,
D0BEC93824C9632800E864C4 /* SceneViewModel.swift in Sources */,
D0ED1BC124CED48800B4899C /* HTTPClient.swift in Sources */,
@ -648,6 +665,7 @@
D0666A5424C6C3E500F3F04B /* Emoji.swift in Sources */,
D0DC175524D00F0A00A75C65 /* AccessTokenEndpoint+Stubbing.swift in Sources */,
D0B23F0D24D210E90066F411 /* NSError+Extensions.swift in Sources */,
D052BBCA24D74C9200A80A7A /* FakeUserDefaults.swift in Sources */,
D0DC175224D008E300A75C65 /* MastodonTarget+Stubbing.swift in Sources */,
D0666A4224C6BB7B00F3F04B /* IdentityDatabase.swift in Sources */,
D0BEC94A24CA231200E864C4 /* TimelineView.swift in Sources */,
@ -662,10 +680,10 @@
D0666A5724C6C63400F3F04B /* MastodonDecoder.swift in Sources */,
D0DB6EF424C5228A00D965FE /* AddIdentityView.swift in Sources */,
D0DC177424D0B58800A75C65 /* Keychain.swift in Sources */,
D074577724D29006004758DB /* StubbingWebAuthenticationSession.swift in Sources */,
D074577724D29006004758DB /* StubbingWebAuthSession.swift in Sources */,
D0ED1BCE24CF768200B4899C /* MastodonEndpoint.swift in Sources */,
D074577A24D29366004758DB /* URLSessionConfiguration+Extensions.swift in Sources */,
D0ED1BB724CE47F400B4899C /* WebAuthenticationSession.swift in Sources */,
D0ED1BB724CE47F400B4899C /* WebAuthSession.swift in Sources */,
D0666A7224C6E0D300F3F04B /* Secrets.swift in Sources */,
D0BEC95124CA2B7E00E864C4 /* TabNavigation.swift in Sources */,
D0ED1BC424CED54D00B4899C /* HTTPTarget.swift in Sources */,
@ -690,6 +708,7 @@
D0BEC94824CA22C400E864C4 /* TimelineViewModel.swift in Sources */,
D0666A4F24C6C39600F3F04B /* Instance.swift in Sources */,
D0ED1BDB24CF963E00B4899C /* AppAuthorizationEndpoint.swift in Sources */,
D052BBD024D750C000A80A7A /* Preferences.swift in Sources */,
D0ED1BE424CFA84400B4899C /* MastodonError.swift in Sources */,
D0666A6424C6DC6C00F3F04B /* AppAuthorization.swift in Sources */,
D065F53C24D3B33A00741304 /* View+Extensions.swift in Sources */,
@ -699,6 +718,7 @@
D0666A5224C6C3BC00F3F04B /* Account.swift in Sources */,
D0ED1BE124CF98FB00B4899C /* AccountEndpoint.swift in Sources */,
D0B93B3124D55098007AF646 /* Screen.swift in Sources */,
D052BBD124D750CA00A80A7A /* AppEnvironment.swift in Sources */,
D081A40624D0F1A8001B016E /* String+Extensions.swift in Sources */,
D0BEC93924C9632800E864C4 /* SceneViewModel.swift in Sources */,
D0ED1BC224CED48800B4899C /* HTTPClient.swift in Sources */,
@ -708,6 +728,7 @@
D0666A5524C6C3E500F3F04B /* Emoji.swift in Sources */,
D0DC175624D00F0A00A75C65 /* AccessTokenEndpoint+Stubbing.swift in Sources */,
D0B23F0E24D210E90066F411 /* NSError+Extensions.swift in Sources */,
D052BBCB24D74C9300A80A7A /* FakeUserDefaults.swift in Sources */,
D0DC175324D008E300A75C65 /* MastodonTarget+Stubbing.swift in Sources */,
D0666A4324C6BB7B00F3F04B /* IdentityDatabase.swift in Sources */,
D0BEC94B24CA231200E864C4 /* TimelineView.swift in Sources */,
@ -722,10 +743,10 @@
D0666A5824C6C63400F3F04B /* MastodonDecoder.swift in Sources */,
D0DB6EF524C5233E00D965FE /* AddIdentityView.swift in Sources */,
D0DC177524D0B58800A75C65 /* Keychain.swift in Sources */,
D074577824D29006004758DB /* StubbingWebAuthenticationSession.swift in Sources */,
D074577824D29006004758DB /* StubbingWebAuthSession.swift in Sources */,
D0ED1BCF24CF768200B4899C /* MastodonEndpoint.swift in Sources */,
D074577B24D29366004758DB /* URLSessionConfiguration+Extensions.swift in Sources */,
D0ED1BB824CE47F400B4899C /* WebAuthenticationSession.swift in Sources */,
D0ED1BB824CE47F400B4899C /* WebAuthSession.swift in Sources */,
D0BEC94F24CA2B5300E864C4 /* SidebarNavigation.swift in Sources */,
D0666A7324C6E0D300F3F04B /* Secrets.swift in Sources */,
D0ED1BC524CED54D00B4899C /* HTTPTarget.swift in Sources */,
@ -744,6 +765,7 @@
buildActionMask = 2147483647;
files = (
D0ED1B6E24CE100C00B4899C /* AddIdentityViewModelTests.swift in Sources */,
D052BBC724D749C800A80A7A /* SceneViewModelTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};

View File

@ -4,15 +4,22 @@ import SwiftUI
@main
struct MetatextApp: App {
private let identityDatabase: IdentityDatabase
private let secrets = Secrets(keychain: Keychain(service: "com.metabolist.metatext"))
private let environment: AppEnvironment
init() {
let identityDatabase: IdentityDatabase
do {
try identityDatabase = IdentityDatabase()
} catch {
fatalError("Failed to initialize identity database")
}
environment = AppEnvironment(
identityDatabase: identityDatabase,
preferences: Preferences(userDefaults: .standard),
secrets: Secrets(keychain: Keychain(service: "com.metabolist.metatext")),
webAuthSessionType: RealWebAuthSession.self)
}
var body: some Scene {
@ -21,8 +28,7 @@ struct MetatextApp: App {
.environmentObject(
SceneViewModel(
networkClient: MastodonClient(),
identityDatabase: identityDatabase,
secrets: secrets))
environment: environment))
}
}
}

View File

@ -0,0 +1,10 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Foundation
struct AppEnvironment {
let identityDatabase: IdentityDatabase
let preferences: Preferences
let secrets: Secrets
let webAuthSessionType: WebAuthSession.Type
}

View File

@ -0,0 +1,24 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Foundation
class Preferences {
private let userDefaults: UserDefaults
init(userDefaults: UserDefaults) {
self.userDefaults = userDefaults
}
}
extension Preferences {
enum Item: String {
case recentIdentityID = "recent-identity-id"
}
}
extension Preferences {
subscript<T>(index: Preferences.Item) -> T? {
get { userDefaults.value(forKey: index.rawValue) as? T }
set { userDefaults.set(newValue, forKey: index.rawValue) }
}
}

View File

@ -0,0 +1,49 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Foundation
import AuthenticationServices
import Combine
protocol WebAuthSession: AnyObject {
init(url URL: URL,
callbackURLScheme: String?,
completionHandler: @escaping WebAuthSessionCompletionHandler)
var presentationContextProvider: WebAuthPresentationContextProviding? { get set }
@discardableResult func start() -> Bool
}
extension WebAuthSession {
static func publisher(
url: URL,
callbackURLScheme: String?,
presentationContextProvider: WebAuthPresentationContextProviding) -> AnyPublisher<URL?, Error> {
Future<URL?, Error> { promise in
let webAuthSession = Self(
url: url,
callbackURLScheme: callbackURLScheme) { oauthCallbackURL, error in
if let error = error {
return promise(.failure(error))
}
return promise(.success(oauthCallbackURL))
}
webAuthSession.presentationContextProvider = presentationContextProvider
webAuthSession.start()
}
.eraseToAnyPublisher()
}
}
class WebAuthSessionContextProvider: NSObject, ASWebAuthenticationPresentationContextProviding {
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
ASPresentationAnchor()
}
}
typealias WebAuthSessionCompletionHandler = ASWebAuthenticationSession.CompletionHandler
typealias WebAuthSessionError = ASWebAuthenticationSessionError
typealias WebAuthPresentationContextProviding = ASWebAuthenticationPresentationContextProviding
typealias RealWebAuthSession = ASWebAuthenticationSession
extension RealWebAuthSession: WebAuthSession {}

View File

@ -1,38 +0,0 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Foundation
import AuthenticationServices
import Combine
protocol WebAuthenticationSessionType: AnyObject {
init(url URL: URL,
callbackURLScheme: String?,
completionHandler: @escaping ASWebAuthenticationSession.CompletionHandler)
var presentationContextProvider: ASWebAuthenticationPresentationContextProviding? { get set }
@discardableResult func start() -> Bool
}
extension ASWebAuthenticationSession: WebAuthenticationSessionType {}
extension WebAuthenticationSessionType {
static func publisher(
url: URL,
callbackURLScheme: String?,
presentationContextProvider: ASWebAuthenticationPresentationContextProviding) -> AnyPublisher<URL?, Error> {
Future<URL?, Error> { promise in
let webAuthenticationSession = Self(
url: url,
callbackURLScheme: callbackURLScheme) { oauthCallbackURL, error in
if let error = error {
return promise(.failure(error))
}
return promise(.success(oauthCallbackURL))
}
webAuthenticationSession.presentationContextProvider = presentationContextProvider
webAuthenticationSession.start()
}
.eraseToAnyPublisher()
}
}

View File

@ -2,7 +2,6 @@
import Foundation
import Combine
import AuthenticationServices
class AddIdentityViewModel: ObservableObject {
@Published var urlFieldText = ""
@ -11,20 +10,12 @@ class AddIdentityViewModel: ObservableObject {
@Published private(set) var addedIdentityID: String?
private let networkClient: HTTPClient
private let identityDatabase: IdentityDatabase
private let secrets: Secrets
private let webAuthenticationSessionType: WebAuthenticationSessionType.Type
private let webAuthenticationSessionContextProvider = WebAuthenticationSessionContextProvider()
private let environment: AppEnvironment
private let webAuthSessionContextProvider = WebAuthSessionContextProvider()
init(
networkClient: HTTPClient,
identityDatabase: IdentityDatabase,
secrets: Secrets,
webAuthenticationSessionType: WebAuthenticationSessionType.Type = ASWebAuthenticationSession.self) {
init(networkClient: HTTPClient, environment: AppEnvironment) {
self.networkClient = networkClient
self.identityDatabase = identityDatabase
self.secrets = secrets
self.webAuthenticationSessionType = webAuthenticationSessionType
self.environment = environment
}
func goTapped() {
@ -45,22 +36,19 @@ class AddIdentityViewModel: ObservableObject {
identityID: identityID,
instanceURL: instanceURL,
redirectURL: redirectURL,
secrets: secrets)
secrets: environment.secrets)
.authenticationURL(instanceURL: instanceURL, redirectURL: redirectURL)
.authenticate(
webAuthenticationSessionType: webAuthenticationSessionType,
contextProvider: webAuthenticationSessionContextProvider,
webAuthSessionType: environment.webAuthSessionType,
contextProvider: webAuthSessionContextProvider,
callbackURLScheme: MastodonAPI.OAuth.callbackURLScheme)
.extractCode()
.requestAccessToken(
networkClient: networkClient,
identityID: identityID,
instanceURL: instanceURL)
.createIdentity(
id: identityID,
instanceURL: instanceURL,
identityDatabase: identityDatabase,
secrets: secrets)
redirectURL: redirectURL)
.createIdentity(id: identityID, instanceURL: instanceURL, environment: environment)
.assignErrorsToAlertItem(to: \.alertItem, on: self)
.receive(on: RunLoop.main)
.handleEvents(
@ -72,12 +60,6 @@ class AddIdentityViewModel: ObservableObject {
}
private extension AddIdentityViewModel {
private class WebAuthenticationSessionContextProvider: NSObject, ASWebAuthenticationPresentationContextProviding {
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
ASPresentationAnchor()
}
}
private func authorizeApp(
identityID: String,
instanceURL: URL,
@ -102,9 +84,7 @@ private extension AddIdentityViewModel {
}
private extension Publisher where Output == AppAuthorization {
func authenticationURL(
instanceURL: URL,
redirectURL: URL) -> AnyPublisher<(AppAuthorization, URL), Error> {
func authenticationURL(instanceURL: URL, redirectURL: URL) -> AnyPublisher<(AppAuthorization, URL), Error> {
tryMap { appAuthorization in
guard var authorizationURLComponents = URLComponents(url: instanceURL, resolvingAgainstBaseURL: true) else {
throw URLError(.badURL)
@ -131,16 +111,16 @@ private extension Publisher where Output == AppAuthorization {
private extension Publisher where Output == (AppAuthorization, URL), Failure == Error {
func authenticate(
webAuthenticationSessionType: WebAuthenticationSessionType.Type,
contextProvider: ASWebAuthenticationPresentationContextProviding,
webAuthSessionType: WebAuthSession.Type,
contextProvider: WebAuthSessionContextProvider,
callbackURLScheme: String) -> AnyPublisher<(AppAuthorization, URL), Error> {
flatMap { appAuthorization, url in
webAuthenticationSessionType.publisher(
webAuthSessionType.publisher(
url: url,
callbackURLScheme: callbackURLScheme,
presentationContextProvider: contextProvider)
.tryCatch { error -> AnyPublisher<URL?, Error> in
if (error as? ASWebAuthenticationSessionError)?.code == .canceledLogin {
if (error as? WebAuthSessionError)?.code == .canceledLogin {
return Just(nil).setFailureType(to: Error.self).eraseToAnyPublisher()
}
@ -154,33 +134,32 @@ private extension Publisher where Output == (AppAuthorization, URL), Failure ==
}
private extension Publisher where Output == (AppAuthorization, URL) {
// swiftlint:disable large_tuple
func extractCode() -> AnyPublisher<(AppAuthorization, URL, String), Error> {
tryMap { appAuthorization, url -> (AppAuthorization, URL, String) in
func extractCode() -> AnyPublisher<(AppAuthorization, String), Error> {
tryMap { appAuthorization, url -> (AppAuthorization, String) in
guard let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: true)?.queryItems,
let code = queryItems.first(where: { $0.name == MastodonAPI.OAuth.codeCallbackQueryItemName })?.value
else { throw MastodonAPI.OAuthError.codeNotFound }
return (appAuthorization, url, code)
return (appAuthorization, code)
}
.eraseToAnyPublisher()
}
// swiftlint:enable large_tuple
}
private extension Publisher where Output == (AppAuthorization, URL, String), Failure == Error {
private extension Publisher where Output == (AppAuthorization, String), Failure == Error {
func requestAccessToken(
networkClient: HTTPClient,
identityID: String,
instanceURL: URL) -> AnyPublisher<AccessToken, Error> {
flatMap { appAuthorization, url, code -> AnyPublisher<AccessToken, Error> in
instanceURL: URL,
redirectURL: URL) -> AnyPublisher<AccessToken, Error> {
flatMap { appAuthorization, code -> AnyPublisher<AccessToken, Error> in
let endpoint = AccessTokenEndpoint.oauthToken(
clientID: appAuthorization.clientId,
clientSecret: appAuthorization.clientSecret,
code: code,
grantType: MastodonAPI.OAuth.grantType,
scopes: MastodonAPI.OAuth.scopes,
redirectURI: url.absoluteString)
redirectURI: redirectURL.absoluteString)
let target = MastodonTarget(baseURL: instanceURL, endpoint: endpoint, accessToken: nil)
return networkClient.request(target)
@ -190,18 +169,18 @@ private extension Publisher where Output == (AppAuthorization, URL, String), Fai
}
private extension Publisher where Output == AccessToken {
func createIdentity(
id: String,
instanceURL: URL,
identityDatabase: IdentityDatabase,
secrets: Secrets) -> AnyPublisher<String, Error> {
func createIdentity(id: String, instanceURL: URL, environment: AppEnvironment) -> AnyPublisher<String, Error> {
tryMap { accessToken -> (String, URL) in
try secrets.set(accessToken.accessToken, forItem: .accessToken, forIdentityID: id)
try environment.secrets.set(accessToken.accessToken, forItem: .accessToken, forIdentityID: id)
return (id, instanceURL)
}
.flatMap(identityDatabase.createIdentity)
.map { id }
.flatMap(environment.identityDatabase.createIdentity)
.map {
environment.preferences[.recentIdentityID] = id
return id
}
.eraseToAnyPublisher()
}
}

View File

@ -10,21 +10,14 @@ class SceneViewModel: ObservableObject {
var selectedTopLevelNavigation: TopLevelNavigation? = .timelines
private let networkClient: MastodonClient
private let identityDatabase: IdentityDatabase
private let secrets: Secrets
private let userDefaults: UserDefaults
private let environment: AppEnvironment
private var cancellables = Set<AnyCancellable>()
init(networkClient: MastodonClient,
identityDatabase: IdentityDatabase,
secrets: Secrets,
userDefaults: UserDefaults = .standard) {
init(networkClient: MastodonClient, environment: AppEnvironment) {
self.networkClient = networkClient
self.identityDatabase = identityDatabase
self.secrets = secrets
self.userDefaults = userDefaults
self.environment = environment
if let recentIdentityID = recentIdentityID {
if let recentIdentityID = environment.preferences[.recentIdentityID] as String? {
changeIdentity(id: recentIdentityID)
}
}
@ -37,7 +30,7 @@ extension SceneViewModel {
if networkClient.accessToken != nil {
networkClient.request(AccountEndpoint.verifyCredentials)
.map { ($0, identity.id) }
.flatMap(identityDatabase.updateAccount)
.flatMap(environment.identityDatabase.updateAccount)
.assignErrorsToAlertItem(to: \.alertItem, on: self)
.sink(receiveValue: {})
.store(in: &cancellables)
@ -45,17 +38,14 @@ extension SceneViewModel {
networkClient.request(InstanceEndpoint.instance)
.map { ($0, identity.id) }
.flatMap(identityDatabase.updateInstance)
.flatMap(environment.identityDatabase.updateInstance)
.assignErrorsToAlertItem(to: \.alertItem, on: self)
.sink(receiveValue: {})
.store(in: &cancellables)
}
func addIdentityViewModel() -> AddIdentityViewModel {
let addAccountViewModel = AddIdentityViewModel(
networkClient: networkClient,
identityDatabase: identityDatabase,
secrets: secrets)
let addAccountViewModel = AddIdentityViewModel(networkClient: networkClient, environment: environment)
addAccountViewModel.$addedIdentityID
.compactMap { $0 }
@ -67,24 +57,17 @@ extension SceneViewModel {
}
private extension SceneViewModel {
private static let recentIdentityIDKey = "recentIdentityID"
private var recentIdentityID: String? {
get { userDefaults.value(forKey: Self.recentIdentityIDKey) as? String }
set { userDefaults.set(newValue, forKey: Self.recentIdentityIDKey) }
}
private func changeIdentity(id: String) {
identityDatabase.identityObservation(id: id)
environment.identityDatabase.identityObservation(id: id)
.assignErrorsToAlertItem(to: \.alertItem, on: self)
.handleEvents(receiveOutput: { [weak self] in
guard let self = self, let identity = $0 else { return }
self.recentIdentityID = identity.id
self.networkClient.instanceURL = identity.url
do {
self.networkClient.accessToken = try self.secrets.item(.accessToken, forIdentityID: identity.id)
self.networkClient.accessToken =
try self.environment.secrets.item(.accessToken, forIdentityID: identity.id)
} catch {
self.alertItem = AlertItem(error: error)
}

View File

@ -41,10 +41,7 @@ struct AddAccountView_Previews: PreviewProvider {
static var previews: some View {
AddIdentityView(viewModel: AddIdentityViewModel(
networkClient: MastodonClient.development,
// swiftlint:disable force_try
identityDatabase: try! IdentityDatabase(inMemory: true),
// swiftlint:enable force_try
secrets: Secrets(keychain: FakeKeychain())))
environment: .development))
}
}
#endif

View File

@ -24,7 +24,8 @@ struct ContentView: View {
private extension ContentView {
private func mainNavigation(identity: Identity) -> some View {
#if os(macOS)
return SidebarNavigation().frame(minWidth: 900, maxWidth: .infinity, minHeight: 500, maxHeight: .infinity)
return SidebarNavigation(identity: identity)
.frame(minWidth: 900, maxWidth: .infinity, minHeight: 500, maxHeight: .infinity)
#else
return TabNavigation(identity: identity)
#endif

View File

@ -6,72 +6,54 @@ import CombineExpectations
@testable import Metatext
class AddIdentityViewModelTests: XCTestCase {
var networkClient: MastodonClient!
var identityDatabase: IdentityDatabase!
var secrets: Secrets!
var cancellables = Set<AnyCancellable>()
override func setUpWithError() throws {
networkClient = MastodonClient(configuration: .stubbing)
identityDatabase = try IdentityDatabase(inMemory: true)
secrets = Secrets(keychain: FakeKeychain())
}
func testAddIdentity() throws {
let sut = AddIdentityViewModel(
networkClient: networkClient,
identityDatabase: identityDatabase,
secrets: secrets,
webAuthenticationSessionType: SuccessfulStubbingWebAuthenticationSession.self)
let environment = AppEnvironment.fresh()
let sut = AddIdentityViewModel(networkClient: MastodonClient.fresh(), environment: environment)
let addedIDRecorder = sut.$addedIdentityID.record()
_ = try wait(for: addedIDRecorder.next(), timeout: 1)
XCTAssertNil(try wait(for: addedIDRecorder.next(), timeout: 1))
sut.urlFieldText = "https://mastodon.social"
sut.goTapped()
let addedIdentityID = try wait(for: addedIDRecorder.next(), timeout: 1)!
let identityRecorder = identityDatabase.identityObservation(id: addedIdentityID).record()
let identityRecorder = environment.identityDatabase.identityObservation(id: addedIdentityID).record()
let addedIdentity = try wait(for: identityRecorder.next(), timeout: 1)!
XCTAssertEqual(addedIdentity.id, addedIdentityID)
XCTAssertEqual(addedIdentity.url, URL(string: "https://mastodon.social")!)
XCTAssertEqual(environment.preferences[.recentIdentityID], addedIdentity.id)
XCTAssertEqual(
try secrets.item(.clientID, forIdentityID: addedIdentityID) as String?,
try environment.secrets.item(.clientID, forIdentityID: addedIdentityID) as String?,
"AUTHORIZATION_CLIENT_ID_STUB_VALUE")
XCTAssertEqual(
try secrets.item(.clientSecret, forIdentityID: addedIdentityID) as String?,
try environment.secrets.item(.clientSecret, forIdentityID: addedIdentityID) as String?,
"AUTHORIZATION_CLIENT_SECRET_STUB_VALUE")
XCTAssertEqual(
try secrets.item(.accessToken, forIdentityID: addedIdentityID) as String?,
try environment.secrets.item(.accessToken, forIdentityID: addedIdentityID) as String?,
"ACCESS_TOKEN_STUB_VALUE")
}
func testAddIdentityWithoutScheme() throws {
let sut = AddIdentityViewModel(
networkClient: networkClient,
identityDatabase: identityDatabase,
secrets: secrets,
webAuthenticationSessionType: SuccessfulStubbingWebAuthenticationSession.self)
let environment = AppEnvironment.fresh()
let sut = AddIdentityViewModel(networkClient: MastodonClient.fresh(), environment: environment)
let addedIDRecorder = sut.$addedIdentityID.record()
_ = try wait(for: addedIDRecorder.next(), timeout: 1)
XCTAssertNil(try wait(for: addedIDRecorder.next(), timeout: 1))
sut.urlFieldText = "mastodon.social"
sut.goTapped()
let addedIdentityID = try wait(for: addedIDRecorder.next(), timeout: 1)!
let identityRecorder = identityDatabase.identityObservation(id: addedIdentityID).record()
let identityRecorder = environment.identityDatabase.identityObservation(id: addedIdentityID).record()
let addedIdentity = try wait(for: identityRecorder.next(), timeout: 1)!
XCTAssertEqual(addedIdentity.url, URL(string: "https://mastodon.social")!)
}
func testInvalidURL() throws {
let sut = AddIdentityViewModel(
networkClient: networkClient,
identityDatabase: identityDatabase,
secrets: secrets,
webAuthenticationSessionType: SuccessfulStubbingWebAuthenticationSession.self)
let recorder = sut.$alertItem.dropFirst().record()
let sut = AddIdentityViewModel(networkClient: MastodonClient.fresh(), environment: .fresh())
let recorder = sut.$alertItem.record()
XCTAssertNil(try wait(for: recorder.next(), timeout: 1))
sut.urlFieldText = "🐘.social"
sut.goTapped()
@ -80,4 +62,17 @@ class AddIdentityViewModelTests: XCTestCase {
XCTAssertEqual((alertItem?.error as? URLError)?.code, URLError.badURL)
}
func testDoesNotAlertCanceledLogin() throws {
let environment = AppEnvironment.fresh(webAuthSessionType: CanceledLoginStubbingWebAuthSession.self)
let sut = AddIdentityViewModel(networkClient: MastodonClient.fresh(), environment: environment)
let recorder = sut.$alertItem.record()
XCTAssertNil(try wait(for: recorder.next(), timeout: 1))
sut.urlFieldText = "https://mastodon.social"
sut.goTapped()
try wait(for: recorder.next().inverted, timeout: 1)
}
}

View File

@ -0,0 +1,24 @@
// Copyright © 2020 Metabolist. All rights reserved.
import XCTest
import Combine
import CombineExpectations
@testable import Metatext
class SceneViewModelTests: XCTestCase {
func testAddIdentity() throws {
let sut = SceneViewModel(networkClient: .fresh(), environment: .fresh())
let identityRecorder = sut.$identity.record()
XCTAssertNil(try wait(for: identityRecorder.next(), timeout: 1))
let addIdentityViewModel = sut.addIdentityViewModel()
addIdentityViewModel.urlFieldText = "https://mastodon.social"
addIdentityViewModel.goTapped()
let identity = try wait(for: identityRecorder.next(), timeout: 1)!
XCTAssertEqual(identity.id, addIdentityViewModel.addedIdentityID)
}
}

View File

@ -3,6 +3,7 @@
import SwiftUI
struct SidebarNavigation: View {
let identity: Identity
@EnvironmentObject var sceneViewModel: SceneViewModel
var sidebar: some View {
@ -40,8 +41,11 @@ private extension SidebarNavigation {
}
}
#if DEBUG
struct SidebarNavigation_Previews: PreviewProvider {
static var previews: some View {
SidebarNavigation()
SidebarNavigation(identity: .development)
.environmentObject(SceneViewModel.development)
}
}
#endif