Refactoring

This commit is contained in:
Justin Mazzocchi 2020-09-07 19:12:38 -07:00
parent 367d79ed2c
commit b4549521cb
No known key found for this signature in database
GPG key ID: E223E6937AAFB01C
40 changed files with 282 additions and 369 deletions

View file

@ -14,7 +14,7 @@ public enum IdentityDatabaseError: Error {
public struct IdentityDatabase {
private let databaseQueue: DatabaseQueue
public init(inMemory: Bool, fixture: IdentityFixture?, keychain: Keychain.Type) throws {
public init(inMemory: Bool, keychain: Keychain.Type) throws {
if inMemory {
databaseQueue = DatabaseQueue()
} else {
@ -29,10 +29,6 @@ public struct IdentityDatabase {
}
try Self.migrate(databaseQueue)
if let fixture = fixture {
try populate(fixture: fixture)
}
}
}
@ -262,22 +258,4 @@ private extension IdentityDatabase {
try migrator.migrate(writer)
}
func populate(fixture: IdentityFixture) throws {
_ = createIdentity(id: fixture.id, url: fixture.instanceURL)
.receive(on: ImmediateScheduler.shared)
.sink { _ in } receiveValue: { _ in }
if let instance = fixture.instance {
_ = updateInstance(instance, forIdentityID: fixture.id)
.receive(on: ImmediateScheduler.shared)
.sink { _ in } receiveValue: { _ in }
}
if let account = fixture.account {
_ = updateAccount(account, forIdentityID: fixture.id)
.receive(on: ImmediateScheduler.shared)
.sink { _ in } receiveValue: { _ in }
}
}
}

View file

@ -6,9 +6,6 @@ import Stubbing
extension AccountEndpoint: Stubbing {
public func data(url: URL) -> Data? {
switch self {
case .verifyCredentials: return try? Data(contentsOf: Bundle.module.url(forResource: "account",
withExtension: "json")!)
}
StubData.account
}
}

View file

@ -6,8 +6,6 @@ import Stubbing
extension InstanceEndpoint: Stubbing {
public func data(url: URL) -> Data? {
switch self {
case .instance: return try? Data(contentsOf: Bundle.module.url(forResource: "instance", withExtension: "json")!)
}
StubData.instance
}
}

View file

@ -5,18 +5,7 @@ import MastodonAPI
import Stubbing
extension PreferencesEndpoint: Stubbing {
public func dataString(url: URL) -> String? {
switch self {
case .preferences:
return """
{
"posting:default:visibility": "public",
"posting:default:sensitive": false,
"posting:default:language": null,
"reading:expand:media": "default",
"reading:expand:spoilers": false
}
"""
}
public func data(url: URL) -> Data? {
StubData.preferences
}
}

View file

@ -0,0 +1,7 @@
{
"posting:default:visibility": "public",
"posting:default:sensitive": false,
"posting:default:language": null,
"reading:expand:media": "default",
"reading:expand:spoilers": false
}

View file

@ -0,0 +1,18 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Foundation
public enum StubData {}
public extension StubData {
// swiftlint:disable force_try
static let account = try! Data(contentsOf: Bundle.module.url(forResource: "account",
withExtension: "json")!)
static let instance = try! Data(contentsOf: Bundle.module.url(forResource: "instance",
withExtension: "json")!)
static let preferences = try! Data(contentsOf: Bundle.module.url(forResource: "preferences",
withExtension: "json")!)
static let timeline = try! Data(contentsOf: Bundle.module.url(forResource: "timeline",
withExtension: "json")!)
// swiftlint:enable force_try
}

View file

@ -6,6 +6,6 @@ import Stubbing
extension TimelinesEndpoint: Stubbing {
public func data(url: URL) -> Data? {
try? Data(contentsOf: Bundle.module.url(forResource: "timeline", withExtension: "json")!)
StubData.timeline
}
}

View file

@ -25,6 +25,7 @@ public struct Secrets {
public extension Secrets {
enum Item: String, CaseIterable {
case instanceURL
case clientID
case clientSecret
case accessToken
@ -101,6 +102,14 @@ public extension Secrets {
}
}
func getInstanceURL() throws -> URL {
try item(.instanceURL)
}
func setInstanceURL(_ instanceURL: URL) throws {
try set(instanceURL, forItem: .instanceURL)
}
func getClientID() throws -> String {
try item(.clientID)
}
@ -219,6 +228,18 @@ extension String: SecretsStorable {
}
}
extension URL: SecretsStorable {
public var dataStoredInSecrets: Data { absoluteString.dataStoredInSecrets }
public static func fromDataStoredInSecrets(_ data: Data) throws -> URL {
guard let url = URL(string: try String.fromDataStoredInSecrets(data)) else {
throw SecretsStorableError.conversionFromDataStoredInSecrets(data)
}
return url
}
}
private struct PushKey {
static let authLength = 16
static let sizeInBits = 256

View file

@ -14,7 +14,7 @@ public struct AppEnvironment {
let userDefaults: UserDefaults
let userNotificationClient: UserNotificationClient
let inMemoryContent: Bool
let identityFixture: IdentityFixture?
let fixtureDatabase: IdentityDatabase?
public init(session: Session,
webAuthSessionType: WebAuthSession.Type,
@ -22,14 +22,14 @@ public struct AppEnvironment {
userDefaults: UserDefaults,
userNotificationClient: UserNotificationClient,
inMemoryContent: Bool,
identityFixture: IdentityFixture?) {
fixtureDatabase: IdentityDatabase?) {
self.session = session
self.webAuthSessionType = webAuthSessionType
self.keychain = keychain
self.userDefaults = userDefaults
self.userNotificationClient = userNotificationClient
self.inMemoryContent = inMemoryContent
self.identityFixture = identityFixture
self.fixtureDatabase = fixtureDatabase
}
}
@ -42,6 +42,6 @@ public extension AppEnvironment {
userDefaults: .standard,
userNotificationClient: .live(userNotificationCenter),
inMemoryContent: false,
identityFixture: nil)
fixtureDatabase: nil)
}
}

View file

@ -1,43 +0,0 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Combine
import DB
import Foundation
public class IdentifiedEnvironment {
@Published public private(set) var identity: Identity
public let appEnvironment: AppEnvironment
public let identityService: IdentityService
public let observationErrors: AnyPublisher<Error, Never>
init(id: UUID, database: IdentityDatabase, environment: AppEnvironment) throws {
appEnvironment = environment
// The scheduling on the observation is immediate so an initial value can be extracted
let sharedObservation = database.identityObservation(id: id).share()
var initialIdentity: Identity?
_ = sharedObservation.first().sink(
receiveCompletion: { _ in },
receiveValue: { initialIdentity = $0 })
guard let identity = initialIdentity else { throw IdentityDatabaseError.identityNotFound }
self.identity = identity
identityService = try IdentityService(id: identity.id,
instanceURL: identity.url,
database: database,
environment: environment)
let observationErrorsSubject = PassthroughSubject<Error, Never>()
self.observationErrors = observationErrorsSubject.eraseToAnyPublisher()
sharedObservation.catch { error -> Empty<Identity, Never> in
observationErrorsSubject.send(error)
return Empty()
}
.assign(to: &$identity)
}
}

View file

@ -15,9 +15,9 @@ public struct AllIdentitiesService {
private let environment: AppEnvironment
public init(environment: AppEnvironment) throws {
self.database = try IdentityDatabase(inMemory: environment.inMemoryContent,
fixture: environment.identityFixture,
keychain: environment.keychain)
self.database = try environment.fixtureDatabase ?? IdentityDatabase(
inMemory: environment.inMemoryContent,
keychain: environment.keychain)
self.environment = environment
mostRecentlyUsedIdentityID = database.mostRecentlyUsedIdentityIDObservation()
@ -28,8 +28,8 @@ public struct AllIdentitiesService {
}
public extension AllIdentitiesService {
func identifiedEnvironment(id: UUID) throws -> IdentifiedEnvironment {
try IdentifiedEnvironment(id: id, database: database, environment: environment)
func identityService(id: UUID) throws -> IdentityService {
try IdentityService(id: id, database: database, environment: environment)
}
func createIdentity(id: UUID, instanceURL: URL) -> AnyPublisher<Never, Error> {
@ -42,6 +42,7 @@ public extension AllIdentitiesService {
return authenticationService.authorizeApp(instanceURL: instanceURL)
.tryMap { appAuthorization -> (URL, AppAuthorization) in
try secrets.setInstanceURL(instanceURL)
try secrets.setClientID(appAuthorization.clientId)
try secrets.setClientSecret(appAuthorization.clientSecret)
@ -81,7 +82,7 @@ public extension AllIdentitiesService {
database.identitiesWithOutdatedDeviceTokens(deviceToken: deviceToken)
.tryMap { identities -> [AnyPublisher<Never, Never>] in
try identities.map {
try IdentityService(id: $0.id, instanceURL: $0.url, database: database, environment: environment)
try IdentityService(id: $0.id, database: database, environment: environment)
.createPushSubscription(deviceToken: deviceToken, alerts: $0.pushSubscriptionAlerts)
.catch { _ in Empty() } // don't want to disrupt pipeline
.eraseToAnyPublisher()

View file

@ -17,7 +17,7 @@ public struct IdentityService {
private let secrets: Secrets
private let observationErrorsInput = PassthroughSubject<Error, Never>()
init(id: UUID, instanceURL: URL, database: IdentityDatabase, environment: AppEnvironment) throws {
init(id: UUID, database: IdentityDatabase, environment: AppEnvironment) throws {
identityID = id
identityDatabase = database
self.environment = environment
@ -25,7 +25,7 @@ public struct IdentityService {
identityID: id,
keychain: environment.keychain)
mastodonAPIClient = MastodonAPIClient(session: environment.session)
mastodonAPIClient.instanceURL = instanceURL
mastodonAPIClient.instanceURL = try secrets.getInstanceURL()
mastodonAPIClient.accessToken = try? secrets.getAccessToken()
contentDatabase = try ContentDatabase(identityID: id,
@ -86,6 +86,10 @@ public extension IdentityService {
.eraseToAnyPublisher()
}
func observation() -> AnyPublisher<Identity, Error> {
identityDatabase.identityObservation(id: identityID)
}
func listsObservation() -> AnyPublisher<[Timeline], Error> {
contentDatabase.listsObservation()
}

View file

@ -3,12 +3,19 @@
import DB
import Foundation
import HTTP
import Keychain
import MockKeychain
import ServiceLayer
import Stubbing
public extension AppEnvironment {
static func mock(identityFixture: IdentityFixture? = nil) -> Self {
static func mock(session: Session = Session(configuration: .stubbing),
webAuthSessionType: WebAuthSession.Type = SuccessfulMockWebAuthSession.self,
keychain: Keychain.Type = MockKeychain.self,
userDefaults: UserDefaults = MockUserDefaults(),
userNotificationClient: UserNotificationClient = .mock,
inMemoryContent: Bool = true,
fixtureDatabase: IdentityDatabase? = nil) -> Self {
AppEnvironment(
session: Session(configuration: .stubbing),
webAuthSessionType: SuccessfulMockWebAuthSession.self,
@ -16,6 +23,6 @@ public extension AppEnvironment {
userDefaults: MockUserDefaults(),
userNotificationClient: .mock,
inMemoryContent: true,
identityFixture: identityFixture)
fixtureDatabase: fixtureDatabase)
}
}

View file

@ -0,0 +1,53 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Combine
import DB
import Foundation
import Mastodon
import MastodonAPIStubs
import MockKeychain
import Secrets
import ServiceLayer
import ServiceLayerMocks
import ViewModels
// swiftlint:disable force_try
let db: IdentityDatabase = {
let id = UUID()
let url = URL(string: "https://mastodon.social")!
let db = try! IdentityDatabase(inMemory: true, keychain: MockKeychain.self)
let decoder = MastodonDecoder()
let instance = try! decoder.decode(Instance.self, from: StubData.instance)
let account = try! decoder.decode(Account.self, from: StubData.account)
let secrets = Secrets(identityID: id, keychain: MockKeychain.self)
try! secrets.setInstanceURL(url)
try! secrets.setAccessToken(UUID().uuidString)
_ = db.createIdentity(id: id, url: url)
.receive(on: ImmediateScheduler.shared)
.sink { _ in } receiveValue: { _ in }
_ = db.updateInstance(instance, forIdentityID: id)
.receive(on: ImmediateScheduler.shared)
.sink { _ in } receiveValue: { _ in }
_ = db.updateAccount(account, forIdentityID: id)
.receive(on: ImmediateScheduler.shared)
.sink { _ in } receiveValue: { _ in }
return db
}()
let environment = AppEnvironment.mock(fixtureDatabase: db)
public extension RootViewModel {
static let preview = try! RootViewModel(environment: environment) { Empty().eraseToAnyPublisher() }
}
public extension Identification {
static let preview = RootViewModel.preview.identification!
}
// swiftlint:enable force_try

View file

@ -1,104 +0,0 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Combine
import Foundation
import HTTP
import Mastodon
import MastodonAPI
import MastodonAPIStubs
import ServiceLayer
import ServiceLayerMocks
import ViewModels
private let decoder = MastodonDecoder()
private let devInstanceURL = URL(string: "https://mastodon.social")!
// swiftlint:disable force_try
extension AppEnvironment {
public static let mockAuthenticated: Self = .mock(
identityFixture: .init(
id: UUID(uuidString: "E621E1F8-C36C-495A-93FC-0C247A3E6E5F")!,
instanceURL: devInstanceURL,
instance: try! decoder.decode(Instance.self,
from: InstanceEndpoint.instance.data(url: devInstanceURL)!),
account: try! decoder.decode(Account.self,
from: AccountEndpoint.verifyCredentials.data(url: devInstanceURL)!)))
}
extension RootViewModel {
public static func mock(environment: AppEnvironment = .mockAuthenticated) -> Self {
try! Self(environment: environment,
registerForRemoteNotifications: { Empty().eraseToAnyPublisher() })
}
}
// swiftlint:enable force_try
extension AddIdentityViewModel {
public static func mock(environment: AppEnvironment = .mockAuthenticated) -> AddIdentityViewModel {
RootViewModel.mock(environment: environment).addIdentityViewModel()
}
}
extension TabNavigationViewModel {
public static func mock(environment: AppEnvironment = .mockAuthenticated) -> TabNavigationViewModel {
RootViewModel.mock(environment: environment).tabNavigationViewModel!
}
}
extension SecondaryNavigationViewModel {
public static func mock(environment: AppEnvironment = .mockAuthenticated) -> SecondaryNavigationViewModel {
TabNavigationViewModel.mock(environment: environment)
.secondaryNavigationViewModel()
}
}
extension IdentitiesViewModel {
public static func mock(environment: AppEnvironment = .mockAuthenticated) -> IdentitiesViewModel {
SecondaryNavigationViewModel.mock(environment: environment).identitiesViewModel()
}
}
extension ListsViewModel {
public static func mock(environment: AppEnvironment = .mockAuthenticated) -> ListsViewModel {
SecondaryNavigationViewModel.mock(environment: environment).listsViewModel()
}
}
extension PreferencesViewModel {
public static func mock(environment: AppEnvironment = .mockAuthenticated) -> PreferencesViewModel {
SecondaryNavigationViewModel.mock(environment: environment).preferencesViewModel()
}
}
extension PostingReadingPreferencesViewModel {
public static func mock(environment: AppEnvironment = .mockAuthenticated) -> PostingReadingPreferencesViewModel {
PreferencesViewModel.mock(environment: environment)
.postingReadingPreferencesViewModel()
}
}
extension NotificationTypesPreferencesViewModel {
public static func mock(
environment: AppEnvironment = .mockAuthenticated) -> NotificationTypesPreferencesViewModel {
PreferencesViewModel.mock(environment: environment)
.notificationTypesPreferencesViewModel()
}
}
extension FiltersViewModel {
public static func mock(environment: AppEnvironment = .mockAuthenticated) -> FiltersViewModel {
PreferencesViewModel.mock(environment: environment).filtersViewModel()
}
}
extension EditFilterViewModel {
public static func mock(environment: AppEnvironment = .mockAuthenticated) -> EditFilterViewModel {
FiltersViewModel.mock(environment: environment).editFilterViewModel(filter: .new)
}
}
extension StatusListViewModel {
public static func mock(environment: AppEnvironment = .mockAuthenticated) -> StatusListViewModel {
TabNavigationViewModel.mock(environment: environment).viewModel(timeline: .home)
}
}

View file

@ -15,13 +15,13 @@ public class EditFilterViewModel: ObservableObject {
didSet { filter.expiresAt = date }
}
private let environment: IdentifiedEnvironment
private let identification: Identification
private let saveCompletedInput = PassthroughSubject<Void, Never>()
private var cancellables = Set<AnyCancellable>()
init(filter: Filter, environment: IdentifiedEnvironment) {
public init(filter: Filter, identification: Identification) {
self.filter = filter
self.environment = environment
self.identification = identification
date = filter.expiresAt ?? Date()
saveCompleted = saveCompletedInput.eraseToAnyPublisher()
}
@ -41,7 +41,7 @@ public extension EditFilterViewModel {
}
func save() {
(isNew ? environment.identityService.createFilter(filter) : environment.identityService.updateFilter(filter))
(isNew ? identification.service.createFilter(filter) : identification.service.updateFilter(filter))
.assignErrorsToAlertItem(to: \.alertItem, on: self)
.handleEvents(
receiveSubscription: { [weak self] _ in self?.saving = true },

View file

@ -0,0 +1,41 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Combine
import Foundation
import ServiceLayer
enum IdentificationError: Error {
case initialIdentityValueAbsent
}
public final class Identification: ObservableObject {
@Published private(set) var identity: Identity
let service: IdentityService
let observationErrors: AnyPublisher<Error, Never>
init(service: IdentityService) throws {
self.service = service
// The scheduling on the observation is immediate so an initial value can be extracted
let sharedObservation = service.observation().share()
var initialIdentity: Identity?
_ = sharedObservation.first().sink(
receiveCompletion: { _ in },
receiveValue: { initialIdentity = $0 })
guard let identity = initialIdentity else { throw IdentificationError.initialIdentityValueAbsent }
self.identity = identity
let observationErrorsSubject = PassthroughSubject<Error, Never>()
observationErrors = observationErrorsSubject.eraseToAnyPublisher()
sharedObservation.catch { error -> Empty<Identity, Never> in
observationErrorsSubject.send(error)
return Empty()
}
.assign(to: &$identity)
}
}

View file

@ -10,19 +10,19 @@ public class FiltersViewModel: ObservableObject {
@Published public var expiredFilters = [Filter]()
@Published public var alertItem: AlertItem?
private let environment: IdentifiedEnvironment
private let identification: Identification
private var cancellables = Set<AnyCancellable>()
init(environment: IdentifiedEnvironment) {
self.environment = environment
public init(identification: Identification) {
self.identification = identification
let now = Date()
environment.identityService.activeFiltersObservation(date: now)
identification.service.activeFiltersObservation(date: now)
.assignErrorsToAlertItem(to: \.alertItem, on: self)
.assign(to: &$activeFilters)
environment.identityService.expiredFiltersObservation(date: now)
identification.service.expiredFiltersObservation(date: now)
.assignErrorsToAlertItem(to: \.alertItem, on: self)
.assign(to: &$expiredFilters)
}
@ -30,20 +30,16 @@ public class FiltersViewModel: ObservableObject {
public extension FiltersViewModel {
func refreshFilters() {
environment.identityService.refreshFilters()
identification.service.refreshFilters()
.assignErrorsToAlertItem(to: \.alertItem, on: self)
.sink { _ in }
.store(in: &cancellables)
}
func delete(filter: Filter) {
environment.identityService.deleteFilter(id: filter.id)
identification.service.deleteFilter(id: filter.id)
.assignErrorsToAlertItem(to: \.alertItem, on: self)
.sink { _ in }
.store(in: &cancellables)
}
func editFilterViewModel(filter: Filter) -> EditFilterViewModel {
EditFilterViewModel(filter: filter, environment: environment)
}
}

View file

@ -9,14 +9,14 @@ public class IdentitiesViewModel: ObservableObject {
@Published public var identities = [Identity]()
@Published public var alertItem: AlertItem?
private let environment: IdentifiedEnvironment
private let identification: Identification
private var cancellables = Set<AnyCancellable>()
init(environment: IdentifiedEnvironment) {
self.environment = environment
currentIdentityID = environment.identity.id
public init(identification: Identification) {
self.identification = identification
currentIdentityID = identification.identity.id
environment.identityService.identitiesObservation()
identification.service.identitiesObservation()
.assignErrorsToAlertItem(to: \.alertItem, on: self)
.assign(to: &$identities)
}

View file

@ -10,13 +10,13 @@ public class ListsViewModel: ObservableObject {
@Published public private(set) var creatingList = false
@Published public var alertItem: AlertItem?
private let environment: IdentifiedEnvironment
private let identification: Identification
private var cancellables = Set<AnyCancellable>()
init(environment: IdentifiedEnvironment) {
self.environment = environment
public init(identification: Identification) {
self.identification = identification
environment.identityService.listsObservation()
identification.service.listsObservation()
.map {
$0.compactMap {
guard case let .list(list) = $0 else { return nil }
@ -31,14 +31,14 @@ public class ListsViewModel: ObservableObject {
public extension ListsViewModel {
func refreshLists() {
environment.identityService.refreshLists()
identification.service.refreshLists()
.assignErrorsToAlertItem(to: \.alertItem, on: self)
.sink { _ in }
.store(in: &cancellables)
}
func createList(title: String) {
environment.identityService.createList(title: title)
identification.service.createList(title: title)
.assignErrorsToAlertItem(to: \.alertItem, on: self)
.handleEvents(
receiveSubscription: { [weak self] _ in self?.creatingList = true },
@ -48,7 +48,7 @@ public extension ListsViewModel {
}
func delete(list: MastodonList) {
environment.identityService.deleteList(id: list.id)
identification.service.deleteList(id: list.id)
.assignErrorsToAlertItem(to: \.alertItem, on: self)
.sink { _ in }
.store(in: &cancellables)

View file

@ -9,14 +9,14 @@ public class NotificationTypesPreferencesViewModel: ObservableObject {
@Published public var pushSubscriptionAlerts: PushSubscription.Alerts
@Published public var alertItem: AlertItem?
private let environment: IdentifiedEnvironment
private let identification: Identification
private var cancellables = Set<AnyCancellable>()
init(environment: IdentifiedEnvironment) {
self.environment = environment
pushSubscriptionAlerts = environment.identity.pushSubscriptionAlerts
public init(identification: Identification) {
self.identification = identification
pushSubscriptionAlerts = identification.identity.pushSubscriptionAlerts
environment.$identity
identification.$identity
.map(\.pushSubscriptionAlerts)
.dropFirst()
.removeDuplicates()
@ -32,14 +32,14 @@ public class NotificationTypesPreferencesViewModel: ObservableObject {
private extension NotificationTypesPreferencesViewModel {
func update(alerts: PushSubscription.Alerts) {
guard alerts != environment.identity.pushSubscriptionAlerts else { return }
guard alerts != identification.identity.pushSubscriptionAlerts else { return }
environment.identityService.updatePushSubscription(alerts: alerts)
identification.service.updatePushSubscription(alerts: alerts)
.sink { [weak self] in
guard let self = self, case let .failure(error) = $0 else { return }
self.alertItem = AlertItem(error: error)
self.pushSubscriptionAlerts = self.environment.identity.pushSubscriptionAlerts
self.pushSubscriptionAlerts = self.identification.identity.pushSubscriptionAlerts
} receiveValue: { _ in }
.store(in: &cancellables)
}

View file

@ -8,14 +8,14 @@ public class PostingReadingPreferencesViewModel: ObservableObject {
@Published public var preferences: Identity.Preferences
@Published public var alertItem: AlertItem?
private let environment: IdentifiedEnvironment
private let identification: Identification
private var cancellables = Set<AnyCancellable>()
init(environment: IdentifiedEnvironment) {
self.environment = environment
preferences = environment.identity.preferences
public init(identification: Identification) {
self.identification = identification
preferences = identification.identity.preferences
environment.$identity
identification.$identity
.map(\.preferences)
.dropFirst()
.removeDuplicates()
@ -23,7 +23,7 @@ public class PostingReadingPreferencesViewModel: ObservableObject {
$preferences
.dropFirst()
.flatMap(environment.identityService.updatePreferences)
.flatMap(identification.service.updatePreferences)
.assignErrorsToAlertItem(to: \.alertItem, on: self)
.sink { _ in }
.store(in: &cancellables)

View file

@ -7,26 +7,12 @@ public class PreferencesViewModel: ObservableObject {
public let handle: String
public let shouldShowNotificationTypePreferences: Bool
private let environment: IdentifiedEnvironment
private let identification: Identification
init(environment: IdentifiedEnvironment) {
self.environment = environment
handle = environment.identity.handle
public init(identification: Identification) {
self.identification = identification
handle = identification.identity.handle
shouldShowNotificationTypePreferences = environment.identity.lastRegisteredDeviceToken != nil
}
}
public extension PreferencesViewModel {
func postingReadingPreferencesViewModel() -> PostingReadingPreferencesViewModel {
PostingReadingPreferencesViewModel(environment: environment)
}
func notificationTypesPreferencesViewModel() -> NotificationTypesPreferencesViewModel {
NotificationTypesPreferencesViewModel(environment: environment)
}
func filtersViewModel() -> FiltersViewModel {
FiltersViewModel(environment: environment)
shouldShowNotificationTypePreferences = identification.identity.lastRegisteredDeviceToken != nil
}
}

View file

@ -5,10 +5,9 @@ import Foundation
import ServiceLayer
public final class RootViewModel: ObservableObject {
@Published public private(set) var tabNavigationViewModel: TabNavigationViewModel?
@Published public private(set) var identification: Identification?
@Published private var mostRecentlyUsedIdentityID: UUID?
private let environment: AppEnvironment
private let allIdentitiesService: AllIdentitiesService
private let userNotificationService: UserNotificationService
private let registerForRemoteNotifications: () -> AnyPublisher<Data, Error>
@ -16,7 +15,6 @@ public final class RootViewModel: ObservableObject {
public init(environment: AppEnvironment,
registerForRemoteNotifications: @escaping () -> AnyPublisher<Data, Error>) throws {
self.environment = environment
allIdentitiesService = try AllIdentitiesService(environment: environment)
userNotificationService = UserNotificationService(environment: environment)
self.registerForRemoteNotifications = registerForRemoteNotifications
@ -38,39 +36,38 @@ public final class RootViewModel: ObservableObject {
public extension RootViewModel {
func newIdentitySelected(id: UUID?) {
guard let id = id else {
tabNavigationViewModel = nil
identification = nil
return
}
let identifiedEnvironment: IdentifiedEnvironment
let identification: Identification
do {
identifiedEnvironment = try allIdentitiesService.identifiedEnvironment(id: id)
identification = try Identification(service: allIdentitiesService.identityService(id: id))
self.identification = identification
} catch {
return
}
identifiedEnvironment.observationErrors
identification.observationErrors
.receive(on: RunLoop.main)
.map { [weak self] _ in self?.mostRecentlyUsedIdentityID }
.sink { [weak self] in self?.newIdentitySelected(id: $0) }
.store(in: &cancellables)
identifiedEnvironment.identityService.updateLastUse()
identification.service.updateLastUse()
.sink { _ in } receiveValue: { _ in }
.store(in: &cancellables)
userNotificationService.isAuthorized()
.filter { $0 }
.zip(registerForRemoteNotifications())
.filter { identifiedEnvironment.identity.lastRegisteredDeviceToken != $1 }
.map { ($1, identifiedEnvironment.identity.pushSubscriptionAlerts) }
.flatMap(identifiedEnvironment.identityService.createPushSubscription(deviceToken:alerts:))
.filter { identification.identity.lastRegisteredDeviceToken != $1 }
.map { ($1, identification.identity.pushSubscriptionAlerts) }
.flatMap(identification.service.createPushSubscription(deviceToken:alerts:))
.sink { _ in } receiveValue: { _ in }
.store(in: &cancellables)
tabNavigationViewModel = TabNavigationViewModel(environment: identifiedEnvironment)
}
func deleteIdentity(_ identity: Identity) {

View file

@ -1,30 +0,0 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Foundation
import ServiceLayer
public class SecondaryNavigationViewModel: ObservableObject {
@Published public private(set) var identity: Identity
private let environment: IdentifiedEnvironment
init(environment: IdentifiedEnvironment) {
self.environment = environment
identity = environment.identity
environment.$identity.dropFirst().assign(to: &$identity)
}
}
public extension SecondaryNavigationViewModel {
func identitiesViewModel() -> IdentitiesViewModel {
IdentitiesViewModel(environment: environment)
}
func listsViewModel() -> ListsViewModel {
ListsViewModel(environment: environment)
}
func preferencesViewModel() -> PreferencesViewModel {
PreferencesViewModel(environment: environment)
}
}

View file

@ -14,19 +14,19 @@ public class TabNavigationViewModel: ObservableObject {
@Published public var alertItem: AlertItem?
public var selectedTab: Tab? = .timelines
private let environment: IdentifiedEnvironment
private let identification: Identification
private var cancellables = Set<AnyCancellable>()
init(environment: IdentifiedEnvironment) {
self.environment = environment
identity = environment.identity
environment.$identity.dropFirst().assign(to: &$identity)
public init(identification: Identification) {
self.identification = identification
identity = identification.identity
identification.$identity.dropFirst().assign(to: &$identity)
environment.identityService.recentIdentitiesObservation()
identification.service.recentIdentitiesObservation()
.assignErrorsToAlertItem(to: \.alertItem, on: self)
.assign(to: &$recentIdentities)
environment.identityService.listsObservation()
identification.service.listsObservation()
.map { Timeline.nonLists + $0 }
.assignErrorsToAlertItem(to: \.alertItem, on: self)
.assign(to: &$timelinesAndLists)
@ -54,42 +54,38 @@ public extension TabNavigationViewModel {
}
func refreshIdentity() {
if environment.identityService.isAuthorized {
environment.identityService.verifyCredentials()
if identification.service.isAuthorized {
identification.service.verifyCredentials()
.assignErrorsToAlertItem(to: \.alertItem, on: self)
.sink { _ in }
.store(in: &cancellables)
environment.identityService.refreshLists()
identification.service.refreshLists()
.assignErrorsToAlertItem(to: \.alertItem, on: self)
.sink { _ in }
.store(in: &cancellables)
environment.identityService.refreshFilters()
identification.service.refreshFilters()
.assignErrorsToAlertItem(to: \.alertItem, on: self)
.sink { _ in }
.store(in: &cancellables)
if identity.preferences.useServerPostingReadingPreferences {
environment.identityService.refreshServerPreferences()
identification.service.refreshServerPreferences()
.assignErrorsToAlertItem(to: \.alertItem, on: self)
.sink { _ in }
.store(in: &cancellables)
}
}
environment.identityService.refreshInstance()
identification.service.refreshInstance()
.assignErrorsToAlertItem(to: \.alertItem, on: self)
.sink { _ in }
.store(in: &cancellables)
}
func secondaryNavigationViewModel() -> SecondaryNavigationViewModel {
SecondaryNavigationViewModel(environment: environment)
}
func viewModel(timeline: Timeline) -> StatusListViewModel {
StatusListViewModel(statusListService: environment.identityService.service(timeline: timeline))
StatusListViewModel(statusListService: identification.service.service(timeline: timeline))
}
}

View file

@ -46,15 +46,8 @@ class AddIdentityViewModelTests: XCTestCase {
}
func testDoesNotAlertCanceledLogin() throws {
let environment = AppEnvironment(
session: Session(configuration: .stubbing),
webAuthSessionType: CanceledLoginMockWebAuthSession.self,
keychain: MockKeychain.self,
userDefaults: MockUserDefaults(),
userNotificationClient: .mock,
inMemoryContent: true,
identityFixture: nil)
let allIdentitiesService = try AllIdentitiesService(environment: environment)
let allIdentitiesService = try AllIdentitiesService(
environment: .mock(webAuthSessionType: CanceledLoginMockWebAuthSession.self))
let sut = AddIdentityViewModel(allIdentitiesService: allIdentitiesService)
let recorder = sut.$alertItem.record()

View file

@ -14,7 +14,7 @@ class RootViewModelTests: XCTestCase {
let sut = try RootViewModel(
environment: .mock(),
registerForRemoteNotifications: { Empty().setFailureType(to: Error.self).eraseToAnyPublisher() })
let recorder = sut.$tabNavigationViewModel.record()
let recorder = sut.$identification.record()
XCTAssertNil(try wait(for: recorder.next(), timeout: 1))

View file

@ -46,7 +46,7 @@ import PreviewViewModels
struct AddAccountView_Previews: PreviewProvider {
static var previews: some View {
AddIdentityView(viewModel: .mock())
AddIdentityView(viewModel: RootViewModel.preview.addIdentityViewModel())
}
}
#endif

View file

@ -101,7 +101,7 @@ import PreviewViewModels
struct EditFilterView_Previews: PreviewProvider {
static var previews: some View {
EditFilterView(viewModel: .mock())
EditFilterView(viewModel: .init(filter: .new, identification: .preview))
}
}
#endif

View file

@ -6,12 +6,13 @@ import ViewModels
struct FiltersView: View {
@StateObject var viewModel: FiltersViewModel
@EnvironmentObject var identification: Identification
var body: some View {
Form {
Section {
NavigationLink(destination: EditFilterView(
viewModel: viewModel.editFilterViewModel(filter: .new))) {
viewModel: .init(filter: .new, identification: identification))) {
Label("add", systemImage: "plus.circle")
}
}
@ -36,7 +37,7 @@ private extension FiltersView {
Section(header: Text(title)) {
ForEach(filters) { filter in
NavigationLink(destination: EditFilterView(
viewModel: viewModel.editFilterViewModel(filter: filter))) {
viewModel: .init(filter: filter, identification: identification))) {
HStack {
Text(filter.phrase)
Spacer()
@ -60,7 +61,7 @@ import PreviewViewModels
struct FiltersView_Previews: PreviewProvider {
static var previews: some View {
FiltersView(viewModel: .mock())
FiltersView(viewModel: .init(identification: .preview))
}
}
#endif

View file

@ -72,8 +72,8 @@ import PreviewViewModels
struct IdentitiesView_Previews: PreviewProvider {
static var previews: some View {
IdentitiesView(viewModel: .mock())
.environmentObject(RootViewModel.mock())
IdentitiesView(viewModel: .init(identification: .preview))
.environmentObject(RootViewModel.preview)
}
}
#endif

View file

@ -60,8 +60,8 @@ import PreviewViewModels
struct ListsView_Previews: PreviewProvider {
static var previews: some View {
ListsView(viewModel: .mock())
.environmentObject(TabNavigationViewModel.mock())
ListsView(viewModel: .init(identification: .preview))
.environmentObject(TabNavigationViewModel(identification: .preview))
}
}
#endif

View file

@ -29,7 +29,7 @@ import PreviewViewModels
struct NotificationTypesPreferencesView_Previews: PreviewProvider {
static var previews: some View {
NotificationTypesPreferencesView(viewModel: .mock())
NotificationTypesPreferencesView(viewModel: .init(identification: .preview))
}
}
#endif

View file

@ -55,7 +55,7 @@ import PreviewViewModels
struct PostingReadingPreferencesViewView_Previews: PreviewProvider {
static var previews: some View {
PostingReadingPreferencesView(viewModel: .mock())
PostingReadingPreferencesView(viewModel: .init(identification: .preview))
}
}
#endif

View file

@ -5,20 +5,21 @@ import ViewModels
struct PreferencesView: View {
@StateObject var viewModel: PreferencesViewModel
@EnvironmentObject var identification: Identification
var body: some View {
Form {
Section(header: Text(viewModel.handle)) {
NavigationLink("preferences.posting-reading",
destination: PostingReadingPreferencesView(
viewModel: viewModel.postingReadingPreferencesViewModel()))
viewModel: .init(identification: identification)))
NavigationLink("preferences.filters",
destination: FiltersView(
viewModel: viewModel.filtersViewModel()))
viewModel: .init(identification: identification)))
if viewModel.shouldShowNotificationTypePreferences {
NavigationLink("preferences.notification-types",
destination: NotificationTypesPreferencesView(
viewModel: viewModel.notificationTypesPreferencesViewModel()))
viewModel: .init(identification: identification)))
}
}
}
@ -31,7 +32,7 @@ import PreviewViewModels
struct PreferencesView_Previews: PreviewProvider {
static var previews: some View {
PreferencesView(viewModel: .mock())
PreferencesView(viewModel: .init(identification: .preview))
}
}
#endif

View file

@ -7,9 +7,11 @@ struct RootView: View {
@StateObject var viewModel: RootViewModel
var body: some View {
if let tabNavigationViewModel = viewModel.tabNavigationViewModel {
TabNavigationView(viewModel: tabNavigationViewModel)
if let identification = viewModel.identification {
TabNavigationView()
.id(UUID())
.environmentObject(identification)
.environmentObject(TabNavigationViewModel(identification: identification))
.environmentObject(viewModel)
.transition(.opacity)
} else {
@ -21,11 +23,12 @@ struct RootView: View {
}
#if DEBUG
import Combine
import PreviewViewModels
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
RootView(viewModel: .mock())
RootView(viewModel: .preview)
}
}
#endif

View file

@ -5,7 +5,8 @@ import SwiftUI
import ViewModels
struct SecondaryNavigationView: View {
@StateObject var viewModel: SecondaryNavigationViewModel
@EnvironmentObject var identification: Identification
@EnvironmentObject var tabNavigationViewModel: TabNavigationViewModel
@Environment(\.presentationMode) var presentationMode
@Environment(\.displayScale) var displayScale: CGFloat
@ -14,19 +15,19 @@ struct SecondaryNavigationView: View {
Form {
Section {
NavigationLink(
destination: IdentitiesView(viewModel: viewModel.identitiesViewModel()),
destination: IdentitiesView(viewModel: .init(identification: identification)),
label: {
HStack {
KFImage(viewModel.identity.image,
KFImage(tabNavigationViewModel.identity.image,
options: .downsampled(dimension: 50, scaleFactor: displayScale))
VStack(alignment: .leading) {
if let account = viewModel.identity.account {
if let account = tabNavigationViewModel.identity.account {
CustomEmojiText(
text: account.displayName,
emoji: account.emojis,
textStyle: .headline)
}
Text(viewModel.identity.handle)
Text(tabNavigationViewModel.identity.handle)
.font(.subheadline)
.foregroundColor(.secondary)
.lineLimit(1)
@ -40,7 +41,7 @@ struct SecondaryNavigationView: View {
})
}
Section {
NavigationLink(destination: ListsView(viewModel: viewModel.listsViewModel())) {
NavigationLink(destination: ListsView(viewModel: .init(identification: identification))) {
Label("secondary-navigation.lists", systemImage: "scroll")
}
}
@ -48,7 +49,7 @@ struct SecondaryNavigationView: View {
NavigationLink(
"secondary-navigation.preferences",
destination: PreferencesView(
viewModel: viewModel.preferencesViewModel()))
viewModel: .init(identification: identification)))
}
}
.navigationBarTitleDisplayMode(.inline)
@ -71,9 +72,9 @@ import PreviewViewModels
struct SecondaryNavigationView_Previews: PreviewProvider {
static var previews: some View {
SecondaryNavigationView(viewModel: .mock())
.environmentObject(RootViewModel.mock())
.environmentObject(TabNavigationViewModel.mock())
SecondaryNavigationView()
.environmentObject(Identification.preview)
.environmentObject(TabNavigationViewModel(identification: .preview))
}
}
#endif

View file

@ -20,7 +20,7 @@ import PreviewViewModels
struct StatusListView_Previews: PreviewProvider {
static var previews: some View {
StatusListView(viewModel: .mock())
StatusListView(viewModel: TabNavigationViewModel(identification: .preview).viewModel(timeline: .home))
}
}
#endif

View file

@ -6,7 +6,7 @@ import SwiftUI
import ViewModels
struct TabNavigationView: View {
@ObservedObject var viewModel: TabNavigationViewModel
@EnvironmentObject var viewModel: TabNavigationViewModel
@EnvironmentObject var rootViewModel: RootViewModel
@Environment(\.displayScale) var displayScale: CGFloat
@ -25,7 +25,7 @@ struct TabNavigationView: View {
}
}
.sheet(isPresented: $viewModel.presentingSecondaryNavigation) {
SecondaryNavigationView(viewModel: viewModel.secondaryNavigationViewModel())
SecondaryNavigationView()
.environmentObject(viewModel)
}
.alertItem($viewModel.alertItem)
@ -145,8 +145,10 @@ import PreviewViewModels
struct TabNavigation_Previews: PreviewProvider {
static var previews: some View {
TabNavigationView(viewModel: .mock())
.environmentObject(RootViewModel.mock())
TabNavigationView()
.environmentObject(Identification.preview)
.environmentObject(TabNavigationViewModel(identification: .preview))
.environmentObject(RootViewModel.preview)
}
}
#endif