diff --git a/Development Assets/DevelopmentModels.swift b/Development Assets/DevelopmentModels.swift index 93f12d8..1381ee6 100644 --- a/Development Assets/DevelopmentModels.swift +++ b/Development Assets/DevelopmentModels.swift @@ -123,11 +123,15 @@ extension RootViewModel { } extension MainNavigationViewModel { - static let development = RootViewModel.development.mainNavigationViewModel(identityID: devIdentityID)! + static let development = try! MainNavigationViewModel(identityID: devIdentityID, environment: .development) } extension SettingsViewModel { static let development = MainNavigationViewModel.development.settingsViewModel() } +extension IdentitiesViewModel { + static let development = SettingsViewModel.development.identitiesViewModel() +} + // swiftlint:enable force_try diff --git a/Metatext.xcodeproj/project.pbxproj b/Metatext.xcodeproj/project.pbxproj index 7ae1cbb..49f1baf 100644 --- a/Metatext.xcodeproj/project.pbxproj +++ b/Metatext.xcodeproj/project.pbxproj @@ -30,8 +30,6 @@ D052BBD224D750CB00A80A7A /* AppEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D052BBCC24D750A100A80A7A /* AppEnvironment.swift */; }; D052BBE024D805E300A80A7A /* MainNavigationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D052BBDF24D805E300A80A7A /* MainNavigationViewModel.swift */; }; D052BBE124D805E300A80A7A /* MainNavigationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D052BBDF24D805E300A80A7A /* MainNavigationViewModel.swift */; }; - D052BBE424D81C4700A80A7A /* CurrentValuePublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = D052BBE324D81C4700A80A7A /* CurrentValuePublisher.swift */; }; - D052BBE524D81C4700A80A7A /* CurrentValuePublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = D052BBE324D81C4700A80A7A /* CurrentValuePublisher.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 */; }; @@ -65,6 +63,10 @@ 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 */; }; + D06BAB4E24D942BD0081B8FD /* IdentitiesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D06BAB4D24D942BC0081B8FD /* IdentitiesViewModel.swift */; }; + D06BAB4F24D942BD0081B8FD /* IdentitiesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D06BAB4D24D942BC0081B8FD /* IdentitiesViewModel.swift */; }; + D06BAB5124D942CF0081B8FD /* IdentitiesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D06BAB5024D942CF0081B8FD /* IdentitiesView.swift */; }; + D06BAB5224D942CF0081B8FD /* IdentitiesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D06BAB5024D942CF0081B8FD /* IdentitiesView.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 */; }; @@ -73,8 +75,6 @@ D081A40624D0F1A8001B016E /* String+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D081A40424D0F1A8001B016E /* String+Extensions.swift */; }; D0B23F0D24D210E90066F411 /* NSError+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B23F0C24D210E90066F411 /* NSError+Extensions.swift */; }; D0B23F0E24D210E90066F411 /* NSError+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B23F0C24D210E90066F411 /* NSError+Extensions.swift */; }; - D0B93B3024D55098007AF646 /* Screen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B93B2F24D55098007AF646 /* Screen.swift */; }; - D0B93B3124D55098007AF646 /* Screen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B93B2F24D55098007AF646 /* Screen.swift */; }; D0BEC93824C9632800E864C4 /* RootViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEC93724C9632800E864C4 /* RootViewModel.swift */; }; D0BEC93924C9632800E864C4 /* RootViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEC93724C9632800E864C4 /* RootViewModel.swift */; }; D0BEC93B24C96FD500E864C4 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEC93A24C96FD500E864C4 /* RootView.swift */; }; @@ -166,7 +166,6 @@ D052BBCC24D750A100A80A7A /* AppEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppEnvironment.swift; sourceTree = ""; }; D052BBCE24D750C000A80A7A /* Preferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Preferences.swift; sourceTree = ""; }; D052BBDF24D805E300A80A7A /* MainNavigationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainNavigationViewModel.swift; sourceTree = ""; }; - D052BBE324D81C4700A80A7A /* CurrentValuePublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentValuePublisher.swift; sourceTree = ""; }; D065F53A24D3B33A00741304 /* View+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Extensions.swift"; sourceTree = ""; }; D065F53D24D3D20300741304 /* InstanceEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceEndpoint.swift; sourceTree = ""; }; D0666A2124C677B400F3F04B /* Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = Tests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -183,11 +182,12 @@ D0666A6E24C6DFB300F3F04B /* AccessToken.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessToken.swift; sourceTree = ""; }; D0666A7124C6E0D300F3F04B /* Secrets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Secrets.swift; sourceTree = ""; }; D06B491E24D3F7FE00642749 /* Localizable.strings */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; path = Localizable.strings; sourceTree = ""; }; + D06BAB4D24D942BC0081B8FD /* IdentitiesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentitiesViewModel.swift; sourceTree = ""; }; + D06BAB5024D942CF0081B8FD /* IdentitiesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentitiesView.swift; sourceTree = ""; }; D074577624D29006004758DB /* StubbingWebAuthSession.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StubbingWebAuthSession.swift; sourceTree = ""; }; D074577924D29366004758DB /* URLSessionConfiguration+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URLSessionConfiguration+Extensions.swift"; sourceTree = ""; }; D081A40424D0F1A8001B016E /* String+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Extensions.swift"; sourceTree = ""; }; D0B23F0C24D210E90066F411 /* NSError+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSError+Extensions.swift"; sourceTree = ""; }; - D0B93B2F24D55098007AF646 /* Screen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Screen.swift; sourceTree = ""; }; D0BEC93724C9632800E864C4 /* RootViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootViewModel.swift; sourceTree = ""; }; D0BEC93A24C96FD500E864C4 /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; D0BEC94624CA22C400E864C4 /* TimelineViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineViewModel.swift; sourceTree = ""; }; @@ -275,7 +275,6 @@ D047FA8524C3E21000AF17C5 /* MetatextApp.swift */, D0666A3A24C6B56200F3F04B /* Model */, D0DB6EFA24C5730600D965FE /* Networking */, - D052BBE224D81C2300A80A7A /* Publishers */, D0DB6EFB24C658E400D965FE /* View Models */, D0DB6EF024C5224F00D965FE /* Views */, ); @@ -311,14 +310,6 @@ path = macOS; sourceTree = ""; }; - D052BBE224D81C2300A80A7A /* Publishers */ = { - isa = PBXGroup; - children = ( - D052BBE324D81C4700A80A7A /* CurrentValuePublisher.swift */, - ); - path = Publishers; - sourceTree = ""; - }; D0666A2224C677B400F3F04B /* Tests */ = { isa = PBXGroup; children = ( @@ -369,8 +360,8 @@ isa = PBXGroup; children = ( D0DB6EF324C5228A00D965FE /* AddIdentityView.swift */, + D06BAB5024D942CF0081B8FD /* IdentitiesView.swift */, D0BEC93A24C96FD500E864C4 /* RootView.swift */, - D0B93B2F24D55098007AF646 /* Screen.swift */, D04FD73224D48F37007D572D /* SettingsView.swift */, D0BEC94924CA231200E864C4 /* TimelineView.swift */, ); @@ -394,6 +385,7 @@ isa = PBXGroup; children = ( D0DB6F0824C65AC000D965FE /* AddIdentityViewModel.swift */, + D06BAB4D24D942BC0081B8FD /* IdentitiesViewModel.swift */, D052BBDF24D805E300A80A7A /* MainNavigationViewModel.swift */, D0BEC93724C9632800E864C4 /* RootViewModel.swift */, D04FD73524D49506007D572D /* SettingsViewModel.swift */, @@ -670,9 +662,7 @@ D0666A5A24C6C64100F3F04B /* MastodonEncoder.swift in Sources */, D0666A5124C6C3BC00F3F04B /* Account.swift in Sources */, D0ED1BE024CF98FB00B4899C /* AccountEndpoint.swift in Sources */, - D0B93B3024D55098007AF646 /* Screen.swift in Sources */, D052BBD224D750CB00A80A7A /* AppEnvironment.swift in Sources */, - D052BBE424D81C4700A80A7A /* CurrentValuePublisher.swift in Sources */, D081A40524D0F1A8001B016E /* String+Extensions.swift in Sources */, D0BEC93824C9632800E864C4 /* RootViewModel.swift in Sources */, D0ED1BC124CED48800B4899C /* HTTPClient.swift in Sources */, @@ -683,6 +673,7 @@ D0DC175524D00F0A00A75C65 /* AccessTokenEndpoint+Stubbing.swift in Sources */, D0B23F0D24D210E90066F411 /* NSError+Extensions.swift in Sources */, D052BBCA24D74C9200A80A7A /* FakeUserDefaults.swift in Sources */, + D06BAB4E24D942BD0081B8FD /* IdentitiesViewModel.swift in Sources */, D0DC175224D008E300A75C65 /* MastodonTarget+Stubbing.swift in Sources */, D0666A4224C6BB7B00F3F04B /* IdentityDatabase.swift in Sources */, D0BEC94A24CA231200E864C4 /* TimelineView.swift in Sources */, @@ -697,6 +688,7 @@ D0666A5724C6C63400F3F04B /* MastodonDecoder.swift in Sources */, D0DB6EF424C5228A00D965FE /* AddIdentityView.swift in Sources */, D0DC177424D0B58800A75C65 /* Keychain.swift in Sources */, + D06BAB5124D942CF0081B8FD /* IdentitiesView.swift in Sources */, D074577724D29006004758DB /* StubbingWebAuthSession.swift in Sources */, D0ED1BCE24CF768200B4899C /* MastodonEndpoint.swift in Sources */, D074577A24D29366004758DB /* URLSessionConfiguration+Extensions.swift in Sources */, @@ -735,9 +727,7 @@ D0666A5B24C6C64100F3F04B /* MastodonEncoder.swift in Sources */, D0666A5224C6C3BC00F3F04B /* Account.swift in Sources */, D0ED1BE124CF98FB00B4899C /* AccountEndpoint.swift in Sources */, - D0B93B3124D55098007AF646 /* Screen.swift in Sources */, D052BBD124D750CA00A80A7A /* AppEnvironment.swift in Sources */, - D052BBE524D81C4700A80A7A /* CurrentValuePublisher.swift in Sources */, D081A40624D0F1A8001B016E /* String+Extensions.swift in Sources */, D0BEC93924C9632800E864C4 /* RootViewModel.swift in Sources */, D0ED1BC224CED48800B4899C /* HTTPClient.swift in Sources */, @@ -748,6 +738,7 @@ D0DC175624D00F0A00A75C65 /* AccessTokenEndpoint+Stubbing.swift in Sources */, D0B23F0E24D210E90066F411 /* NSError+Extensions.swift in Sources */, D052BBCB24D74C9300A80A7A /* FakeUserDefaults.swift in Sources */, + D06BAB4F24D942BD0081B8FD /* IdentitiesViewModel.swift in Sources */, D0DC175324D008E300A75C65 /* MastodonTarget+Stubbing.swift in Sources */, D0666A4324C6BB7B00F3F04B /* IdentityDatabase.swift in Sources */, D0BEC94B24CA231200E864C4 /* TimelineView.swift in Sources */, @@ -762,6 +753,7 @@ D0666A5824C6C63400F3F04B /* MastodonDecoder.swift in Sources */, D0DB6EF524C5233E00D965FE /* AddIdentityView.swift in Sources */, D0DC177524D0B58800A75C65 /* Keychain.swift in Sources */, + D06BAB5224D942CF0081B8FD /* IdentitiesView.swift in Sources */, D074577824D29006004758DB /* StubbingWebAuthSession.swift in Sources */, D0ED1BCF24CF768200B4899C /* MastodonEndpoint.swift in Sources */, D074577B24D29366004758DB /* URLSessionConfiguration+Extensions.swift in Sources */, diff --git a/Shared/Model/Identity.swift b/Shared/Model/Identity.swift index 53894c4..fe03393 100644 --- a/Shared/Model/Identity.swift +++ b/Shared/Model/Identity.swift @@ -2,9 +2,10 @@ import Foundation -struct Identity: Codable, Hashable { +struct Identity: Codable, Hashable, Identifiable { let id: String let url: URL + let lastUsedAt: Date let instance: Identity.Instance? let account: Identity.Account? } diff --git a/Shared/Model/IdentityDatabase.swift b/Shared/Model/IdentityDatabase.swift index e45ca42..1e3e135 100644 --- a/Shared/Model/IdentityDatabase.swift +++ b/Shared/Model/IdentityDatabase.swift @@ -31,10 +31,24 @@ struct IdentityDatabase { extension IdentityDatabase { func createIdentity(id: String, url: URL) -> AnyPublisher { - databaseQueue.writePublisher(updates: StoredIdentity(id: id, url: url, instanceURI: nil).save) + databaseQueue.writePublisher( + updates: StoredIdentity( + id: id, + url: url, + lastUsedAt: Date(), + instanceURI: nil).save) .eraseToAnyPublisher() } + func updateLastUsedAt(identityID: String) -> AnyPublisher { + databaseQueue.writePublisher { + try StoredIdentity + .filter(Column("id") == identityID) + .updateAll($0, Column("lastUsedAt").set(to: Date())) + } + .eraseToAnyPublisher() + } + func updateInstance(_ instance: Instance, forIdentityID identityID: String) -> AnyPublisher { databaseQueue.writePublisher { try Identity.Instance( @@ -82,6 +96,29 @@ extension IdentityDatabase { } .eraseToAnyPublisher() } + + func identitiesObservation() -> AnyPublisher<[Identity], Error> { + ValueObservation.tracking( + StoredIdentity + .including(optional: StoredIdentity.instance) + .including(optional: StoredIdentity.account) + .asRequest(of: IdentityResult.self).fetchAll) + .removeDuplicates() + .publisher(in: databaseQueue, scheduling: .immediate) + .map { $0.map(Identity.init(result:)) } + .eraseToAnyPublisher() + } + + func identityCountObservation() -> AnyPublisher { + ValueObservation.tracking(StoredIdentity.fetchCount) + .removeDuplicates() + .publisher(in: databaseQueue, scheduling: .immediate) + .eraseToAnyPublisher() + } + + var mostRecentlyUsedIdentityID: String? { + try? databaseQueue.read(StoredIdentity.select(Column("id")).order(Column("lastUsedAt").desc).fetchOne) + } } private extension IdentityDatabase { @@ -99,6 +136,7 @@ private extension IdentityDatabase { try db.create(table: "storedIdentity", ifNotExists: true) { t in t.column("id", .text).notNull().primaryKey(onConflict: .replace) t.column("url", .text).notNull() + t.column("lastUsedAt", .datetime).notNull() t.column("instanceURI", .text) .indexed() .references("instance", column: "uri") @@ -126,6 +164,7 @@ private extension IdentityDatabase { private struct StoredIdentity: Codable, Hashable, TableRecord, FetchableRecord, PersistableRecord { let id: String let url: URL + let lastUsedAt: Date let instanceURI: String? } @@ -150,7 +189,12 @@ private struct IdentityResult: Codable, Hashable, FetchableRecord { private extension Identity { init(result: IdentityResult) { - self.init(id: result.identity.id, url: result.identity.url, instance: result.instance, account: result.account) + self.init( + id: result.identity.id, + url: result.identity.url, + lastUsedAt: result.identity.lastUsedAt, + instance: result.instance, + account: result.account) } } diff --git a/Shared/Publishers/CurrentValuePublisher.swift b/Shared/Publishers/CurrentValuePublisher.swift deleted file mode 100644 index 5c2b6b4..0000000 --- a/Shared/Publishers/CurrentValuePublisher.swift +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright © 2020 Metabolist. All rights reserved. - -import Combine - -class CurrentValuePublisher { - @Published private var wrappedValue: Output - - init

