From 13edec0a47dd7c29236dba4da96696c82e047c18 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Mon, 26 Aug 2024 20:02:13 +0200 Subject: [PATCH] First attempt --- .../AccountValue/AccountKey+Views.swift | 10 ++ .../Keys/ConfiguredCredentials.swift | 29 ++++++ .../AccountValue/Keys/PasswordKey.swift | 34 +++++++ .../Mock/InMemoryAccountService.swift | 5 +- .../Resources/Localizable.xcstrings | 9 ++ .../AccountKeyOverviewRow.swift | 12 ++- .../AccountOverviewSections.swift | 9 +- .../Views/AccountOverview/NameOverview.swift | 12 ++- .../AccountOverview/PasswordChangeSheet.swift | 95 +++++++++++-------- .../AccountOverview/SecurityOverview.swift | 31 ++---- .../AccountOverview/SingleEditView.swift | 2 +- .../Views/DataDisplay/SecurityView.swift | 21 ++++ 12 files changed, 194 insertions(+), 75 deletions(-) create mode 100644 Sources/SpeziAccount/AccountValue/Keys/ConfiguredCredentials.swift create mode 100644 Sources/SpeziAccount/Views/DataDisplay/SecurityView.swift diff --git a/Sources/SpeziAccount/AccountValue/AccountKey+Views.swift b/Sources/SpeziAccount/AccountValue/AccountKey+Views.swift index aa4ee518..44ac9abf 100644 --- a/Sources/SpeziAccount/AccountValue/AccountKey+Views.swift +++ b/Sources/SpeziAccount/AccountValue/AccountKey+Views.swift @@ -39,7 +39,17 @@ extension AccountKey { return AnyView(DataDisplay(value)) } + static func securityViewWithCurrentStoredValueIfPresent(from details: AccountDetails) -> AnyView? { + let value = details[Self.self] + + if let securityView = DataDisplay as? any SecurityView.Type { + return AnyView(securityView.init()) // TODO: pass the current value! + } + return nil + } + static func singleEditView(model: AccountOverviewFormViewModel, details accountDetails: AccountDetails) -> AnyView { + // TODO: pass as environment object AnyView(SingleEditView(model: model, details: accountDetails)) } } diff --git a/Sources/SpeziAccount/AccountValue/Keys/ConfiguredCredentials.swift b/Sources/SpeziAccount/AccountValue/Keys/ConfiguredCredentials.swift new file mode 100644 index 00000000..12b81ff5 --- /dev/null +++ b/Sources/SpeziAccount/AccountValue/Keys/ConfiguredCredentials.swift @@ -0,0 +1,29 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SpeziFoundation + + +extension AccountDetails { + fileprivate struct ConfiguredCredentials: DefaultProvidingKnowledgeSource { + typealias Anchor = AccountAnchor + typealias Value = [any AccountKey.Type] + + static let defaultValue: Value = [] + } + + /// The list of credentials that are configured for the user account. + public var configuredCredentials: [any AccountKey.Type] { + get { + self[ConfiguredCredentials.self] + } + set { + self[ConfiguredCredentials.self] = newValue + } + } +} diff --git a/Sources/SpeziAccount/AccountValue/Keys/PasswordKey.swift b/Sources/SpeziAccount/AccountValue/Keys/PasswordKey.swift index 8d66f768..e7c79143 100644 --- a/Sources/SpeziAccount/AccountValue/Keys/PasswordKey.swift +++ b/Sources/SpeziAccount/AccountValue/Keys/PasswordKey.swift @@ -11,6 +11,39 @@ import SpeziViews import SwiftUI +private struct ConfigureView: SecurityView { + typealias Value = String + + @Environment(Account.self) + private var account + + @State private var presentingPasswordSheet = false + + private var label: Text { + if account.details?.configuredCredentials.contains(where: { $0 == AccountKeys.password }) == true { + Text("CHANGE_PASSWORD", bundle: .module) + } else { + Text("Setup Password", bundle: .module) + } + } + + var body: some View { + Button(action: { + presentingPasswordSheet = true + }) { + label + } + .sheet(isPresented: $presentingPasswordSheet) { + PasswordChangeSheet() + } + } + + init() { // TODO: does that make sense? + // password will be never present, configuring/changing password is done via other mechanisms + } +} + + private struct EntryView: DataEntryView { @Environment(\.accountViewType) private var accountViewType @@ -69,6 +102,7 @@ extension AccountDetails { name: LocalizedStringResource("UP_PASSWORD", bundle: .atURL(from: .module)), category: .credentials, as: String.self, + displayView: ConfigureView.self, entryView: EntryView.self ) public var password: String? diff --git a/Sources/SpeziAccount/Mock/InMemoryAccountService.swift b/Sources/SpeziAccount/Mock/InMemoryAccountService.swift index 51f0d638..df276fa0 100644 --- a/Sources/SpeziAccount/Mock/InMemoryAccountService.swift +++ b/Sources/SpeziAccount/Mock/InMemoryAccountService.swift @@ -387,9 +387,12 @@ public final class InMemoryAccountService: AccountService { details.userId = userId } - if storage.password == nil { + if let password = storage.password { + details.configuredCredentials = [AccountKeys.password] + } else { details.isAnonymous = true } + return details } } diff --git a/Sources/SpeziAccount/Resources/Localizable.xcstrings b/Sources/SpeziAccount/Resources/Localizable.xcstrings index 56c19063..baa4371f 100644 --- a/Sources/SpeziAccount/Resources/Localizable.xcstrings +++ b/Sources/SpeziAccount/Resources/Localizable.xcstrings @@ -362,6 +362,9 @@ } } } + }, + "Changing the password requires a signed in user account." : { + }, "CLOSE" : { "localizations" : { @@ -1016,6 +1019,9 @@ } } } + }, + "No Account" : { + }, "Numeric Key" : { "localizations" : { @@ -1174,6 +1180,9 @@ } } } + }, + "Setup Password" : { + }, "SIGN_IN_AND_SECURITY" : { "localizations" : { diff --git a/Sources/SpeziAccount/Views/AccountOverview/AccountKeyOverviewRow.swift b/Sources/SpeziAccount/Views/AccountOverview/AccountKeyOverviewRow.swift index b99018be..0ae5b4e7 100644 --- a/Sources/SpeziAccount/Views/AccountOverview/AccountKeyOverviewRow.swift +++ b/Sources/SpeziAccount/Views/AccountOverview/AccountKeyOverviewRow.swift @@ -14,10 +14,11 @@ import SwiftUI struct AccountKeyOverviewRow: View { private let accountDetails: AccountDetails private let accountKey: any AccountKey.Type - private let model: AccountOverviewFormViewModel @Environment(Account.self) private var account + @Environment(AccountOverviewFormViewModel.self) + private var model @Environment(\.editMode) private var editMode @@ -58,15 +59,17 @@ struct AccountKeyOverviewRow: View { if let view = accountKey.dataDisplayViewWithCurrentStoredValue(from: accountDetails) { view .environment(\.accountViewType, .overview(mode: .display)) + } else if let view = accountKey.securityViewWithCurrentStoredValueIfPresent(from: accountDetails) { + view + .environment(\.accountViewType, .overview(mode: .display)) } } } - init(details accountDetails: AccountDetails, for accountKey: any AccountKey.Type, model: AccountOverviewFormViewModel) { + init(details accountDetails: AccountDetails, for accountKey: any AccountKey.Type) { self.accountDetails = accountDetails self.accountKey = accountKey - self.model = model } @@ -92,7 +95,8 @@ private let key = AccountKeys.genderIdentity return AccountDetailsReader { account, details in let model = AccountOverviewFormViewModel(account: account, details: details) - AccountKeyOverviewRow(details: details, for: key, model: model) + AccountKeyOverviewRow(details: details, for: key) + .environment(model) .injectEnvironmentObjects(configuration: details.accountServiceConfiguration, model: model) } .previewWith { diff --git a/Sources/SpeziAccount/Views/AccountOverview/AccountOverviewSections.swift b/Sources/SpeziAccount/Views/AccountOverview/AccountOverviewSections.swift index b6245ea4..d1ae5d01 100644 --- a/Sources/SpeziAccount/Views/AccountOverview/AccountOverviewSections.swift +++ b/Sources/SpeziAccount/Views/AccountOverview/AccountOverviewSections.swift @@ -155,6 +155,7 @@ struct AccountOverviewSections: View { defaultSections sectionsView + .environment(model) .injectEnvironmentObjects(configuration: accountDetails.accountServiceConfiguration, model: model) .receiveValidation(in: $validation) .focused($isFocused) @@ -194,7 +195,8 @@ struct AccountOverviewSections: View { Section { if displayName { NavigationLink { - NameOverview(model: model, details: accountDetails) + NameOverview(details: accountDetails) + .environment(model) } label: { Label { model.accountIdentifierLabel(configuration: account.configuration, accountDetails) @@ -206,7 +208,8 @@ struct AccountOverviewSections: View { if displaySecurity { NavigationLink { - SecurityOverview(model: model, details: accountDetails) + SecurityOverview(details: accountDetails) + .environment(model) } label: { Label { Text("SIGN_IN_AND_SECURITY", bundle: .module) @@ -229,7 +232,7 @@ struct AccountOverviewSections: View { } ForEach(forEachWrappers) { wrapper in - AccountKeyOverviewRow(details: accountDetails, for: wrapper.accountKey, model: model) + AccountKeyOverviewRow(details: accountDetails, for: wrapper.accountKey) } .onDelete { indexSet in model.deleteAccountKeys(at: indexSet, in: accountKeys) diff --git a/Sources/SpeziAccount/Views/AccountOverview/NameOverview.swift b/Sources/SpeziAccount/Views/AccountOverview/NameOverview.swift index 6897ae98..019a7acb 100644 --- a/Sources/SpeziAccount/Views/AccountOverview/NameOverview.swift +++ b/Sources/SpeziAccount/Views/AccountOverview/NameOverview.swift @@ -11,11 +11,12 @@ import SwiftUI struct NameOverview: View { - private let model: AccountOverviewFormViewModel private let accountDetails: AccountDetails @Environment(Account.self) private var account + @Environment(AccountOverviewFormViewModel.self) + private var model var body: some View { @@ -63,8 +64,7 @@ struct NameOverview: View { } - init(model: AccountOverviewFormViewModel, details accountDetails: AccountDetails) { - self.model = model + init(details accountDetails: AccountDetails) { self.accountDetails = accountDetails } } @@ -78,7 +78,8 @@ struct NameOverview: View { return NavigationStack { AccountDetailsReader { account, details in - NameOverview(model: AccountOverviewFormViewModel(account: account, details: details), details: details) + NameOverview(details: details) + .environment(AccountOverviewFormViewModel(account: account, details: details)) } } .previewWith { @@ -92,7 +93,8 @@ struct NameOverview: View { return NavigationStack { AccountDetailsReader { account, details in - NameOverview(model: AccountOverviewFormViewModel(account: account, details: details), details: details) + NameOverview(details: details) + .environment(AccountOverviewFormViewModel(account: account, details: details)) } } .previewWith { diff --git a/Sources/SpeziAccount/Views/AccountOverview/PasswordChangeSheet.swift b/Sources/SpeziAccount/Views/AccountOverview/PasswordChangeSheet.swift index 01f7156b..587ff5fb 100644 --- a/Sources/SpeziAccount/Views/AccountOverview/PasswordChangeSheet.swift +++ b/Sources/SpeziAccount/Views/AccountOverview/PasswordChangeSheet.swift @@ -14,12 +14,10 @@ import SwiftUI @MainActor struct PasswordChangeSheet: View { - private let accountDetails: AccountDetails - private let model: AccountOverviewFormViewModel - - @Environment(Account.self) private var account + @Environment(AccountOverviewFormViewModel.self) + private var model @Environment(\.dismiss) private var dismiss @@ -32,45 +30,63 @@ struct PasswordChangeSheet: View { @State private var repeatPassword: String = "" private var passwordValidations: [ValidationRule] { - accountDetails.accountServiceConfiguration.fieldValidationRules(for: AccountKeys.password) ?? [] + // TODO: Details optional access? + account.details?.accountServiceConfiguration.fieldValidationRules(for: AccountKeys.password) ?? [] } var body: some View { NavigationStack { - Form { - passwordFieldsSection - .injectEnvironmentObjects(configuration: accountDetails.accountServiceConfiguration, model: model) - .focused($isFocused) - .environment(\.accountViewType, .overview(mode: .new)) - .environment(\.defaultErrorDescription, model.defaultErrorDescription) + Group { + if let details = account.details { + Form { + passwordFieldsSection(for: details) + .injectEnvironmentObjects(configuration: details.accountServiceConfiguration, model: model) + .focused($isFocused) + .environment(\.accountViewType, .overview(mode: .new)) + .environment(\.defaultErrorDescription, model.defaultErrorDescription) + } + .viewStateAlert(state: $viewState) + .onDisappear { + model.resetModelState() // clears modified details + } + .anyModifiers(account.securityRelatedModifiers.map { $0.anyViewModifier }) + } else { + ContentUnavailableView( + "No Account", + systemImage: "person.crop.square.fill", + description: Text("Changing the password requires a signed in user account.") + ) + } } - .viewStateAlert(state: $viewState) .navigationTitle(Text("CHANGE_PASSWORD", bundle: .module)) -#if !os(macOS) + #if !os(macOS) .navigationBarTitleDisplayMode(.inline) -#endif + #endif .toolbar { - ToolbarItem(placement: .primaryAction) { - AsyncButton(state: $viewState, action: submitPasswordChange) { - Text("DONE", bundle: .module) - } - } - ToolbarItem(placement: .cancellationAction) { - Button(action: { - dismiss() - }) { - Text("CANCEL", bundle: .module) - } - } - } - .onDisappear { - model.resetModelState() // clears modified details + toolbarContent } - .anyModifiers(account.securityRelatedModifiers.map { $0.anyViewModifier }) } } - @ViewBuilder private var passwordFieldsSection: some View { + @ToolbarContentBuilder private var toolbarContent: some ToolbarContent { + ToolbarItem(placement: .primaryAction) { + AsyncButton(state: $viewState, action: submitPasswordChange) { + Text("DONE", bundle: .module) + } + } + ToolbarItem(placement: .cancellationAction) { + Button(action: { + dismiss() + }) { + Text("CANCEL", bundle: .module) + } + } + } + + init() {} // TODO: make public? + + @ViewBuilder + private func passwordFieldsSection(for details: AccountDetails) -> some View { Section { Grid { AccountKeys.password.DataEntry($newPassword) @@ -95,26 +111,24 @@ struct PasswordChangeSheet: View { .environment(\.validationConfiguration, .hideFailedValidationOnEmptySubmit) } } footer: { - PasswordValidationRuleFooter(configuration: accountDetails.accountServiceConfiguration) + PasswordValidationRuleFooter(configuration: details.accountServiceConfiguration) } } - - init(model: AccountOverviewFormViewModel, details accountDetails: AccountDetails) { - self.model = model - self.accountDetails = accountDetails - } - func submitPasswordChange() async throws { guard validation.validateSubviews() else { return } + guard let details = account.details else { + return + } + isFocused = false account.logger.debug("Saving updated password to AccountService!") - try await model.updateAccountDetails(details: accountDetails, using: account) + try await model.updateAccountDetails(details: details, using: account) dismiss() } @@ -139,7 +153,8 @@ struct PasswordChangeSheet: View { return NavigationStack { AccountDetailsReader { account, details in - PasswordChangeSheet(model: AccountOverviewFormViewModel(account: account, details: details), details: details) + PasswordChangeSheet() + .environment(AccountOverviewFormViewModel(account: account, details: details)) } } .previewWith { diff --git a/Sources/SpeziAccount/Views/AccountOverview/SecurityOverview.swift b/Sources/SpeziAccount/Views/AccountOverview/SecurityOverview.swift index bcdfbc0c..bd209f76 100644 --- a/Sources/SpeziAccount/Views/AccountOverview/SecurityOverview.swift +++ b/Sources/SpeziAccount/Views/AccountOverview/SecurityOverview.swift @@ -14,14 +14,14 @@ import SwiftUI @available(macOS, unavailable) struct SecurityOverview: View { private let accountDetails: AccountDetails - private let model: AccountOverviewFormViewModel @Environment(Account.self) private var account + @Environment(AccountOverviewFormViewModel.self) + private var model @State private var viewState: ViewState = .idle - @State private var presentingPasswordChangeSheet = false var body: some View { @@ -34,22 +34,11 @@ struct SecurityOverview: View { ForEach(forEachWrappers, id: \.id) { wrapper in Section { - if wrapper.accountKey == AccountKeys.password { - // we have a special case for the PasswordKey, as we currently don't expose the capabilities required to the subviews! - Button(action: { - presentingPasswordChangeSheet = true - }) { - Text("CHANGE_PASSWORD", bundle: .module) - } - .sheet(isPresented: $presentingPasswordChangeSheet) { - PasswordChangeSheet(model: model, details: accountDetails) - } - } else { - // This view currently doesn't implement an EditMode. Current intention is that the - // DataDisplay view of `.credentials` account values just build toggles or NavigationLinks - // to manage and change the respective account value. - AccountKeyOverviewRow(details: accountDetails, for: wrapper.accountKey, model: model) - } + Text("Collect \(wrapper)") + // This view currently doesn't implement an EditMode. Current intention is that the + // DataDisplay view of `.credentials` account values just build toggles or NavigationLinks + // to manage and change the respective account value. + AccountKeyOverviewRow(details: accountDetails, for: wrapper.accountKey) } } .injectEnvironmentObjects(configuration: accountDetails.accountServiceConfiguration, model: model) @@ -66,8 +55,7 @@ struct SecurityOverview: View { } - init(model: AccountOverviewFormViewModel, details accountDetails: AccountDetails) { - self.model = model + init(details accountDetails: AccountDetails) { self.accountDetails = accountDetails } } @@ -82,7 +70,8 @@ struct SecurityOverview: View { return NavigationStack { AccountDetailsReader { account, details in - SecurityOverview(model: AccountOverviewFormViewModel(account: account, details: details), details: details) + SecurityOverview(details: details) + .environment(AccountOverviewFormViewModel(account: account, details: details)) } } .previewWith { diff --git a/Sources/SpeziAccount/Views/AccountOverview/SingleEditView.swift b/Sources/SpeziAccount/Views/AccountOverview/SingleEditView.swift index 0ab7857e..f3ddfdb4 100644 --- a/Sources/SpeziAccount/Views/AccountOverview/SingleEditView.swift +++ b/Sources/SpeziAccount/Views/AccountOverview/SingleEditView.swift @@ -14,7 +14,7 @@ import SwiftUI @MainActor struct SingleEditView: View { - private let model: AccountOverviewFormViewModel + private let model: AccountOverviewFormViewModel // TODO: pass as environment object private let accountDetails: AccountDetails @Environment(Account.self) diff --git a/Sources/SpeziAccount/Views/DataDisplay/SecurityView.swift b/Sources/SpeziAccount/Views/DataDisplay/SecurityView.swift new file mode 100644 index 00000000..a8db1ec5 --- /dev/null +++ b/Sources/SpeziAccount/Views/DataDisplay/SecurityView.swift @@ -0,0 +1,21 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + + +public protocol SecurityView: DataDisplayView { // TODO: this is not special to security! + @MainActor + init() +} + + +extension SecurityView { + @MainActor + public init(_ value: Value) { + self.init() + } +}