(initial: Output, then: P) where P: Publisher, P.Output == Output, P.Failure == Never { - wrappedValue = initial - then.assign(to: &$wrappedValue) - } -} - -extension CurrentValuePublisher { - var value: Output { wrappedValue } -} - -extension CurrentValuePublisher: Publisher { - typealias Failure = Never - - func receive(subscriber: S) where S: Subscriber, S.Input == Output, S.Failure == Never { - $wrappedValue.receive(subscriber: subscriber) - } -} diff --git a/Shared/View Models/AddIdentityViewModel.swift b/Shared/View Models/AddIdentityViewModel.swift index 405aead..5b7692a 100644 --- a/Shared/View Models/AddIdentityViewModel.swift +++ b/Shared/View Models/AddIdentityViewModel.swift @@ -179,11 +179,7 @@ private extension Publisher where Output == AccessToken { return (id, instanceURL) } .flatMap(environment.identityDatabase.createIdentity) - .map { - environment.preferences[.recentIdentityID] = id - - return id - } + .map { id } .eraseToAnyPublisher() } } diff --git a/Shared/View Models/IdentitiesViewModel.swift b/Shared/View Models/IdentitiesViewModel.swift new file mode 100644 index 0000000..507375a --- /dev/null +++ b/Shared/View Models/IdentitiesViewModel.swift @@ -0,0 +1,31 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Combine +import Foundation + +class IdentitiesViewModel: ObservableObject { + @Published private(set) var identity: Identity + @Published var identities = [Identity]() + @Published var alertItem: AlertItem? + + private let environment: AppEnvironment + private var cancellables = Set() + + init(identity: Published, environment: AppEnvironment) { + _identity = identity + self.environment = environment + + environment.identityDatabase.identitiesObservation() + .assignErrorsToAlertItem(to: \.alertItem, on: self) + .assign(to: &$identities) + } +} + +extension IdentitiesViewModel { + func identitySelected(id: String) { + environment.identityDatabase.updateLastUsedAt(identityID: id) + .assignErrorsToAlertItem(to: \.alertItem, on: self) + .sink(receiveValue: {}) + .store(in: &cancellables) + } +} diff --git a/Shared/View Models/MainNavigationViewModel.swift b/Shared/View Models/MainNavigationViewModel.swift index 5421d8f..bc6b86f 100644 --- a/Shared/View Models/MainNavigationViewModel.swift +++ b/Shared/View Models/MainNavigationViewModel.swift @@ -4,41 +4,50 @@ import Foundation import Combine class MainNavigationViewModel: ObservableObject { - var selectedTab: Tab? = .timelines + @Published private(set) var identity: Identity @Published var presentingSettings = false - @Published private(set) var alertItem: AlertItem? - @Published private(set) var handle: String - @Published private(set) var image: URL? + @Published var alertItem: AlertItem? + var selectedTab: Tab? = .timelines private let environment: AppEnvironment - private let identity: CurrentValuePublisher private let networkClient: MastodonClient private var cancellables = Set() - init(identity: CurrentValuePublisher, environment: AppEnvironment) { - self.identity = identity + init(identityID: String, environment: AppEnvironment) throws { self.environment = environment networkClient = MastodonClient(configuration: environment.URLSessionConfiguration) - networkClient.instanceURL = identity.value.url + let observation = environment.identityDatabase.identityObservation(id: identityID).share() + var initialIdentity: Identity? + + observation.first().sink( + receiveCompletion: { _ in }, + receiveValue: { initialIdentity = $0 }) + .store(in: &cancellables) + + guard let identity = initialIdentity else { throw IdentityDatabaseError.identityNotFound } + + self.identity = identity + networkClient.instanceURL = identity.url do { - networkClient.accessToken = try environment.secrets.item(.accessToken, forIdentityID: identity.value.id) + networkClient.accessToken = try environment.secrets.item(.accessToken, forIdentityID: identity.id) } catch { alertItem = AlertItem(error: error) } - handle = identity.value.handle - identity.map(\.handle).assign(to: &$handle) + observation.assignErrorsToAlertItem(to: \.alertItem, on: self).assign(to: &$identity) - image = identity.value.image - identity.map(\.image).assign(to: &$image) + environment.identityDatabase.updateLastUsedAt(identityID: identityID) + .assignErrorsToAlertItem(to: \.alertItem, on: self) + .sink(receiveValue: {}) + .store(in: &cancellables) } } extension MainNavigationViewModel { func refreshIdentity() { - let id = identity.value.id + let id = identity.id if networkClient.accessToken != nil { networkClient.request(AccountEndpoint.verifyCredentials) @@ -58,7 +67,7 @@ extension MainNavigationViewModel { } func settingsViewModel() -> SettingsViewModel { - SettingsViewModel(identity: identity, environment: environment) + SettingsViewModel(identity: _identity, environment: environment) } } diff --git a/Shared/View Models/RootViewModel.swift b/Shared/View Models/RootViewModel.swift index 39dd3d7..036eff2 100644 --- a/Shared/View Models/RootViewModel.swift +++ b/Shared/View Models/RootViewModel.swift @@ -4,49 +4,39 @@ import Foundation import Combine class RootViewModel: ObservableObject { - @Published private(set) var identityID: String? - @Published var alertItem: AlertItem? + @Published private(set) var mainNavigationViewModel: MainNavigationViewModel? + @Published private var identityID: String? private let environment: AppEnvironment private var cancellables = Set() init(environment: AppEnvironment) { self.environment = environment - identityID = environment.preferences[.recentIdentityID] + identityID = environment.identityDatabase.mostRecentlyUsedIdentityID + + $identityID + .tryMap { + guard let id = $0 else { return nil } + + return try MainNavigationViewModel(identityID: id, environment: environment) + } + .replaceError(with: nil) + .assign(to: &$mainNavigationViewModel) } } extension RootViewModel { + func newIdentitySelected(id: String) { + identityID = id + } + func addIdentityViewModel() -> AddIdentityViewModel { let addAccountViewModel = AddIdentityViewModel(environment: environment) - addAccountViewModel.addedIdentityID.map { $0 as String? }.assign(to: &$identityID) + addAccountViewModel.addedIdentityID + .sink(receiveValue: newIdentitySelected(id:)) + .store(in: &cancellables) return addAccountViewModel } - - func mainNavigationViewModel(identityID: String) -> MainNavigationViewModel? { - environment.preferences[.recentIdentityID] = identityID - - let identityObservation = environment.identityDatabase.identityObservation(id: identityID) - .share() - var initialIdentity: Identity? - - // setting `initialIdentity` works because of immediate scheduling - identityObservation.sink(receiveCompletion: { _ in }, receiveValue: { initialIdentity = $0 }) - .store(in: &cancellables) - identityObservation.map { $0.id } - .catch { [weak self] _ -> AnyPublisher in - Just(self?.environment.preferences[.recentIdentityID]).eraseToAnyPublisher() - } - .assign(to: &$identityID) - - guard let presentIdentity = initialIdentity else { return nil } - - return MainNavigationViewModel( - identity: CurrentValuePublisher( - initial: presentIdentity, - then: identityObservation.assignErrorsToAlertItem(to: \.alertItem, on: self)), - environment: environment) - } } diff --git a/Shared/View Models/SettingsViewModel.swift b/Shared/View Models/SettingsViewModel.swift index 75875b1..7d28ea7 100644 --- a/Shared/View Models/SettingsViewModel.swift +++ b/Shared/View Models/SettingsViewModel.swift @@ -3,11 +3,17 @@ import Foundation class SettingsViewModel: ObservableObject { - private let identity: CurrentValuePublisher + @Published private(set) var identity: Identity private let environment: AppEnvironment - init(identity: CurrentValuePublisher, environment: AppEnvironment) { - self.identity = identity + init(identity: Published, environment: AppEnvironment) { + _identity = identity self.environment = environment } } + +extension SettingsViewModel { + func identitiesViewModel() -> IdentitiesViewModel { + IdentitiesViewModel(identity: _identity, environment: environment) + } +} diff --git a/Shared/Views/IdentitiesView.swift b/Shared/Views/IdentitiesView.swift new file mode 100644 index 0000000..77c1af7 --- /dev/null +++ b/Shared/Views/IdentitiesView.swift @@ -0,0 +1,33 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import SwiftUI + +struct IdentitiesView: View { + @StateObject var viewModel: IdentitiesViewModel + @EnvironmentObject var rootViewModel: RootViewModel + + var body: some View { + Form { + Section { + NavigationLink( + destination: AddIdentityView(viewModel: rootViewModel.addIdentityViewModel()), + label: { + Label("add new account", systemImage: "plus") + }) + } + Section { + List(viewModel.identities) { identity in + Button(identity.handle) { + rootViewModel.newIdentitySelected(id: identity.id) + } + } + } + } + } +} + +struct IdentitiesView_Previews: PreviewProvider { + static var previews: some View { + IdentitiesView(viewModel: .development) + } +} diff --git a/Shared/Views/RootView.swift b/Shared/Views/RootView.swift index 5027aa6..cdf01e2 100644 --- a/Shared/Views/RootView.swift +++ b/Shared/Views/RootView.swift @@ -6,29 +6,27 @@ struct RootView: View { @StateObject var viewModel: RootViewModel var body: some View { - if - let identityID = viewModel.identityID, - let mainNavigationViewModel = viewModel.mainNavigationViewModel(identityID: identityID) { - Self.mainNavigation(viewModel: mainNavigationViewModel) - } else { - addIdentity + ZStack { + if let mainNavigationViewModel = viewModel.mainNavigationViewModel { + Self.mainNavigation(mainNavigationViewModel: mainNavigationViewModel) + .environmentObject(viewModel) + } else { + AddIdentityView(viewModel: viewModel.addIdentityViewModel()) + } } } } private extension RootView { - private static func mainNavigation(viewModel: MainNavigationViewModel) -> some View { + @ViewBuilder + private static func mainNavigation(mainNavigationViewModel: MainNavigationViewModel) -> some View { #if os(macOS) - return SidebarNavigation(viewModel: viewModel) + SidebarNavigation(viewModel: mainNavigationViewModel) .frame(minWidth: 900, maxWidth: .infinity, minHeight: 500, maxHeight: .infinity) #else - return TabNavigation(viewModel: viewModel) + TabNavigation(viewModel: mainNavigationViewModel) #endif } - - private var addIdentity: some View { - AddIdentityView(viewModel: viewModel.addIdentityViewModel()) - } } #if DEBUG diff --git a/Shared/Views/Screen.swift b/Shared/Views/Screen.swift deleted file mode 100644 index 81d0d0a..0000000 --- a/Shared/Views/Screen.swift +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright © 2020 Metabolist. All rights reserved. - -import SwiftUI - -struct Screen { - static var scale: CGFloat { - #if os(macOS) - return NSScreen.main?.backingScaleFactor ?? 1 - #else - return UIScreen.main.scale - #endif - } -} diff --git a/Shared/Views/SettingsView.swift b/Shared/Views/SettingsView.swift index 202fdb2..4df7609 100644 --- a/Shared/Views/SettingsView.swift +++ b/Shared/Views/SettingsView.swift @@ -6,34 +6,41 @@ import struct Kingfisher.DownsamplingImageProcessor struct SettingsView: View { @StateObject var viewModel: SettingsViewModel - @EnvironmentObject var mainNavigationViewModel: MainNavigationViewModel + @EnvironmentObject var rootViewModel: RootViewModel + @Environment(\.presentationMode) var presentationMode + @Environment(\.displayScale) var displayScale: CGFloat var body: some View { VStack(spacing: 0) { NavigationView { Form { HStack { - KFImage(mainNavigationViewModel.image, + KFImage(viewModel.identity.image, options: [ .processor( DownsamplingImageProcessor(size: CGSize(width: 50, height: 50)) ), - .scaleFactor(Screen.scale), + .scaleFactor(displayScale), .cacheOriginalImage ]) .clipShape(Circle()) - Text(mainNavigationViewModel.handle) + Text(viewModel.identity.handle) .font(.subheadline) } + NavigationLink( + "accounts", + destination: IdentitiesView( + viewModel: viewModel.identitiesViewModel()) + .environmentObject(rootViewModel)) } - .navigationBarTitleAndItems(mainNavigationViewModel: mainNavigationViewModel) + .navigationBarTitleAndItems(presentationMode: presentationMode) } .navigationViewStyle #if os(macOS) Divider() HStack { Spacer() - Button(action: { mainNavigationViewModel.presentingSettings.toggle() }) { + Button(action: { presentationMode.wrappedValue.dismiss() }) { Text("Done") } .keyboardShortcut(.defaultAction) @@ -47,12 +54,12 @@ struct SettingsView: View { } private extension View { - func navigationBarTitleAndItems(mainNavigationViewModel: MainNavigationViewModel) -> some View { + func navigationBarTitleAndItems(presentationMode: Binding) -> some View { #if os(iOS) return navigationBarTitle(Text("settings"), displayMode: .inline) .navigationBarItems( leading: Button { - mainNavigationViewModel.presentingSettings.toggle() + presentationMode.wrappedValue.dismiss() } label: { Image(systemName: "xmark.circle.fill").imageScale(.large) }) @@ -83,6 +90,7 @@ struct SettingsView_Previews: PreviewProvider { static var previews: some View { SettingsView(viewModel: .development) .environmentObject(MainNavigationViewModel.development) + .environmentObject(RootViewModel.development) } } #endif diff --git a/Tests/View Models/AddIdentityViewModelTests.swift b/Tests/View Models/AddIdentityViewModelTests.swift index 3547a98..c3404ee 100644 --- a/Tests/View Models/AddIdentityViewModelTests.swift +++ b/Tests/View Models/AddIdentityViewModelTests.swift @@ -20,7 +20,6 @@ class AddIdentityViewModelTests: XCTestCase { XCTAssertEqual(addedIdentity.id, addedIdentityID) XCTAssertEqual(addedIdentity.url, URL(string: "https://mastodon.social")!) - XCTAssertEqual(environment.preferences[.recentIdentityID], addedIdentity.id) XCTAssertEqual( try environment.secrets.item(.clientID, forIdentityID: addedIdentityID) as String?, "AUTHORIZATION_CLIENT_ID_STUB_VALUE") diff --git a/Tests/View Models/RootViewModelTests.swift b/Tests/View Models/RootViewModelTests.swift index e26e7fc..03673fd 100644 --- a/Tests/View Models/RootViewModelTests.swift +++ b/Tests/View Models/RootViewModelTests.swift @@ -8,17 +8,17 @@ import CombineExpectations class RootViewModelTests: XCTestCase { func testAddIdentity() throws { let sut = RootViewModel(environment: .fresh()) - let identityIDRecorder = sut.$identityID.record() + let recorder = sut.$mainNavigationViewModel.record() - XCTAssertNil(try wait(for: identityIDRecorder.next(), timeout: 1)) + XCTAssertNil(try wait(for: recorder.next(), timeout: 1)) let addIdentityViewModel = sut.addIdentityViewModel() addIdentityViewModel.urlFieldText = "https://mastodon.social" addIdentityViewModel.goTapped() - let identityID = try wait(for: identityIDRecorder.next(), timeout: 1)! + let mainNavigationViewModel = try wait(for: recorder.next(), timeout: 1)! - XCTAssertNotNil(identityID) + XCTAssertNotNil(mainNavigationViewModel) } } diff --git a/iOS/TabNavigation.swift b/iOS/TabNavigation.swift index aa313a2..1db5574 100644 --- a/iOS/TabNavigation.swift +++ b/iOS/TabNavigation.swift @@ -5,7 +5,9 @@ import KingfisherSwiftUI import struct Kingfisher.DownsamplingImageProcessor struct TabNavigation: View { - @StateObject var viewModel: MainNavigationViewModel + @ObservedObject var viewModel: MainNavigationViewModel + @EnvironmentObject var rootViewModel: RootViewModel + @Environment(\.displayScale) var displayScale: CGFloat var body: some View { TabView(selection: $viewModel.selectedTab) { @@ -23,9 +25,10 @@ struct TabNavigation: View { } .sheet(isPresented: $viewModel.presentingSettings) { SettingsView(viewModel: viewModel.settingsViewModel()) - .environmentObject(viewModel) + .environmentObject(rootViewModel) } - .onAppear(perform: viewModel.refreshIdentity) + .onReceive(rootViewModel.$mainNavigationViewModel.map { _ in ()}, + perform: viewModel.refreshIdentity) .onReceive(NotificationCenter.default .publisher(for: UIScene.willEnterForegroundNotification) .map { _ in () }, @@ -39,17 +42,17 @@ private extension TabNavigation { switch tab { case .timelines: TimelineView() - .navigationBarTitle(viewModel.handle, displayMode: .inline) + .navigationBarTitle(viewModel.identity.handle, displayMode: .inline) .navigationBarItems( leading: Button { viewModel.presentingSettings.toggle() } label: { - KFImage(viewModel.image, + KFImage(viewModel.identity.image, options: [ .processor( DownsamplingImageProcessor(size: CGSize(width: 28, height: 28)) ), - .scaleFactor(Screen.scale), + .scaleFactor(displayScale), .cacheOriginalImage ]) .placeholder { Image(systemName: "gear") } diff --git a/macOS/SidebarNavigation.swift b/macOS/SidebarNavigation.swift index c2da3aa..bc43fb1 100644 --- a/macOS/SidebarNavigation.swift +++ b/macOS/SidebarNavigation.swift @@ -7,6 +7,8 @@ import struct Kingfisher.RoundCornerImageProcessor struct SidebarNavigation: View { @StateObject var viewModel: MainNavigationViewModel + @EnvironmentObject var rootViewModel: RootViewModel + @Environment(\.displayScale) var displayScale: CGFloat var sidebar: some View { List(selection: $viewModel.selectedTab) { @@ -18,7 +20,10 @@ struct SidebarNavigation: View { .tag(tab) } } - .overlay(Pocket().environmentObject(viewModel), alignment: .bottom) + .overlay(Pocket() + .environmentObject(viewModel) + .environmentObject(rootViewModel), + alignment: .bottom) .listStyle(SidebarListStyle()) .onAppear(perform: viewModel.refreshIdentity) .onReceive(NotificationCenter.default @@ -51,18 +56,19 @@ private extension SidebarNavigation { struct Pocket: View { @EnvironmentObject var viewModel: MainNavigationViewModel + @EnvironmentObject var rootViewModel: RootViewModel var body: some View { VStack(alignment: .leading, spacing: 0) { Divider() Button(action: { viewModel.presentingSettings.toggle() }) { - KFImage(viewModel.image, + KFImage(viewModel.identity.image, options: [ .processor( DownsamplingImageProcessor(size: CGSize(width: 50, height: 50)) .append(another: RoundCornerImageProcessor(radius: .widthFraction(0.5))) ), - .scaleFactor(Screen.scale), + .scaleFactor(displayScale), .cacheOriginalImage ]) .placeholder { Image(systemName: "gear") } @@ -80,6 +86,7 @@ private extension SidebarNavigation { .sheet(isPresented: $viewModel.presentingSettings) { SettingsView(viewModel: viewModel.settingsViewModel()) .environmentObject(viewModel) + .environmentObject(rootViewModel) } } } @@ -89,6 +96,7 @@ private extension SidebarNavigation { struct SidebarNavigation_Previews: PreviewProvider { static var previews: some View { SidebarNavigation(viewModel: .development) + .environmentObject(RootViewModel.development) } } #endif