diff --git a/.gitignore b/.gitignore index bcbd16f4..9586855b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ # # This source file is part of the Spezi open source project # -# SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) +# SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) # # SPDX-License-Identifier: MIT # diff --git a/.swiftlint.yml b/.swiftlint.yml index a2fa581a..a4a34d0d 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -1,10 +1,10 @@ # # This source file is part of the Stanford Spezi open-source project # -# SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) +# SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) # # SPDX-License-Identifier: MIT -# +# # The whitelist_rules configuration also includes rules that are enabled by default to provide a good overview of all rules. only_rules: @@ -198,7 +198,7 @@ only_rules: - nimble_operator # Prefer not to use extension access modifiers - no_extension_access_modifier - # Fallthroughs can only be used if the case contains at least one other statement. + # Fallthroughs can only be used if the case contains at least one other statement. - no_fallthrough_only # Don’t add a space between the method name and the parentheses. - no_space_in_method_call @@ -429,7 +429,7 @@ nesting: # Types should be nested at most 2 level deep, and functions should be warning: 2 # warning - default: 1 function_level: warning: 5 # warning - default: 5 - + trailing_closure: only_single_muted_parameter: true @@ -444,7 +444,7 @@ type_name: trailing_whitespace: ignores_empty_lines: true # default: false ignores_comments: true # default: false - + unused_optional_binding: ignore_optional_try: true diff --git a/CITATION.cff b/CITATION.cff index 3243802f..ae0f3eea 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -1,7 +1,7 @@ # # This source file is part of the Spezi open source project # -# SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) +# SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) # # SPDX-License-Identifier: MIT # diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index c292d34a..90beb818 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -2,7 +2,7 @@ This source file is part of the Spezi open-source project. -SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) +SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) SPDX-License-Identifier: MIT diff --git a/LICENSE.md b/LICENSE.md index 6998b5f9..599ed6fd 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2022 Stanford University and the project authors (see CONTRIBUTORS.md) +Copyright (c) 2023 Stanford University and the project authors (see CONTRIBUTORS.md) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: diff --git a/LICENSES/MIT.txt b/LICENSES/MIT.txt index 6998b5f9..599ed6fd 100644 --- a/LICENSES/MIT.txt +++ b/LICENSES/MIT.txt @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2022 Stanford University and the project authors (see CONTRIBUTORS.md) +Copyright (c) 2023 Stanford University and the project authors (see CONTRIBUTORS.md) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: diff --git a/Package.swift b/Package.swift index 4eaf5b93..9ae3c0af 100644 --- a/Package.swift +++ b/Package.swift @@ -3,7 +3,7 @@ // // This source file is part of the Spezi open source project // -// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) // // SPDX-License-Identifier: MIT // @@ -21,21 +21,26 @@ let package = Package( .library(name: "SpeziAccount", targets: ["SpeziAccount"]) ], dependencies: [ - .package(url: "https://github.com/StanfordSpezi/Spezi", .upToNextMinor(from: "0.7.0")), - .package(url: "https://github.com/StanfordSpezi/SpeziViews", .upToNextMinor(from: "0.4.0")) + .package(url: "https://github.com/StanfordSpezi/Spezi", .upToNextMinor(from: "0.7.2")), + .package(url: "https://github.com/StanfordSpezi/SpeziViews", .upToNextMinor(from: "0.5.0")), + .package(url: "https://github.com/StanfordBDHG/XCTRuntimeAssertions", .upToNextMinor(from: "0.2.5")), + .package(url: "https://github.com/apple/swift-collections.git", .upToNextMajor(from: "1.0.4")) ], targets: [ .target( name: "SpeziAccount", dependencies: [ .product(name: "Spezi", package: "Spezi"), - .product(name: "SpeziViews", package: "SpeziViews") + .product(name: "SpeziViews", package: "SpeziViews"), + .product(name: "XCTRuntimeAssertions", package: "XCTRuntimeAssertions"), + .product(name: "OrderedCollections", package: "swift-collections") ] ), .testTarget( name: "SpeziAccountTests", dependencies: [ - .target(name: "SpeziAccount") + .target(name: "SpeziAccount"), + .product(name: "XCTRuntimeAssertions", package: "XCTRuntimeAssertions") ] ) ] diff --git a/README.md b/README.md index 4b743cbb..78214888 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ This source file is part of the Spezi open-source project. -SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) +SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) SPDX-License-Identifier: MIT @@ -16,7 +16,9 @@ SPDX-License-Identifier: MIT [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FStanfordSpezi%2FSpeziAccount%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/StanfordSpezi/SpeziAccount) [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FStanfordSpezi%2FSpeziAccount%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/StanfordSpezi/SpeziAccount) -The Account module allows users to incorporate account-related functionality for Spezi-based applications. +The Account module provides account-related functionality for Spezi-based applications. +It provides two standardized views with Account Setup, Overview and Edit functionality. +It allows integrating arbitrary account management services using the Account Service abstraction. For more information, please refer to the [API documentation](https://swiftpackageindex.com/StanfordSpezi/SpeziAccount/documentation). @@ -25,7 +27,6 @@ For more information, please refer to the [API documentation](https://swiftpacka The [Spezi Template Application](https://github.com/StanfordSpezi/SpeziTemplateApplication) provides a great starting point and example using the Spezi Account module. - ## Contributing Contributions to this project are welcome. Please make sure to read the [contribution guidelines](https://github.com/StanfordSpezi/.github/blob/main/CONTRIBUTING.md) and the [contributor covenant code of conduct](https://github.com/StanfordSpezi/.github/blob/main/CODE_OF_CONDUCT.md) first. diff --git a/Sources/SpeziAccount/Account.swift b/Sources/SpeziAccount/Account.swift index f1784e3b..14ca3ac3 100644 --- a/Sources/SpeziAccount/Account.swift +++ b/Sources/SpeziAccount/Account.swift @@ -1,31 +1,243 @@ // // This source file is part of the Spezi open-source project // -// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) // // SPDX-License-Identifier: MIT // +import os import Spezi import SwiftUI -/// Account-related Spezi module managing a collection of ``AccountService``s. -/// -/// The ``Account/Account`` type also enables interaction with the ``AccountService``s from anywhere in the view hierachy. -public actor Account: ObservableObject { - /// The ``Account/Account/signedIn`` determines if the the current Account context is signed in or not yet signed in. - @MainActor @Published public var signedIn = false - +/// The primary entry point for UI components and ``AccountService``s to interact with ``SpeziAccount`` interfaces. +/// +/// The `Account` object is responsible to manage the state of the currently logged in user. +/// You can simply access the currently ``signedIn`` state of the user (or via the `$signedIn` publisher) or +/// access the account information from the ``details`` property (or via the `$details` publisher). +/// +/// - Note: For more information on how to access and use the `Account` object when implementing a custom ``AccountService`` +/// refer to the article. +/// +/// ### Accessing `Account` in your view +/// +/// To access the `Account` object from anywhere in your view hierarchy (assuming you have ``AccountConfiguration`` configured), +/// you may just declare the respective [@EnvironmentObject](https://developer.apple.com/documentation/swiftui/environmentobject) +/// property wrapper as in the code sample below. +/// +/// ```swift +/// struct MyView: View { +/// @EnvironmentObject var account: Account +/// +/// var body: some View { +/// if let details = account.details { +/// Text("Hello \(details.name.formatted(.name(style: .medium)))") +/// } else { +/// Text("Hello World") +/// } +/// } +/// } +/// ``` +/// +/// ## Topics +/// +/// ### Retrieving Account state +/// This section provides an overview on how to retrieve the currently logged in user from your views. +/// +/// - ``signedIn`` +/// - ``details`` +/// +/// ### Managing Account state +/// This section provides an overview on how to manage and manipulate the current user account as an ``AccountService``. +/// +/// - ``supplyUserDetails(_:)`` +/// - ``removeUserDetails()`` +/// +/// ### Initializers for your Preview Provider +/// +/// - ``init(services:configuration:)`` +/// - ``init(_:configuration:)`` +/// - ``init(building:active:configuration:)`` +@MainActor +public class Account: ObservableObject, Sendable { + private let logger: Logger + + /// The `signedIn` property determines if the the current Account context is signed in or not yet signed in. + /// + /// You might use the projected value `$signedIn` to get access to the corresponding publisher. + /// + /// - Important: If the property is set to `true`, it is guaranteed that ``details`` is present. + /// This has the following implications. When `signedIn` is `false`, there might still be a `details` instance present. + /// Similarly, when `details` is set to `nil, `signedIn` is guaranteed to be `false`. Otherwise, + /// if `details` is set to some value, the `signedIn` property might still be set to `false`. + @Published public private(set) var signedIn: Bool + + /// Provides access to associated data of the currently associated user account. + /// + /// The ``AccountDetails`` acts as a typed collection and is implemented as a + /// [Shared Repository](https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/shared-repository). + /// + /// - Note: The associated ``AccountService`` that is responsible for managing the associated user can be retrieved + /// using the ``AccountDetails/accountService`` property. + @Published public private(set) var details: AccountDetails? + + /// The user-defined configuration of account values that all user accounts need to support. + public let configuration: AccountValueConfiguration + /// An account provides a collection of ``AccountService``s that are used to populate login, sign up, or reset password screens. - nonisolated let accountServices: [any AccountService] - - - /// - Parameter accountServices: An account provides a collection of ``AccountService``s that are used to populate login, sign up, or reset password screens. - public init(accountServices: [any AccountService]) { - self.accountServices = accountServices - for accountService in accountServices { - accountService.inject(account: self) + /// + /// - Note: This array also contains ``IdentityProvider``s that need to be treated differently due to differing + /// ``AccountSetupViewStyle`` implementations (see ``IdentityProviderViewStyle``). + let registeredAccountServices: [any AccountService] + + /// Initialize a new `Account` object by providing all properties individually. + /// - Parameters: + /// - services: A collection of ``AccountService`` that are used to handle account-related functionality. + /// - supportedConfiguration: The ``AccountValueConfiguration`` to user intends to support. + /// - details: A initial ``AccountDetails`` object. The ``signedIn`` is set automatically based on the presence of this argument. + private nonisolated init( + services: [any AccountService], + supportedConfiguration: AccountValueConfiguration = .default, + details: AccountDetails? = nil + ) { + self.logger = LoggerKey.defaultValue + + self._signedIn = Published(wrappedValue: details != nil) + self._details = Published(wrappedValue: details) + self.configuration = supportedConfiguration + self.registeredAccountServices = services + + if supportedConfiguration[UserIdKey.self] == nil { + logger.warning( + """ + Your AccountConfiguration doesn't have the \\.userId (aka. UserIdKey) configured. \ + A primary and unique user identifier is expected with most SpeziAccount components and \ + will result in those components breaking. + """ + ) + } + + for service in registeredAccountServices { + injectWeakAccount(into: service) + } + } + + /// Initializes a new `Account` object without a logged in user for usage within a `PreviewProvider`. + /// + /// To use this within your `PreviewProvider` just supply it to a `environmentObject(_:)` modified in your view hierarchy. + /// - Parameters: + /// - services: A collection of ``AccountService`` that are used to handle account-related functionality. + /// - configuration: The ``AccountValueConfiguration`` to user intends to support. + public nonisolated convenience init( + services: [any AccountService], + configuration: AccountValueConfiguration = .default + ) { + self.init(services: services, supportedConfiguration: configuration) + } + + /// Initializes a new `Account` object without a logged in user for usage within a `PreviewProvider`. + /// + /// To use this within your `PreviewProvider` just supply it to a `environmentObject(_:)` modified in your view hierarchy. + /// - Parameters: + /// - services: A collection of ``AccountService`` that are used to handle account-related functionality. + /// - configuration: The ``AccountValueConfiguration`` to user intends to support. + public nonisolated convenience init( + _ services: any AccountService..., + configuration: AccountValueConfiguration = .default + ) { + self.init(services: services, supportedConfiguration: configuration) + } + + /// Initializes a new `Account` object with a logged in user for usage within a `PreviewProvider`. + /// + /// To use this within your `PreviewProvider` just supply it to a `environmentObject(_:)` modified in your view hierarchy. + /// - Parameters: + /// - builder: A ``AccountValuesBuilder`` for ``AccountDetails`` with all account details for the logged in user. + /// - accountService: The ``AccountService`` that is managing the provided ``AccountDetails``. + /// - configuration: The ``AccountValueConfiguration`` to user intends to support. + public nonisolated convenience init( + building builder: AccountDetails.Builder, + active accountService: Service, + configuration: AccountValueConfiguration = .default + ) { + self.init(services: [accountService], supportedConfiguration: configuration, details: builder.build(owner: accountService)) + } + + + nonisolated func injectWeakAccount(into value: Any) { + let mirror = Mirror(reflecting: value) + + for (_, value) in mirror.children { + if let weakReference = value as? _WeakInjectable { // see AccountService.AccountReference + weakReference.inject(self) + } else if let accountService = value as? any AccountService { + // allow for nested injection like in the case of `StandardBackedAccountService` + injectWeakAccount(into: accountService) + } + } + } + + /// Supply the ``AccountDetails`` of the currently logged in user. + /// + /// This method is called by the ``AccountService`` every time the state of the user account changes. + /// Either if the went from no logged in user to having a logged in user, or if the details of the user account changed. + /// + /// - Parameter details: The ``AccountDetails`` of the currently logged in user account. + public func supplyUserDetails(_ details: AccountDetails) async throws { + var details = details + + // Account details will always get built by the respective Account Service. Therefore, we need to patch it + // if they are wrapped into a StandardBacked one such that the `AccountDetails` carray the correct reference. + for service in registeredAccountServices { + if let standardBacked = service as? any StandardBacked, + standardBacked.isBacking(service: details.accountService) { + details.patchAccountService(service) + break + } + } + + if let existingDetails = self.details { + precondition( + existingDetails.accountService.id == details.accountService.id, + "The AccountService \(details.accountService) tried to overwrite `AccountDetails` from \(existingDetails.accountService)!" + ) + } + + if let standardBacked = details.accountService as? any StandardBacked { + let recordId = AdditionalRecordId(serviceId: standardBacked.backedId, userId: details.userId) + + let unsupportedKeys = details.accountService.configuration + .unsupportedAccountKeys(basedOn: configuration) + .map { $0.key } + + let partialDetails = try await standardBacked.standard.load(recordId, unsupportedKeys) + + self.details = details.merge(with: partialDetails, allowOverwrite: false) + } else { + self.details = details + } + + if !signedIn { + signedIn = true + } + } + + /// Removes the currently logged in user. + /// + /// This method is called by the currently active ``AccountService`` to remove the ``AccountDetails`` of the currently + /// signed in user and notify others that the user logged out (or the account was removed). + public func removeUserDetails() async { + if let details, + let standardBacked = details.accountService as? any StandardBacked { + let recordId = AdditionalRecordId(serviceId: standardBacked.backedId, userId: details.userId) + + await standardBacked.standard.clear(recordId) + } + + if signedIn { + signedIn = false } + details = nil } } diff --git a/Sources/SpeziAccount/AccountConfiguration.swift b/Sources/SpeziAccount/AccountConfiguration.swift new file mode 100644 index 00000000..b9406b4b --- /dev/null +++ b/Sources/SpeziAccount/AccountConfiguration.swift @@ -0,0 +1,169 @@ +// +// This source file is part of the Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Foundation +import Spezi +import XCTRuntimeAssertions + + +/// The Spezi `Component` to configure the ``SpeziAccount`` framework in the `Configuration` section of your app. +/// +/// This Spezi `Component` is used to configure the ``SpeziAccount`` framework, namely to collect and setup all ``AccountService`` +/// either provided directly or provided by other configured `Component`s. The global ``Account`` object will +/// be injected as an environment object into the view hierarchy of your app. +/// +/// ``AccountService`` can either be supplied directly via ``init(configuration:_:)`` or might be automatically collected from +/// other `Component`s that provide ``AccountService`` instances (like the `SpeziFirebase` framework). +/// +/// - Note: For more information on how to provide an ``AccountService`` if you are implementing your own Spezi `Component` +/// refer to the article. +public final class AccountConfiguration: Component, ObservableObjectProvider { + private let logger = LoggerKey.defaultValue + + /// The user-defined configuration of account values that all user accounts need to support. + private let configuredAccountKeys: AccountValueConfiguration + /// An array of ``AccountService``s provided directly in the initializer of the configuration object. + private let providedAccountServices: [any AccountService] + + private var account: Account? + + @StandardActor private var standard: any Standard + + /// The array of ``AccountService``s provided through other Spezi `Components`. + @Collect private var accountServices: [any AccountService] + + + public var observableObjects: [any ObservableObject] { + guard let account else { + preconditionFailure("Tried to access ObservableObjectProvider before \(Self.self).configure() was called") + } + + return [account] + } + + + /// Initializes a `AccountConfiguration` without directly providing any ``AccountService`` instances. + /// + /// ``AccountService`` instances might be automatically collected from other Spezi `Component`s that provide some. + /// + /// - Parameter configuration: The user-defined configuration of account values that all user accounts need to support. + public init(configuration: AccountValueConfiguration = .default) { + self.configuredAccountKeys = configuration + self.providedAccountServices = [] + } + + /// Initializes a `AccountConfiguration` by directly providing a set of ``AccountService`` instances. + /// + /// In addition to the supplied ``AccountService``s, ``SpeziAccount`` will collect any ``AccountService`` instances + /// provided by other Spezi `Component`s. + /// + /// - Parameters: + /// - configuration: The user-defined configuration of account values that all user accounts need to support. + /// - accountServices: Account Services provided through a ``AccountServiceBuilder``. + public init( + configuration: AccountValueConfiguration = .default, + @AccountServiceBuilder _ accountServices: () -> [any AccountService] + ) { + self.configuredAccountKeys = configuration + self.providedAccountServices = accountServices() + } + + + public func configure() { + // assemble the final array of account services + let accountServices = (providedAccountServices + self.accountServices).map { service in + // verify that the configuration matches what is expected by the account service + verifyAccountServiceRequirements(of: service) + + // Verify account service can store all configured account keys. + // If applicable, wraps the service into an StandardBackedAccountService + return verifyConfigurationRequirements(against: service) + } + + self.account = Account( + services: accountServices, + configuration: configuredAccountKeys + ) + + if let accountStandard = standard as? any AccountStorageStandard { + self.account?.injectWeakAccount(into: accountStandard) + } + } + + private func verifyAccountServiceRequirements(of service: any AccountService) { + let requiredValues = service.configuration.requiredAccountKeys + + // A collection of AccountKey.Type which aren't configured by the user or not configured to be required + // but the Account Service requires them. + let mismatchedKeys: [any AccountKeyWithDescription] = requiredValues.filter { keyWithDescription in + let key = keyWithDescription.key + let configuration = configuredAccountKeys[key] + return configuration == nil + || (key.isRequired && configuration?.requirement != .required) + } + + guard !mismatchedKeys.isEmpty else { + return + } + + // Note: AccountKeyWithDescription has a nice `debugDescription` that pretty prints the KeyPath property name + preconditionFailure( + """ + You configured the AccountService \(service) which requires the following account values to be configured: \ + \(mismatchedKeys.description). + + Please modify your `AccountServiceConfiguration` to have these account values configured. + """ + ) + } + + private func verifyConfigurationRequirements(against service: any AccountService) -> any AccountService { + logger.debug("Checking \(service.description) against the configured account keys.") + + // collect all values that cannot be handled by the account service + let unmappedAccountKeys: [any AccountKeyConfiguration] = service.configuration + .unsupportedAccountKeys(basedOn: configuredAccountKeys) + + guard !unmappedAccountKeys.isEmpty else { + return service // we are fine, nothing unsupported + } + + + if let accountStandard = standard as? any AccountStorageStandard { + // we are also fine, we have a standard that can store any unsupported account values + logger.debug(""" + The standard \(accountStandard.description) is used to store the following account values that \ + are unsupported by the Account Service \(service.description): \(unmappedAccountKeys.debugDescription) + + """) + return service.backedBy(standard: accountStandard) + } + + // When we reach here, we have no way to store the configured account value + // Note: AnyAccountValueConfigurationEntry has a nice `debugDescription` that pretty prints the KeyPath property name + preconditionFailure( + """ + Your `AccountConfiguration` lists the following account values "\(unmappedAccountKeys.debugDescription)" which are + not supported by the Account Service \(service.description)! + + The Account Service \(service.description) indicated that it cannot store the above-listed account values. + + In order to proceed you may use a Standard inside your Spezi Configuration that conforms to \ + `AccountStorageStandard` which handles storage of the above-listed account values. Otherwise, you may \ + remove the above-listed account values from your SpeziAccount configuration. + """ + ) + } +} + + +extension Standard { + fileprivate nonisolated var description: String { + "\(Self.self)" + } +} diff --git a/Sources/SpeziAccount/AccountOverview.swift b/Sources/SpeziAccount/AccountOverview.swift new file mode 100644 index 00000000..d871bc32 --- /dev/null +++ b/Sources/SpeziAccount/AccountOverview.swift @@ -0,0 +1,93 @@ +// +// This source file is part of the Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SpeziViews +import SwiftUI + + +/// The essential ``SpeziAccount`` view to view and modify the active account details. +/// +/// This provides an overview of the current account details. Further, it allows the user to modify their +/// account values. +/// +/// This view requires a currently logged in user (see ``Account/details``). +/// Further, this view relies on an ``Account`` object in its environment. This is done automatically by providing a +/// ``AccountConfiguration`` in the configuration section of your `Spezi` app delegate. +/// +/// - Note: In SwiftUI previews you can easily instantiate your own ``Account``. Use the ``Account/init(building:active:configuration:)`` +/// initializer to create a new `Account` object with active ``AccountDetails``. +/// +/// Below is a short code example on how to use the `AccountOverview` view. +/// +/// ```swift +/// struct MyView: View { +/// var body: some View { +/// AccountOverview() +/// } +/// } +/// +/// - Note: The ``init(isEditing:)`` initializer allows to pass an optional `Bool` Binding to retrieve the +/// current edit mode of the view. This can be helpful to, e.g., render a custom `Close` Button if the +/// view is not editing when presenting the AccountOverview in a sheet. +/// ``` +public struct AccountOverview: View { + @EnvironmentObject private var account: Account + + @Binding private var isEditing: Bool + + public var body: some View { + NavigationStack { + if let details = account.details { + Form { + // Splitting everything into a separate subview was actually necessary for the EditMode to work. + // Not even the example that Apple provides for the EditMode works. See https://developer.apple.com/forums/thread/716434 + AccountOverviewSections( + account: account, + details: details, + isEditing: $isEditing + ) + } + .padding(.top, -20) + } else { + Spacer() + MissingAccountDetailsWarning() + .padding(.horizontal, ViewSizing.outerHorizontalPadding) + Spacer() + Spacer() + Spacer() + } + } + .navigationTitle(Text("ACCOUNT_OVERVIEW", bundle: .module)) + .navigationBarTitleDisplayMode(.inline) + } + + + /// Display a new Account Overview. + /// - Parameter isEditing: A Binding that allows you to read the current editing state of the Account Overview view. + public init(isEditing: Binding = .constant(false)) { + self._isEditing = isEditing + } +} + + +#if DEBUG +struct AccountOverView_Previews: PreviewProvider { + static let details = AccountDetails.Builder() + .set(\.userId, value: "andi.bauer@tum.de") + .set(\.name, value: PersonNameComponents(givenName: "Andreas", familyName: "Bauer")) + .set(\.genderIdentity, value: .male) + + static var previews: some View { + AccountOverview() + .environmentObject(Account(building: details, active: MockUserIdPasswordAccountService())) + + AccountOverview() + .environmentObject(Account()) + } +} +#endif diff --git a/Sources/SpeziAccount/AccountService.swift b/Sources/SpeziAccount/AccountService.swift deleted file mode 100644 index 233ee840..00000000 --- a/Sources/SpeziAccount/AccountService.swift +++ /dev/null @@ -1,40 +0,0 @@ -// -// This source file is part of the Spezi open-source project -// -// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -import SwiftUI - - -/// Describe the mechanism for account management components to display login, signUp, and account-related UI elements. -/// -/// You can learn more about creating an account service at: . -public protocol AccountService: Sendable, AnyObject, Identifiable { - /// A `View` erased as an `AnyView` that will be displayd in login-related user interfaces. - var loginButton: AnyView { get } - /// A `View` erased as an `AnyView` that will be displayd in sign up-related user interfaces. - var signUpButton: AnyView { get } - - - /// Injects an ``Account`` instance in a ``AccountService`` instance. - /// - Parameter account: The ``Account`` instance used to store information retrieved in the `AccountService`. - func inject(account: Account) -} - - -extension AccountService { - // A documentation for this methodd exists in the `AccountService` type which SwiftLint doesn't recognize. - // swiftlint:disable:next missing_docs - public var signUpButton: AnyView { - loginButton - } - - // A documentation for this methodd exists in the `Identifiable` type which SwiftLint doesn't recognize. - // swiftlint:disable:next missing_docs - public var id: String { - String(describing: type(of: self)) - } -} diff --git a/Sources/SpeziAccount/AccountService/AccountService+Properties.swift b/Sources/SpeziAccount/AccountService/AccountService+Properties.swift new file mode 100644 index 00000000..01b6fe76 --- /dev/null +++ b/Sources/SpeziAccount/AccountService/AccountService+Properties.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 +// + + +extension AccountService { + /// A property wrapper that can be used within ``AccountService`` instances to request + /// access to the global ``Account`` instance. + /// + /// Below is a short code example on how to use this property wrapper: + /// ```swift + /// public actor MyAccountService: AccountService { + /// @AccountReference var account + /// } + /// ``` + public typealias AccountReference = _WeakInjectable +} diff --git a/Sources/SpeziAccount/AccountService/AccountService.swift b/Sources/SpeziAccount/AccountService/AccountService.swift new file mode 100644 index 00000000..b188dd22 --- /dev/null +++ b/Sources/SpeziAccount/AccountService/AccountService.swift @@ -0,0 +1,111 @@ +// +// This source file is part of the Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Spezi +import SwiftUI + + +/// A `AccountService` is a set of components that is capable of setting up and managing the ``AccountDetails`` for the global ``Account`` context. +/// +/// This protocol imposes the minimal requirements for an `AccountService` where most of the account-related procedures +/// are entirely application defined. This protocol requires functionality for account signup, modifications, +/// logout and removal. +/// +/// You may improve the user experience or rely on user interface defaults if you adopt protocols like +/// ``EmbeddableAccountService`` or ``UserIdPasswordAccountService``. +/// +/// - Note: `SpeziAccount` provides the generalized ``UserIdKey`` unique user identifier that can be customized +/// using the ``UserIdConfiguration``. +/// +/// You can learn more about creating an account service at: . +/// +/// ## Topics +/// +/// ### Result Builder +/// - ``AccountServiceBuilder`` +public protocol AccountService: AnyObject, Hashable, CustomStringConvertible, Sendable { + /// The ``AccountSetupViewStyle`` will be used to customized the look and feel of the ``AccountSetup`` view. + associatedtype ViewStyle: AccountSetupViewStyle + + /// An identifier to uniquely identify an `AccountService`. + /// + /// This identifier is used to uniquely identify an account service that persists across process instances. + /// + /// - Important: A default implementation is defined that relies on the type name. If you rename the account service + /// type without supplying a manual `id` implementation, components like a ``AccountStorageStandard`` won't + /// be able to associate existing user details with this account service. + var id: String { get } + + /// The configuration of the account service. + var configuration: AccountServiceConfiguration { get } + + /// A ``AccountSetupViewStyle`` that is capable of rendering UI elements associated with the account service. + /// + /// - Note: Define this as a computed property to resolve the cyclic type dependence. + var viewStyle: ViewStyle { get } + + + /// Create a new user account for the provided ``SignupDetails``. + /// + /// - Note: You must call ``Account/supplyUserDetails(_:)`` eventually once the user context was established after this call. + /// - Parameter signupDetails: The signup details + /// - Throws: Throw an `Error` type conforming to `LocalizedError` if the signup operation was unsuccessful, + /// inorder to present a localized description to the user. + /// Make sure to remain in a state where the user can easily retry the signup operation. + func signUp(signupDetails: SignupDetails) async throws + + /// This method implements account logout functionality. + /// + /// - Throws: Throw an `Error` type conforming to `LocalizedError` if the logout was unsuccessful, + /// inorder to present a localized description to the user. + /// Make sure to remain in a state where the user can easily retry the logout operation. + func logout() async throws + + /// This method implements account deletion. + /// + /// This method should delete the account and all associated data of the currently signed in user. + /// + /// - Throws: Throw an `Error` type conforming to `LocalizedError` if the removal was unsuccessful, + /// inorder to present a localized description to the user. + /// Make sure to remain in a state where the user can easily retry the removal operation. + func delete() async throws + + /// This method implements modifications to the ``AccountDetails``. + /// - Parameter modifications: The account modifications listing added, updated and removed account values. + /// - Throws: Throw an `Error` type conforming to `LocalizedError` if the modify operation was unsuccessful, + /// inorder to present a localized description to the user. + /// Make sure to remain in a state where the user can easily retry the removal operation. + func updateAccountDetails(_ modifications: AccountModifications) async throws +} + + +extension AccountService { + /// Default implementation that uses the type name as an unique identifier. + public var id: String { + description + } + + var objId: ObjectIdentifier { + ObjectIdentifier(self) + } + + /// Default `CustomStringConvertible` returning the type name. + public var description: String { + "\(Self.self)" + } + + /// Default `Equatable` implementation by relying on the hashable ``AccountService/id-83c6c`` property. + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.id == rhs.id + } + + /// Default `Hashable` implementation by relying on the hashable ``AccountService/id-83c6c`` property. + public func hash(into hasher: inout Hasher) { + id.hash(into: &hasher) + } +} diff --git a/Sources/SpeziAccount/AccountService/AccountServiceBuilder.swift b/Sources/SpeziAccount/AccountService/AccountServiceBuilder.swift new file mode 100644 index 00000000..73c60d1a --- /dev/null +++ b/Sources/SpeziAccount/AccountService/AccountServiceBuilder.swift @@ -0,0 +1,49 @@ +// +// 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 +// + +/// A result builder to build a collection of ``AccountService``s. +@resultBuilder +public enum AccountServiceBuilder { + /// Build a single ``AccountService`` expression. + public static func buildExpression(_ service: Service) -> [any AccountService] { + [service] + } + + /// Build a block of ``AccountService``s. + public static func buildBlock(_ components: [any AccountService]...) -> [any AccountService] { + buildArray(components) + } + + /// Build the first block of an conditional ``AccountService`` component. + public static func buildEither(first component: [any AccountService]) -> [any AccountService] { + component + } + + /// Build the second block of an conditional ``AccountService`` component. + public static func buildEither(second component: [any AccountService]) -> [any AccountService] { + component + } + + /// Build an optional ``AccountService`` component. + public static func buildOptional(_ component: [any AccountService]?) -> [any AccountService] { + // swiftlint:disable:previous discouraged_optional_collection + component ?? [] + } + + /// Build an ``AccountService`` component with limited availability. + public static func buildLimitedAvailability(_ component: [any AccountService]) -> [any AccountService] { + component + } + + /// Build an array of ``AccountService`` components. + public static func buildArray(_ components: [[any AccountService]]) -> [any AccountService] { + components.reduce(into: []) { result, services in + result.append(contentsOf: services) + } + } +} diff --git a/Sources/SpeziAccount/AccountService/Configuration/AccountServiceConfiguration.swift b/Sources/SpeziAccount/AccountService/Configuration/AccountServiceConfiguration.swift new file mode 100644 index 00000000..13a40856 --- /dev/null +++ b/Sources/SpeziAccount/AccountService/Configuration/AccountServiceConfiguration.swift @@ -0,0 +1,98 @@ +// +// 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 +// + +import Foundation +import Spezi +import SwiftUI + + +/// A `RepositoryAnchor` for ``AccountServiceConfigurationStorage``. +public struct AccountServiceConfigurationStorageAnchor: RepositoryAnchor, Sendable {} + + +/// A `ValueRepository` that is anchored to ``AccountServiceConfigurationStorageAnchor``. +/// +/// This is the underlying storage type for the ``AccountServiceConfiguration`` to store instances of ``AccountServiceConfigurationKey``. +public typealias AccountServiceConfigurationStorage = ValueRepository + + +/// Configuration options that are provided by an ``AccountService``. +/// +/// A instance of this type is required to be provided by every ``AccountService``. It is used to +/// set and communicate certain configuration options of the account service to the UI components that +/// represent the account service (e.g., determining the type of userId through ``UserIdConfiguration`` or +/// providing ``ValidationRule``s for a field through the ``FieldValidationRules`` configuration). +/// +/// For more information on how to provide custom configuration options, refer to the documentation of +/// ``AccountServiceConfigurationKey``. +/// +/// ## Topics +/// +/// ### Retrieving configuration +/// Below is a list of configuration options built into the ``SpeziAccount`` framework. +/// +/// - ``name`` +/// - ``image`` +/// - ``userIdConfiguration`` +/// - ``fieldValidationRules(for:)-5n7c0`` +/// - ``fieldValidationRules(for:)-2nqpi`` +/// +/// ### Result Builder +/// - ``AccountServiceConfigurationKey`` +/// - ``AccountServiceConfigurationBuilder`` +/// +/// ### Shared Repository +/// - ``AccountServiceConfigurationStorageAnchor`` +/// - ``AccountServiceConfigurationStorage`` +public struct AccountServiceConfiguration: Sendable { + /// The underlying storage container you access to implement your own ``AccountServiceConfigurationKey``. + public let storage: AccountServiceConfigurationStorage + + + /// Initialize a new configuration by just providing the required ones. + /// - Parameters: + /// - name: The name of the ``AccountService``. Refer to ``AccountServiceName`` for more information. + /// - supportedKeys: The set of ``SupportedAccountKeys`` the ``AccountService`` is capable of storing itself. + /// If ``SupportedAccountKeys/exactly(_:)`` is chosen, the user is responsible of providing a ``AccountStorageStandard`` + /// that is capable of handling all non-supported ``AccountKey``s. + public init(name: LocalizedStringResource, supportedKeys: SupportedAccountKeys) { + self.storage = Self.createStorage(name: name, supportedKeys: supportedKeys) + } + + /// Initialize a new configuration by providing additional configurations. + /// - Parameters: + /// - name: The name of the ``AccountService``. Refer to ``AccountServiceName`` for more information. + /// - supportedKeys: The set of ``SupportedAccountKeys`` the ``AccountService`` is capable of storing itself. + /// If ``SupportedAccountKeys/exactly(_:)`` is chosen, the user is responsible of providing a ``AccountStorageStandard`` + /// that is capable of handling all non-supported ``AccountKey``s. + /// - configuration: A ``AccountServiceConfigurationBuilder`` to provide a list of ``AccountServiceConfigurationKey``s. + public init( + name: LocalizedStringResource, + supportedKeys: SupportedAccountKeys, + @AccountServiceConfigurationBuilder configuration: () -> [any AccountServiceConfigurationKey] + ) { + self.storage = Self.createStorage(name: name, supportedKeys: supportedKeys, configuration: configuration()) + } + + + private static func createStorage( + name: LocalizedStringResource, + supportedKeys: SupportedAccountKeys, + configuration: [any AccountServiceConfigurationKey] = [] + ) -> AccountServiceConfigurationStorage { + var storage = AccountServiceConfigurationStorage() + storage[AccountServiceName.self] = AccountServiceName(name) + storage[SupportedAccountKeys.self] = supportedKeys + + for configuration in configuration { + configuration.store(into: &storage) + } + + return storage + } +} diff --git a/Sources/SpeziAccount/AccountService/Configuration/AccountServiceConfigurationBuilder.swift b/Sources/SpeziAccount/AccountService/Configuration/AccountServiceConfigurationBuilder.swift new file mode 100644 index 00000000..1bfdf8f1 --- /dev/null +++ b/Sources/SpeziAccount/AccountService/Configuration/AccountServiceConfigurationBuilder.swift @@ -0,0 +1,50 @@ +// +// 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 +// + + +/// A result builder to build a collection of ``AccountServiceConfigurationKey``s. +@resultBuilder +public enum AccountServiceConfigurationBuilder { + /// Build a single ``AccountServiceConfigurationKey`` expression. + public static func buildExpression(_ expression: Key) -> [any AccountServiceConfigurationKey] { + [expression] + } + + /// Build a block of ``AccountServiceConfigurationKey``s. + public static func buildBlock(_ components: [any AccountServiceConfigurationKey]...) -> [any AccountServiceConfigurationKey] { + buildArray(components) + } + + /// Build the first block of an conditional ``AccountServiceConfigurationKey`` component. + public static func buildEither(first component: [any AccountServiceConfigurationKey]) -> [any AccountServiceConfigurationKey] { + component + } + + /// Build the second block of an conditional ``AccountServiceConfigurationKey`` component. + public static func buildEither(second component: [any AccountServiceConfigurationKey]) -> [any AccountServiceConfigurationKey] { + component + } + + /// Build an optional ``AccountServiceConfigurationKey`` component. + public static func buildOptional(_ component: [any AccountServiceConfigurationKey]?) -> [any AccountServiceConfigurationKey] { + // swiftlint:disable:previous discouraged_optional_collection + component ?? [] + } + + /// Build an ``AccountServiceConfigurationKey`` component with limited availability. + public static func buildLimitedAvailability(_ component: [any AccountServiceConfigurationKey]) -> [any AccountServiceConfigurationKey] { + component + } + + /// Build an array of ``AccountServiceConfigurationKey`` components. + public static func buildArray(_ components: [[any AccountServiceConfigurationKey]]) -> [any AccountServiceConfigurationKey] { + components.reduce(into: []) { result, components in + result.append(contentsOf: components) + } + } +} diff --git a/Sources/SpeziAccount/AccountService/Configuration/AccountServiceConfigurationKey.swift b/Sources/SpeziAccount/AccountService/Configuration/AccountServiceConfigurationKey.swift new file mode 100644 index 00000000..f4117a87 --- /dev/null +++ b/Sources/SpeziAccount/AccountService/Configuration/AccountServiceConfigurationKey.swift @@ -0,0 +1,45 @@ +// +// 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 +// + +import Spezi + + +/// A `KnowledgeSource` that implements a configuration option for ``AccountServiceConfiguration``. +/// +/// `AccountServiceConfigurationKey` are `KnowledgeSource`s which are anchored to the ``AccountServiceConfigurationStorageAnchor`` +/// and have the requirement that the protocol-adopting type is the `Value` itself. +/// +/// Below is a minimal code example on how to implement your own `AccountServiceConfigurationKey` and make it easily +/// accessible via an extension to the ``AccountServiceConfiguration``. +/// +/// ```swift +/// public struct MyOwnOption: AccountServiceConfigurationKey { +/// public let myOptionString: String +/// } +/// +/// extension AccountServiceConfiguration { +/// public var myOption: String { +/// // you may also just return the `MyOwnOption` as a whole if it makes sense +/// storage[MyOwnOption.self].myOptionString +/// } +/// } +/// ``` +/// +/// - Note: Refer to the [Shared Repository](https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/shared-repository) +/// documentation to leverage the full potential of what's possible with `KnowledgeSource`s. Particularly, how to provide default +/// values or compute the value dependent on other configuration options. +public protocol AccountServiceConfigurationKey: KnowledgeSource, Sendable where Value == Self {} + + +extension AccountServiceConfigurationKey { + /// This method is used internally to store the instance into the a ``AccountServiceConfigurationStorage`` + /// when having a type-erased view on the instance. + func store(into repository: inout AccountServiceConfigurationStorage) { + repository[Self.self] = self + } +} diff --git a/Sources/SpeziAccount/AccountService/Configuration/AccountServiceImage.swift b/Sources/SpeziAccount/AccountService/Configuration/AccountServiceImage.swift new file mode 100644 index 00000000..32f89fba --- /dev/null +++ b/Sources/SpeziAccount/AccountService/Configuration/AccountServiceImage.swift @@ -0,0 +1,40 @@ +// +// 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 +// + +import Spezi +import SwiftUI + + +/// A SwiftUI `Image` to visualize an ``AccountService``. +/// +/// UI components may use this configuration to visually refer to an ``AccountService``. +/// +/// Access the configuration via the ``AccountServiceConfiguration/image`` property. +public struct AccountServiceImage: AccountServiceConfigurationKey, DefaultProvidingKnowledgeSource, @unchecked Sendable { + public static var defaultValue: AccountServiceImage { + AccountServiceImage(Image(systemName: "person.crop.circle.fill") + .symbolRenderingMode(.hierarchical)) + } + + /// The SwiftUI `Image` of the ``AccountService`` + public let image: Image + + /// Initialize a new `AccountServiceImage`. + /// - Parameter image: The SwiftUI `Image` of the ``AccountService``. + public init(_ image: Image) { + self.image = image + } +} + + +extension AccountServiceConfiguration { + /// Access the SwiftUI `Image` of an ``AccountService``. + public var image: Image { + storage[AccountServiceImage.self].image + } +} diff --git a/Sources/SpeziAccount/AccountService/Configuration/AccountServiceName.swift b/Sources/SpeziAccount/AccountService/Configuration/AccountServiceName.swift new file mode 100644 index 00000000..f2229593 --- /dev/null +++ b/Sources/SpeziAccount/AccountService/Configuration/AccountServiceName.swift @@ -0,0 +1,42 @@ +// +// 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 +// + +import Foundation +import Spezi + + +/// The localized name of an ``AccountService``. +/// +/// UI components may use this configuration to textually refer to an ``AccountService``. +/// +/// Access the configuration via the ``AccountServiceConfiguration/name`` property. +public struct AccountServiceName: AccountServiceConfigurationKey, DefaultProvidingKnowledgeSource { + public static var defaultValue: AccountServiceName { + preconditionFailure("Reached illegal state where AccountServiceName configuration was never supplied!") + } + + /// The localized name of the ``AccountService``. + public let name: LocalizedStringResource + + + /// Initialize a new `AccountServiceName`. + /// + /// This initializer is internal-access only, as it is required by the ``AccountServiceConfiguration`` initializer. + /// - Parameter name: The localized name of the ``AccountService``. + init(_ name: LocalizedStringResource) { + self.name = name + } +} + + +extension AccountServiceConfiguration { + /// Access the localized name of an ``AccountService``. + public var name: LocalizedStringResource { + storage[AccountServiceName.self].name + } +} diff --git a/Sources/SpeziAccount/AccountService/Configuration/FieldValidationRules.swift b/Sources/SpeziAccount/AccountService/Configuration/FieldValidationRules.swift new file mode 100644 index 00000000..db9921d9 --- /dev/null +++ b/Sources/SpeziAccount/AccountService/Configuration/FieldValidationRules.swift @@ -0,0 +1,117 @@ +// +// 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 +// + +import Spezi + + +/// A list of ``ValidationRule`` to validate the input for String-based ``AccountKey``s. +/// +/// You can use this configuration to set up ``ValidationRule`` used by an ``ValidationEngine`` for any string-based +/// ``AccountKey``. Input fields (e.g., placed in signup or edit forms) use those rules to validate the received string input +/// against the provided value. +/// +/// Below is a minimal code example on how to configure ``ValidationRule``s for the `userId` and `password` account values: +/// ```swift +/// public actor SomeAccountService: AccountService { +/// public let configuration = AccountServiceConfiguration(name: "Some name") { +/// FieldValidationRules(for: \.userId, rules: .interceptingChain(.nonEmpty), .minimalEmail) +/// FieldValidationRules(for: \.password, rules: .interceptingChain(.nonEmpty), .strongPassword) +/// } +/// } +/// ``` +/// +/// - Note: When using built-in views like ``SignupForm`` that use the ``GeneralizedDataEntryView``, a ``ValidationEngine`` +/// with the configured validation rules is automatically injected using the ``SwiftUI/View/managedValidation(input:for:rules:)-5gj5g`` +/// or ``SwiftUI/View/managedValidation(input:for:rules:)-zito`` modifier. +/// +/// ### Default Values +/// The configuration provides the following default validation rules depending on the context: +/// * ``ValidationRule/nonEmpty`` (intercepting) and ``ValidationRule/minimalEmail`` if the `Key` is of type ``UserIdKey`` and the user id type is ``UserIdType/emailAddress`` +/// or if the `Key` is of type ``EmailAddressKey``. +/// * ``ValidationRule/nonEmpty`` (intercepting) and ``ValidationRule/minimalPassword`` if the `Key` is of type ``PasswordKey``. +/// * ``ValidationRule/nonEmpty`` otherwise. +public struct FieldValidationRules: AccountServiceConfigurationKey, OptionalComputedKnowledgeSource where Key.Value == String { + // We use always compute, as we don't want our computation result to get stored. We don't have a mutable view anyways. + public typealias StoragePolicy = AlwaysCompute + + /// The ``AccountKey`` type for which this instance provides validation rules. + public let key: Key.Type + /// The list of ``ValidationRule`` a new value is validated against. + public let validationRules: [ValidationRule] + + + /// Initialize a new `FieldValidationRules`. + /// - Parameters: + /// - key: The ``AccountKey`` type. + /// - validationRules: The array of ``ValidationRule``s. + public init(for key: Key.Type, rules validationRules: [ValidationRule]) { + self.key = key + self.validationRules = validationRules + } + + /// Initialize a new `FieldValidationRules`. + /// - Parameters: + /// - key: The ``AccountKey`` type. + /// - validationRules: The array of ``ValidationRule``s supplied as variadic arguments. + public init(for key: Key.Type, rules validationRules: ValidationRule...) { + self.init(for: key, rules: validationRules) + } + + /// Initialize a new `FieldValidationRules`. + /// - Parameters: + /// - keyPath: The ``AccountKey`` type supplied as a `KeyPath`. + /// - validationRules: The array of ``ValidationRule``s. + public init(for keyPath: KeyPath, rules validationRules: [ValidationRule]) { + self.init(for: Key.self, rules: validationRules) + } + + /// Initialize a new `FieldValidationRules`. + /// - Parameters: + /// - keyPath: The ``AccountKey`` type supplied as a `KeyPath`. + /// - validationRules: The array of ``ValidationRule``s supplied as variadic arguments. + public init(for keyPath: KeyPath, rules validationRules: ValidationRule...) { + self.init(for: Key.self, rules: validationRules) + } + + + public static func compute>(from repository: Repository) -> FieldValidationRules? { + if let value = repository.get(Self.self) { + return value // either the user configured a value themselves + } + + // or we return a default based on the Key type and the current configuration environment + if Key.self == UserIdKey.self && repository[UserIdConfiguration.self].idType == .emailAddress + || Key.self == EmailAddressKey.self { + return FieldValidationRules(for: Key.self, rules: .nonEmpty.intercepting, .minimalEmail) + } else if Key.self == PasswordKey.self { + return FieldValidationRules(for: Key.self, rules: .nonEmpty.intercepting, .minimalPassword) + } else { + // we cannot statically determine here if the user may have configured the Key to be required + return nil + } + } +} + + +extension AccountServiceConfiguration { + /// Access the validation rules for String-based ``AccountKey`` configured by an ``AccountService``. + /// - Parameter key: The ``AccountKey`` type. + /// - Returns: The array of ``ValidationRule``s. + public func fieldValidationRules(for key: Key.Type) -> [ValidationRule]? where Key.Value == String { + storage[FieldValidationRules.self]?.validationRules + } + + /// Access the validation rules for String-based ``AccountKey`` configured by an ``AccountService``. + /// - Parameter keyPath: The ``AccountKey`` type supplied as `KeyPath`. + /// - Returns: The array of ``ValidationRule``s. + public func fieldValidationRules( + for keyPath: KeyPath + ) -> [ValidationRule]? where Key.Value == String { + fieldValidationRules(for: Key.self) + } +} diff --git a/Sources/SpeziAccount/AccountService/Configuration/RequiredAccountKeys.swift b/Sources/SpeziAccount/AccountService/Configuration/RequiredAccountKeys.swift new file mode 100644 index 00000000..ceea4bfc --- /dev/null +++ b/Sources/SpeziAccount/AccountService/Configuration/RequiredAccountKeys.swift @@ -0,0 +1,59 @@ +// +// 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 +// + +import Spezi + + +/// The collection of ``AccountKey``s that are required to use the associated ``AccountService``. +/// +/// A ``AccountService`` may set this configuration to communicate that a certain set of ``AccountKey``s are +/// required to be configured in the ``AccountValueConfiguration`` provided in the ``AccountConfiguration`` in +/// order to user the account service. +/// +/// Upon startup, `SpeziAccount` automatically verifies that the user-configured account values match the expectation +/// set by the ``AccountService`` through this configuration option. +/// +/// Access the configuration via the ``AccountServiceConfiguration/requiredAccountKeys``. +/// +/// Below is an example on how to provide this option. +/// +/// ```swift +/// let configuration = AccountServiceConfiguration(/* ... */) { +/// RequiredAccountKeys { +/// \.userId +/// \.password +/// } +/// } +public struct RequiredAccountKeys: AccountServiceConfigurationKey, DefaultProvidingKnowledgeSource { + public static let defaultValue = RequiredAccountKeys { + \.userId // by default everyone requires the userId + } + + fileprivate let keys: AccountKeyCollection + + + /// Initialize new `RequiredAccountKeys` by providing the keys in the closure. + /// - Parameter keys: The result builder closure providing the keys using `KeyPath` notation. + public init(@AccountKeyCollectionBuilder _ keys: () -> [any AccountKeyWithDescription]) { + self.init(ofKeys: AccountKeyCollection(keys)) + } + + /// Initialize new `RequiredAccountKeys` by providing a instance of ``AccountKeyCollection``. + /// - Parameter keys: The keys that are marked as required. + public init(ofKeys keys: AccountKeyCollection) { + self.keys = keys + } +} + + +extension AccountServiceConfiguration { + /// Access the required account keys of an ``AccountService``. + public var requiredAccountKeys: AccountKeyCollection { + storage[RequiredAccountKeys.self].keys + } +} diff --git a/Sources/SpeziAccount/AccountService/Configuration/SupportedAccountKeys.swift b/Sources/SpeziAccount/AccountService/Configuration/SupportedAccountKeys.swift new file mode 100644 index 00000000..55aa4caa --- /dev/null +++ b/Sources/SpeziAccount/AccountService/Configuration/SupportedAccountKeys.swift @@ -0,0 +1,79 @@ +// +// 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 +// + + +/// The collection of ``AccountKey``s that a ``AccountService`` is capable to storing itself. +/// +/// A ``AccountService`` must set this configuration option to communicate what set of ``AccountKey`` it is +/// capable of storing. +/// +/// Upon startup, `SpeziAccount` automatically verifies that the user-configured account values match what the +/// ``AccountService`` is capable of storing or that the user provides a ``AccountStorageStandard`` conforming +/// `Standard` in their app that is used to handle storage of all unsupported account values. +/// +/// Access the configuration via the ``AccountServiceConfiguration/supportedAccountKeys``. +/// +/// Belo is an example on how to provide a fixed set of supported account keys. +/// +/// ```swift +/// let supportedKeys = AccountKeyCollection { +/// \.userId +/// \.password +/// \.name +/// } +/// +/// let configuration = AccountServiceConfiguration(name: /* ... */, supportedKeys: .exactly(supportedKeys)) +/// ``` +public enum SupportedAccountKeys: AccountServiceConfigurationKey { + /// The ``AccountService`` is capable of storing arbitrary account keys. + case arbitrary + /// The ``AccountService`` is capable of only storing a fixed set of account keys. + case exactly(_ ofKeys: AccountKeyCollection) + + fileprivate func canStore(_ configuredValue: any AccountKeyConfiguration) -> Bool { + switch self { + case .arbitrary: + return true + case let .exactly(keys): + guard let key = keys.first(where: { $0.key == configuredValue.key })?.key else { + return false // we didn't find the key in the collection of supported keys + } + + // Either it is not a `RequiredAccountKey` or it is and the requirement specifies `.required` + // However, we automatically set a `.required` requirement for `RequiredAccountKey` so this is more of + // a sanity/integrity check. + return !key.isRequired || configuredValue.requirement == .required + } + } +} + + +extension AccountServiceConfiguration { + /// Access the supported account keys of an ``AccountService``. + public var supportedAccountKeys: SupportedAccountKeys { + guard let value = storage[SupportedAccountKeys.self] else { + preconditionFailure("Reached illegal state where SupportedAccountKeys configuration was never supplied!") + } + + return value + } + + /// Determine the set of unsupported ``AccountKey``s of this ``AccountService`` based on the global ``AccountValueConfiguration``. + /// + /// - Note: Access the global configuration using ``Account/configuration``. + /// - Parameter configuration: The user-supplied account value configuration. + /// - Returns: Returns array of ``AccountKeyConfiguration``. + public func unsupportedAccountKeys(basedOn configuration: AccountValueConfiguration) -> [any AccountKeyConfiguration] { + let supportedValues = supportedAccountKeys + + return configuration + .filter { configuredValue in + !supportedValues.canStore(configuredValue) + } + } +} diff --git a/Sources/SpeziAccount/AccountService/Configuration/UserIdConfiguration.swift b/Sources/SpeziAccount/AccountService/Configuration/UserIdConfiguration.swift new file mode 100644 index 00000000..34f852ac --- /dev/null +++ b/Sources/SpeziAccount/AccountService/Configuration/UserIdConfiguration.swift @@ -0,0 +1,55 @@ +// +// 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 +// + +import Spezi +import SwiftUI + + +/// The user id configuration of an ``AccountService``. +/// +/// This configuration comes with the assumption that every ``AccountService`` exposes some sort of primary and unique user identifier. +/// UI components may use this configuration to get more information about the shape of such a user identifier +/// (e.g. if it's an email address or just some alphanumerical string). +/// +/// Access the configuration via the ``AccountServiceConfiguration/userIdConfiguration`` property. +public struct UserIdConfiguration: AccountServiceConfigurationKey, DefaultProvidingKnowledgeSource { + public static var defaultValue: UserIdConfiguration { + UserIdConfiguration(type: .emailAddress, contentType: .username, keyboardType: .emailAddress) + } + + /// The type of user id stored in ``UserIdKey``. + /// You can use this property to provide a localized textual representation of the user id. + public let idType: UserIdType + /// The `UITextContentType` used for a field that is used to input the user id. + /// + /// - Note: Even if the user id is an email address you will want to use `UITextContentType/username` and set + /// the ``keyboardType`` to `UIKeyboardType/emailAddress`. For more information refer to + /// [Enabling Password AutoFill on a text input view](https://developer.apple.com/documentation/security/password_autofill/enabling_password_autofill_on_a_text_input_view). + public let textContentType: UITextContentType? + /// The `UIKeyboardType` used for a field that is used to input the user id. + public let keyboardType: UIKeyboardType + + /// Initialize a new `UserIdConfiguration`. + /// - Parameters: + /// - type: The user id type. + /// - contentType: The `UITextContentType` used for a field that is used to input the user id. + /// - keyboardType: The `UIKeyboardType` used for a field that is used to input the user id. + public init(type: UserIdType, contentType: UITextContentType? = .username, keyboardType: UIKeyboardType = .default) { + self.idType = type + self.textContentType = contentType + self.keyboardType = keyboardType + } +} + + +extension AccountServiceConfiguration { + /// Access the user id configuration of an ``AccountService``. + public var userIdConfiguration: UserIdConfiguration { + storage[UserIdConfiguration.self] + } +} diff --git a/Sources/SpeziAccount/AccountService/Configuration/UserIdType.swift b/Sources/SpeziAccount/AccountService/Configuration/UserIdType.swift new file mode 100644 index 00000000..0aa03fb7 --- /dev/null +++ b/Sources/SpeziAccount/AccountService/Configuration/UserIdType.swift @@ -0,0 +1,39 @@ +// +// This source file is part of the Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Foundation + + +/// Determines the type and kind of the ``UserIdKey``. +public enum UserIdType: Sendable, Equatable { + /// An user id that is the user's email address at the same time. + case emailAddress + /// An user id that models as some kind of alphanumeric string. + case username + /// An user id that has some custom representation with unspecified semantics. + /// + /// The `LocalizedStringResource` is used as the textual representation of this id type. + case custom(_ label: LocalizedStringResource) +} + + +extension LocalizedStringResource: @unchecked Sendable {} + + +extension UserIdType: CustomLocalizedStringResourceConvertible { + public var localizedStringResource: LocalizedStringResource { + switch self { + case .emailAddress: + return LocalizedStringResource("USER_ID_EMAIL", bundle: .atURL(from: .module)) + case .username: + return LocalizedStringResource("USER_ID_USERNAME", bundle: .atURL(from: .module)) + case let .custom(label): + return label + } + } +} diff --git a/Sources/SpeziAccount/AccountService/EmbeddableAccountService.swift b/Sources/SpeziAccount/AccountService/EmbeddableAccountService.swift new file mode 100644 index 00000000..3a6d55ff --- /dev/null +++ b/Sources/SpeziAccount/AccountService/EmbeddableAccountService.swift @@ -0,0 +1,20 @@ +// +// This source file is part of the Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Foundation + + +/// A embeddable ``AccountService`` allows to render simplified UI in the ``AccountSetup`` view. +/// +/// By default, the ``AccountSetup`` renders all ``AccountService`` as a list of buttons that navigate +/// to ``AccountSetupViewStyle/makePrimaryView()`` where login and signup flows are completely defined by the ``AccountService``. +/// +/// However, if there is a single `EmbeddableAccountService` in the list of all configured account service, this +/// account service is directly embedded into the main ``AccountSetup`` view for easier access. +/// The view is rendered using ``EmbeddableAccountSetupViewStyle/makeEmbeddedAccountView()`` +public protocol EmbeddableAccountService: AccountService where ViewStyle: EmbeddableAccountSetupViewStyle {} diff --git a/Sources/SpeziAccount/AccountService/IdentityProvider.swift b/Sources/SpeziAccount/AccountService/IdentityProvider.swift new file mode 100644 index 00000000..b387ecaf --- /dev/null +++ b/Sources/SpeziAccount/AccountService/IdentityProvider.swift @@ -0,0 +1,16 @@ +// +// This source file is part of the Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SwiftUI + +/// An identity provider that provides account functionality through a one-click third-party account service. +/// +/// ## Topics +/// ### Mock Implementations for SwiftUI Previews +/// - ``MockSignInWithAppleProvider`` +public protocol IdentityProvider: AccountService where ViewStyle: IdentityProviderViewStyle {} diff --git a/Sources/SpeziAccount/AccountService/StandardBackedAccountService.swift b/Sources/SpeziAccount/AccountService/StandardBackedAccountService.swift new file mode 100644 index 00000000..8bab0d88 --- /dev/null +++ b/Sources/SpeziAccount/AccountService/StandardBackedAccountService.swift @@ -0,0 +1,169 @@ +// +// 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 +// + +import Spezi + +/// Internal marker protocol to determine what ``AccountService`` require assistance by a ``AccountStorageStandard``. +protocol StandardBacked { + associatedtype Standard: AccountStorageStandard + var standard: Standard { get } + + var backedId: String { get } + + func isBacking(service accountService: any AccountService) -> Bool +} + + +/// An ``AccountService`` implementation for account services with ``SupportedAccountKeys/exactly(_:)`` configuration +/// to forward unsupported account values to a ``AccountStorageStandard`` implementation. +actor StandardBackedAccountService: AccountService, StandardBacked { + @AccountReference private var account + + let accountService: Service + let standard: Standard + let serviceSupportedKeys: AccountKeyCollection + + nonisolated var configuration: AccountServiceConfiguration { + accountService.configuration + } + + nonisolated var viewStyle: Service.ViewStyle { + accountService.viewStyle + } + + nonisolated var backedId: String { + accountService.id + } + + + private var currentUserId: String? { + get async { + await account.details?.userId + } + } + + + init(service accountService: Service, standard: Standard) { + guard case let .exactly(keys) = accountService.configuration.supportedAccountKeys else { + preconditionFailure("Cannot initialize a \(Self.self) where the underlying service \(Service.self) does support all account keys!") + } + + self.accountService = accountService + self.standard = standard + self.serviceSupportedKeys = keys + } + + + nonisolated func isBacking(service accountService: any AccountService) -> Bool { + self.accountService.objId == accountService.objId + } + + func signUp(signupDetails: SignupDetails) async throws { + let details = splitDetails(from: signupDetails) + + let recordId = AdditionalRecordId(serviceId: accountService.id, userId: signupDetails.userId) + + // call standard first, such that it will happen before any `supplyAccountDetails` calls made by the Account Service + try await standard.create(recordId, details.standard) + + try await accountService.signUp(signupDetails: details.service) + } + + func updateAccountDetails(_ modifications: AccountModifications) async throws { + guard let userId = await currentUserId else { + return + } + + let modifiedDetails = splitDetails(from: modifications.modifiedDetails, copyUserId: true) + let removedDetails = splitDetails(from: modifications.removedAccountDetails) + + let serviceModifications = AccountModifications( + modifiedDetails: modifiedDetails.service, + removedAccountDetails: removedDetails.service + ) + + let standardModifications = AccountModifications( + modifiedDetails: modifiedDetails.standard, + removedAccountDetails: removedDetails.standard + ) + + let recordId = AdditionalRecordId(serviceId: accountService.id, userId: userId) + + // first call the standard, such that it will happen before any `supplyAccountDetails` calls made by the Account Service + try await standard.modify(recordId, standardModifications) + + try await accountService.updateAccountDetails(serviceModifications) + } + + func logout() async throws { + try await accountService.logout() + } + + func delete() async throws { + guard let userId = await currentUserId else { + return + } + + try await standard.delete(AdditionalRecordId(serviceId: accountService.id, userId: userId)) + try await accountService.delete() + } + + private func splitDetails( + from details: Values, + copyUserId: Bool = false + ) -> (service: Values, standard: Values) { + let serviceBuilder = AccountValuesBuilder() + let standardBuilder = AccountValuesBuilder(from: details) + + + for element in serviceSupportedKeys { + if copyUserId && element.key == UserIdKey.self { + // ensure that in a `modify` call, the Standard gets notified about the updated userId as the primary + // identifier will change. + continue + } + + // remove all service supported keys from the standard builder (which is a copy of `details` currently) + standardBuilder.remove(element.key) + } + + // copy all values from `details` of the service supported keys into the service builder + serviceBuilder.merging(with: serviceSupportedKeys, from: details) + + return (serviceBuilder.build(), standardBuilder.build()) + } +} + + +extension StandardBackedAccountService: EmbeddableAccountService where Service: EmbeddableAccountService {} + + +extension StandardBackedAccountService: UserIdPasswordAccountService where Service: UserIdPasswordAccountService { + func login(userId: String, password: String) async throws { + // the standard is queried once the account service calls `supplyAccountDetails` + try await accountService.login(userId: userId, password: password) + } + + func resetPassword(userId: String) async throws { + try await accountService.resetPassword(userId: userId) + } +} + + +extension AccountService { + func backedBy(standard: any AccountStorageStandard) -> any AccountService { + standard.backedService(with: self) + } +} + + +extension AccountStorageStandard { + fileprivate nonisolated func backedService(with service: Service) -> any AccountService { + StandardBackedAccountService(service: service, standard: self) + } +} diff --git a/Sources/SpeziAccount/AccountService/UserIdPasswordAccountService.swift b/Sources/SpeziAccount/AccountService/UserIdPasswordAccountService.swift new file mode 100644 index 00000000..fec54c00 --- /dev/null +++ b/Sources/SpeziAccount/AccountService/UserIdPasswordAccountService.swift @@ -0,0 +1,52 @@ +// +// This source file is part of the Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + + +/// An ``AccountService`` that relies on ``UserIdKey`` and ``PasswordKey`` as primary authentication credentials. +/// +/// This type of ``AccountService`` provides a default set of UI components through ``UserIdPasswordAccountSetupViewStyle`` +/// for all functionalities. +/// +/// - Note: The type of userId might be configured using ``UserIdConfiguration``. Additionally make sure, if you +/// require the ``UserIdKey`` and ``PasswordKey`` to be present, you have to manually specify the ``RequiredAccountKeys`` +/// configuration. +/// +/// ## Topics +/// +/// ### Default View Style +/// +/// - ``DefaultUserIdPasswordAccountSetupViewStyle`` +/// +/// ### Mock Implementations for SwiftUI Previews +/// +/// - ``MockUserIdPasswordAccountService`` +public protocol UserIdPasswordAccountService: AccountService, EmbeddableAccountService where ViewStyle: UserIdPasswordAccountSetupViewStyle { + /// This method implements login functionality using ``UserIdKey`` and ``PasswordKey``-based credentials. + /// - Parameters: + /// - userId: The userId of the account. + /// - password: The plain-text password for the account. + /// - Throws: Throw an `Error` type conforming to `LocalizedError` if the login operation was unsuccessful, + /// inorder to present a localized description to the user. + /// Make sure to remain in a state where the user can easily retry the login operation. + func login(userId: String, password: String) async throws + + /// This method implements password reset functionality for a given userId. + /// - Parameter userId: The userId to + /// - Throws: Throw an `Error` type conforming to `LocalizedError` if the password reset operation was unsuccessful, + /// inorder to present a localized description to the user. + /// Make sure to remain in a state where the user can easily retry the password reset operation. + func resetPassword(userId: String) async throws +} + + +extension UserIdPasswordAccountService where ViewStyle == DefaultUserIdPasswordAccountSetupViewStyle { + /// Default UI components for userId and password-based account services. + public var viewStyle: DefaultUserIdPasswordAccountSetupViewStyle { + DefaultUserIdPasswordAccountSetupViewStyle(using: self) + } +} diff --git a/Sources/SpeziAccount/AccountServicesView.swift b/Sources/SpeziAccount/AccountServicesView.swift deleted file mode 100644 index fab52f13..00000000 --- a/Sources/SpeziAccount/AccountServicesView.swift +++ /dev/null @@ -1,92 +0,0 @@ -// -// This source file is part of the Spezi open-source project -// -// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -import Spezi -import SwiftUI - -struct AccountServicesView: View { - @EnvironmentObject var account: Account - - private var header: Header - private var button: (any AccountService) -> AnyView - - private var documentationUrl: URL { - // we may move to a #URL macro once Swift 5.9 is shipping - guard let docsUrl = URL(string: "https://swiftpackageindex.com/stanfordspezi/speziaccount/documentation/speziaccount/createanaccountservice") else { - fatalError("Failed to construct SpeziAccount Documentation URL. Please review URL syntax!") - } - - return docsUrl - } - - - var body: some View { - GeometryReader { proxy in - ScrollView(.vertical) { - VStack { - header - Spacer(minLength: 0) - VStack(spacing: 16) { - if account.accountServices.isEmpty { - Text("MISSING_ACCOUNT_SERVICES", bundle: .module) - .multilineTextAlignment(.center) - .foregroundColor(.secondary) - - Button { - UIApplication.shared.open(documentationUrl) - } label: { - Text("OPEN_DOCUMENTATION", bundle: .module) - } - } else { - ForEach(account.accountServices, id: \.id) { loginService in - button(loginService) - } - } - } - .padding(16) - } - .frame(minHeight: proxy.size.height) - .frame(maxWidth: .infinity) - } - } - } - - - init(button: @escaping (any AccountService) -> AnyView) where Header == EmptyView { - self.header = EmptyView() - self.button = button - } - - init(header: Header, button: @escaping (any AccountService) -> AnyView) { - self.header = header - self.button = button - } -} - - -#if DEBUG -struct AccountServicesView_Previews: PreviewProvider { - @StateObject private static var account: Account = { - let accountServices: [any AccountService] = [ - UsernamePasswordAccountService(), - EmailPasswordAccountService() - ] - return Account(accountServices: accountServices) - }() - - static var previews: some View { - NavigationStack { - AccountServicesView(header: EmptyView()) { accountService in - accountService.loginButton - } - .navigationTitle(String(localized: "LOGIN", bundle: .module)) - } - .environmentObject(account) - } -} -#endif diff --git a/Sources/SpeziAccount/AccountSetup.swift b/Sources/SpeziAccount/AccountSetup.swift new file mode 100644 index 00000000..3623e584 --- /dev/null +++ b/Sources/SpeziAccount/AccountSetup.swift @@ -0,0 +1,171 @@ +// +// This source file is part of the Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SwiftUI + + +/// The essential ``SpeziAccount`` view to login into or signup for a user account. +/// +/// This view handles account setup for a user. The user can choose from all configured ``AccountService`` and +/// ``IdentityProvider`` instances to setup an active user account. They might create a new account with a given +/// ``AccountService`` or log into an existing one. +/// +/// This view relies on an ``Account`` object in its environment. This is done automatically by providing a +/// ``AccountConfiguration`` in the configuration section of your `Spezi` app delegate. +/// +/// - Note: In SwiftUI previews you can easily instantiate your own ``Account``. Use initializers like +/// ``Account/init(services:configuration:)`` or ``Account/init(building:active:configuration:)``. +/// +/// +/// Below is a short code example on how to use the `AccountSetup` view. +/// +/// ```swift +/// struct MyView: View { +/// @EnvironmentObject var account: Account +/// +/// var body: some View { +/// // You may use `account.signedIn` to conditionally render another view if there is already a signed in account +/// // or use the `continue` closure as shown below to render a Continue button. +/// // The continue button is especially useful in cases like Onboarding Flows such that the user has the chance +/// // to review the currently signed in account. +/// +/// AccountSetup { +/// NavigationLink { +/// // ... next view +/// } label: { +/// Text("Continue") +/// } +/// } +/// } +/// } +/// ``` +public struct AccountSetup: View { + private let header: Header + private let continueButton: Continue + + @EnvironmentObject var account: Account + + private var services: [any AccountService] { + account.registeredAccountServices + .filter { !($0 is any IdentityProvider) } + } + + private var identityProviders: [any IdentityProvider] { + account.registeredAccountServices + .compactMap { $0 as? any IdentityProvider } + } + + public var body: some View { + NavigationStack { + GeometryReader { proxy in + ScrollView(.vertical) { + VStack { + if !services.isEmpty { + header + } + + Spacer() + + if let details = account.details { + ExistingAccountView(details: details) { + continueButton + } + } else { + accountSetupView + } + + Spacer() + Spacer() + Spacer() + } + .padding(.horizontal, ViewSizing.outerHorizontalPadding) + .frame(minHeight: proxy.size.height) + .frame(maxWidth: .infinity) + } + } + } + } + + @ViewBuilder private var accountSetupView: some View { + if services.isEmpty && identityProviders.isEmpty { + EmptyServicesWarning() + } else { + VStack { + AccountServicesSection(services: services) + + if !services.isEmpty && !identityProviders.isEmpty { + ServicesDivider() + } + + IdentityProviderSection(providers: identityProviders) + } + .padding(.horizontal, ViewSizing.innerHorizontalPadding) + .frame(maxWidth: ViewSizing.maxFrameWidth) // landscape optimizations + .dynamicTypeSize(.medium ... .xxxLarge) // ui doesn't make sense on size larger than .xxxLarge + } + } + + public init( + @ViewBuilder `continue`: () -> Continue = { EmptyView() } + ) where Header == DefaultAccountSetupHeader { + self.init(continue: `continue`, header: { DefaultAccountSetupHeader() }) + } + + + public init( + @ViewBuilder `continue`: () -> Continue = { EmptyView() }, + @ViewBuilder header: () -> Header + ) { + self.header = header() + self.continueButton = `continue`() + } +} + + +#if DEBUG +struct AccountView_Previews: PreviewProvider { + static var accountServicePermutations: [[any AccountService]] = { + [ + [MockUserIdPasswordAccountService()], + [MockSimpleAccountService()], + [MockUserIdPasswordAccountService(), MockSimpleAccountService()], + [ + MockUserIdPasswordAccountService(), + MockSimpleAccountService(), + MockUserIdPasswordAccountService() + ], + [] + ] + }() + + static let detailsBuilder = AccountDetails.Builder() + .set(\.userId, value: "andi.bauer@tum.de") + .set(\.name, value: PersonNameComponents(givenName: "Andreas", familyName: "Bauer")) + + @MainActor static var previews: some View { + ForEach(accountServicePermutations.indices, id: \.self) { index in + AccountSetup() + .environmentObject(Account(services: accountServicePermutations[index] + [MockSignInWithAppleProvider()])) + } + + AccountSetup() + .environmentObject(Account(building: detailsBuilder, active: MockUserIdPasswordAccountService())) + + AccountSetup { + Button(action: { + print("Continue") + }, label: { + Text("Continue") + .frame(maxWidth: .infinity, minHeight: 38) + }) + .buttonStyle(.borderedProminent) + } + .environmentObject(Account(building: detailsBuilder, active: MockUserIdPasswordAccountService())) + } +} +#endif diff --git a/Sources/SpeziAccount/AccountSetupViewStyle/AccountSetupViewStyle.swift b/Sources/SpeziAccount/AccountSetupViewStyle/AccountSetupViewStyle.swift new file mode 100644 index 00000000..a9dbaa83 --- /dev/null +++ b/Sources/SpeziAccount/AccountSetupViewStyle/AccountSetupViewStyle.swift @@ -0,0 +1,59 @@ +// +// This source file is part of the Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Foundation +import SwiftUI + + +/// A view style defining UI components for an associated ``AccountService``. +public protocol AccountSetupViewStyle { + /// The associated ``AccountService``. + associatedtype Service: AccountService + + /// The button label rendered in the list of account services in ``AccountSetup`` + associatedtype ButtonLabel: View + /// The primary view that is opened as the destination of the ``ButtonLabel``. + associatedtype PrimaryView: View + /// A small account summary that is rendered if ``AccountSetup`` is presented on an already + /// signed in user. + associatedtype AccountSummaryView: View + + /// The associated ``AccountService`` instance. + var service: Service { get } + + /// The button label in the list of account services for the ``AccountSetup`` view. + @ViewBuilder + func makeServiceButtonLabel() -> ButtonLabel + + /// The primary view that is opened as the destination of the ``makeServiceButtonLabel()-6ihdh`` button. + @ViewBuilder + func makePrimaryView() -> PrimaryView + + /// The account summary that is presented in the ``AccountSetup`` for an already signed in user. + @ViewBuilder + func makeAccountSummary(details: AccountDetails) -> AccountSummaryView +} + + +extension AccountSetupViewStyle { + /// Default service button label using the ``AccountServiceName`` and ``AccountServiceImage`` configurations. + public func makeServiceButtonLabel() -> some View { + Group { + service.configuration.image + .font(.title2) + .accessibilityHidden(true) + Text(service.configuration.name) + } + .accountServiceButtonBackground() + } + + /// Default account summary using ``AccountSummaryBox``. + public func makeAccountSummary(details: AccountDetails) -> some View { + AccountSummaryBox(details: details) + } +} diff --git a/Sources/SpeziAccount/AccountSetupViewStyle/DefaultUserIdPasswordAccountSetupViewStyle.swift b/Sources/SpeziAccount/AccountSetupViewStyle/DefaultUserIdPasswordAccountSetupViewStyle.swift new file mode 100644 index 00000000..c61158e2 --- /dev/null +++ b/Sources/SpeziAccount/AccountSetupViewStyle/DefaultUserIdPasswordAccountSetupViewStyle.swift @@ -0,0 +1,20 @@ +// +// This source file is part of the Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SwiftUI + + +/// A ``UserIdPasswordAccountSetupViewStyle`` that provides default UI components for all views. +public struct DefaultUserIdPasswordAccountSetupViewStyle: UserIdPasswordAccountSetupViewStyle { + // swiftlint:disable:previous type_name + public let service: Service + + public init(using service: Service) { + self.service = service + } +} diff --git a/Sources/SpeziAccount/AccountSetupViewStyle/EmbeddableAccountSetupViewStyle.swift b/Sources/SpeziAccount/AccountSetupViewStyle/EmbeddableAccountSetupViewStyle.swift new file mode 100644 index 00000000..7849c089 --- /dev/null +++ b/Sources/SpeziAccount/AccountSetupViewStyle/EmbeddableAccountSetupViewStyle.swift @@ -0,0 +1,22 @@ +// +// This source file is part of the Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Foundation +import SwiftUI + + +/// A view style defining UI components for an associated ``EmbeddableAccountService``. +public protocol EmbeddableAccountSetupViewStyle: AccountSetupViewStyle where Service: EmbeddableAccountService { + /// The view that is embedded into the ``AccountSetup`` view if the associated ``EmbeddableAccountService`` + /// is the only configured embeddable account service. + associatedtype EmbeddedView: View + + /// The view that is embedded into the ``AccountSetup`` view if applicable. + @ViewBuilder + func makeEmbeddedAccountView() -> EmbeddedView +} diff --git a/Sources/SpeziAccount/AccountSetupViewStyle/IdentityProviderViewStyle.swift b/Sources/SpeziAccount/AccountSetupViewStyle/IdentityProviderViewStyle.swift new file mode 100644 index 00000000..6bc9509e --- /dev/null +++ b/Sources/SpeziAccount/AccountSetupViewStyle/IdentityProviderViewStyle.swift @@ -0,0 +1,33 @@ +// +// 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 +// + +import SwiftUI + + +/// A view style defining UI components for an associated ``IdentityProvider``. +public protocol IdentityProviderViewStyle: AccountSetupViewStyle where Service: IdentityProvider, PrimaryView == Never, ButtonLabel == Never { + /// The view rendering the sign in button. + associatedtype Button: View + + /// Render the sign in button. + @ViewBuilder + func makeSignInButton() -> Button +} + + +extension IdentityProviderViewStyle { + /// Implementation that results in a fatal error as these methods are unavailable on identity providers. + public func makeServiceButtonLabel() -> Never { + preconditionFailure("makeServiceButtonLabel() is not available on `IdentityProvider`s") + } + + /// Implementation that results in a fatal error as these methods are unavailable on identity providers. + public func makePrimaryView() -> Never { + preconditionFailure("makePrimaryView() is not available on `IdentityProvider`s") + } +} diff --git a/Sources/SpeziAccount/AccountSetupViewStyle/UserIdPasswordAccountSetupViewStyle.swift b/Sources/SpeziAccount/AccountSetupViewStyle/UserIdPasswordAccountSetupViewStyle.swift new file mode 100644 index 00000000..8140a61a --- /dev/null +++ b/Sources/SpeziAccount/AccountSetupViewStyle/UserIdPasswordAccountSetupViewStyle.swift @@ -0,0 +1,52 @@ +// +// This source file is part of the Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Foundation +import SwiftUI + + +/// A view style defining UI components for an associated ``UserIdPasswordAccountService``. +public protocol UserIdPasswordAccountSetupViewStyle: EmbeddableAccountSetupViewStyle where Service: UserIdPasswordAccountService { + /// A view that is rendered to signup a new user. + associatedtype SignupView: View + /// A view that is rendered to reset the user's password. + associatedtype PasswordResetView: View + + /// The view that is presented to signup a new user. + @ViewBuilder + func makeSignupView() -> SignupView + + /// The view that is presented to reset a user's password. + @ViewBuilder + func makePasswordResetView() -> PasswordResetView +} + + +extension UserIdPasswordAccountSetupViewStyle { + /// Default primary view using ``UserIdPasswordPrimaryView``. + public func makePrimaryView() -> some View { + UserIdPasswordPrimaryView(using: service) + } + + /// Default embedded account view using ``UserIdPasswordEmbeddedView``. + public func makeEmbeddedAccountView() -> some View { + UserIdPasswordEmbeddedView(using: service) + } + + /// Default signup view using ``SignupForm``. + public func makeSignupView() -> some View { + SignupForm(using: service) + } + + /// Default password reset view using ``UserIdPasswordResetView`` and ``SuccessfulPasswordResetView``. + public func makePasswordResetView() -> some View { + UserIdPasswordResetView(using: service) { + SuccessfulPasswordResetView() + } + } +} diff --git a/Sources/SpeziAccount/AccountStorageStandard.swift b/Sources/SpeziAccount/AccountStorageStandard.swift new file mode 100644 index 00000000..0d987f04 --- /dev/null +++ b/Sources/SpeziAccount/AccountStorageStandard.swift @@ -0,0 +1,92 @@ +// +// 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 +// + +import Spezi + + +/// A `Spezi` Standard that manages data flow of additional account values. +/// +/// Certain ``AccountService`` implementations might be limited to supported only a specific set of ``AccountKey``s +/// (see ``SupportedAccountKeys/exactly(_:)``. If you nonetheless want to use ``AccountKey``s that are unsupported +/// by your ``AccountService``, you may add an implementation of the `AccountStorageStandard` protocol to your App's `Standard`, +/// inorder to handle storage and retrieval of these additional account values. +/// +/// - Note: You can use the ``AccountReference`` property wrapper to get access to the global ``Account`` object if you need it to implement additional functionality. +public protocol AccountStorageStandard: Standard { + /// Create new associated account data. + /// + /// - Note: A call to this method might certainly be immediately followed by a call to ``load(_:_:)``. + /// + /// - Parameters: + /// - identifier: The primary identifier for stored record. + /// - details: The signup details that need to be stored. + /// - Throws: A `LocalizedError`. + func create(_ identifier: AdditionalRecordId, _ details: SignupDetails) async throws + + /// Load associated account data. + /// + /// This method is called to load all ``AccountDetails`` that are managed by this `Standard`. + /// + /// - Note: It is advised to maintain a local cache for the stored ``AccountDetails`` to maintain + /// easy and fast retrieval. Make sure the local data is maintained throughout operations like + /// ``create(_:_:)`` and ``modify(_:_:)`` while also accounting for updates in the remote storage. + /// + /// + /// - Parameters: + /// - identifier: The primary identifier for stored record. + /// - keys: The keys to load. + /// - Parameter userId: The userId to load data for. + /// - Returns: The assembled ``PartialAccountDetails`` (see ``AccountValuesBuilder``). + /// - Throws: A `LocalizedError`. + func load(_ identifier: AdditionalRecordId, _ keys: [any AccountKey.Type]) async throws -> PartialAccountDetails + + /// Modify the associated account data of an existing user account. + /// + /// This call is used to apply all modifications of the Standard-managed account values. + /// + /// - Important: The ``ModifiedAccountDetails`` the ``AccountModifications`` structure might + /// contain a change to the ``UserIdKey`` as well. This changes the primary ``AdditionalRecordId`` identifier + /// used in all calls to reference a certain record. You must update the primary identifier! + /// + /// - Note: A call to this method might certainly be immediately followed by a call to ``load(_:_:)``. + /// + /// - Parameters: + /// - identifier: The primary identifier for stored record. + /// - modifications: The account modifications. + /// - Throws: A `LocalizedError`. + func modify(_ identifier: AdditionalRecordId, _ modifications: AccountModifications) async throws + + /// Signals the standard the the currently logged in user was removed. + /// + /// This method is useful to clear any data of the currently cached user. + /// + /// - Parameter identifier: The primary identifier for stored record. + func clear(_ identifier: AdditionalRecordId) async + + /// Delete all associated account data. + /// + /// - Note: Due to the underlying architecture, there might still be a call to ``clear(_:)`` after a call to + /// this method. + /// - Parameter identifier: The primary identifier for stored record. + /// - Throws: A `LocalizedError`. + func delete(_ identifier: AdditionalRecordId) async throws +} + + +extension AccountStorageStandard { + /// A property wrapper that can be used within ``AccountStorageStandard`` instances to request + /// access to the global ``Account`` instance. + /// + /// Below is a short code example on how to use this property wrapper: + /// ```swift + /// public actor MyStandard: AccountStorageStandard { + /// @AccountReference var account + /// } + /// ``` + public typealias AccountReference = _WeakInjectable +} diff --git a/Sources/SpeziAccount/AccountValue/AccountAnchor.swift b/Sources/SpeziAccount/AccountValue/AccountAnchor.swift new file mode 100644 index 00000000..811dbbf9 --- /dev/null +++ b/Sources/SpeziAccount/AccountValue/AccountAnchor.swift @@ -0,0 +1,13 @@ +// +// 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 +// + +import Spezi + + +/// A `RepositoryAnchor` for ``AccountStorage``. +public struct AccountAnchor: RepositoryAnchor, Sendable {} diff --git a/Sources/SpeziAccount/AccountValue/AccountKey+Views.swift b/Sources/SpeziAccount/AccountValue/AccountKey+Views.swift new file mode 100644 index 00000000..720b6d1b --- /dev/null +++ b/Sources/SpeziAccount/AccountValue/AccountKey+Views.swift @@ -0,0 +1,58 @@ +// +// 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 +// + +import Spezi +import SwiftUI + + +extension AccountKey where Value: StringProtocol { + /// Default DataDisplay for String-based values using ``StringBasedDisplayView``. + public typealias DataDisplay = StringBasedDisplayView +} + +extension AccountKey where Value: CustomLocalizedStringResourceConvertible { + /// Default DataDisplay for `CustomLocalizedStringResourceConvertible`-based values using ``LocalizableStringBasedDisplayView``. + public typealias DataDisplay = LocalizableStringBasedDisplayView +} + + +extension AccountKey { + static func emptyDataEntryView(for values: Values.Type) -> AnyView { + AnyView(GeneralizedDataEntryView(initialValue: initialValue.value)) + } + + static func dataEntryViewWithStoredValue( + details: AccountDetails, + for values: Values.Type + ) -> AnyView? { + guard let value = details.storage.get(Self.self) else { + return nil + } + + return AnyView(GeneralizedDataEntryView(initialValue: value)) + } + + static func dataEntryViewFromBuilder( + builder: ModifiedAccountDetails.Builder, + for values: Values.Type + ) -> AnyView? { + guard let value = builder.get(Self.self) else { + return nil + } + + return AnyView(GeneralizedDataEntryView(initialValue: value)) + } + + static func dataDisplayViewWithCurrentStoredValue(from details: AccountDetails) -> AnyView? { + guard let value = details.storage.get(Self.self) else { + return nil + } + + return AnyView(DataDisplay(value)) + } +} diff --git a/Sources/SpeziAccount/AccountValue/AccountKey.swift b/Sources/SpeziAccount/AccountValue/AccountKey.swift new file mode 100644 index 00000000..7a4cd171 --- /dev/null +++ b/Sources/SpeziAccount/AccountValue/AccountKey.swift @@ -0,0 +1,124 @@ +// +// This source file is part of the Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Spezi +import SwiftUI +import XCTRuntimeAssertions + + +/// A typed storage key to store values associated with an user account. +/// +/// The `AccountKey` protocol builds upon the [Shared Repository](https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/shared-repository) +/// infrastructure provided by the `Spezi` framework. +/// +/// The article provides a great overview on how to implement a custom account value. +/// +/// - Important: The `Value` of an ``AccountKey`` must conform to `Sendable` such that storage containers +/// can be safely passed between actor boundaries. +/// `Equatable` conformance is required such that views like the ``SignupForm`` can react to changes +/// and validate input against a ``ValidationEngine``. +/// `Codable` conformance is required such that ``AccountService``s of ``AccountStorageStandard``s +/// can easily store arbitrarily defined account values. +/// +/// ## Topics +/// +/// ### Builtin Account Keys +/// - ``UserIdKey`` +/// - ``PasswordKey`` +/// - ``PersonNameKey`` +/// - ``EmailAddressKey`` +/// - ``DateOfBirthKey`` +/// - ``GenderIdentityKey`` +/// - ``ActiveAccountServiceKey`` +public protocol AccountKey: KnowledgeSource where Value: Sendable, Value: Equatable, Value: Codable { + /// The ``DataDisplayView`` that is used to display a value for this account value. + /// + /// This view is used in views like the ``AccountOverview`` to display the current value for this `AccountKey`. + /// - Note: This View implementation is automatically provided if the `Value` is a String or the `Value` + /// conforms to `CustomLocalizedStringResourceConvertible`. + associatedtype DataDisplay: DataDisplayView + + /// The ``DataEntryView`` that is used to enter a value for this account value. + /// + /// This view is wrapped into a ``GeneralizedDataEntryView`` and used in views like the ``SignupForm`` to enter the account value. + /// For example, for a String-based account value, one might define a ``DataEntryView`` based on `TextField` or ``VerifiableTextField``. + associatedtype DataEntry: DataEntryView + + /// The localized name describing a value of this `AccountKey`. + static var name: LocalizedStringResource { get } + + + /// The category of the account key. + /// + /// The ``AccountKeyCategory`` is used to group ``DataEntryView``s in views like the ``SignupForm``. + /// Use ``AccountKeyCategory/other`` to move the ``DataEntry`` view to a unnamed group at the bottom. + static var category: AccountKeyCategory { get } + + /// The ``InitialValue`` that is used when supplying a new account value. + /// + /// An empty value (e.g., a empty String) is required when the user is asked to supply a new value for the account value + /// in views like the ``SignupForm``. + /// + /// - Note: There are default implementations for some standard types that all provide a ``InitialValue/empty(_:)`` value. + static var initialValue: InitialValue { get } + + /// A unique identifier for an account key. + /// + /// - Note: A default implementation is provided. + static var id: ObjectIdentifier { get } +} + + +extension AccountKey { + /// A unique identifier for an account key. + public static var id: ObjectIdentifier { + ObjectIdentifier(Self.self) + } + + /// The default identifier for the `@FocusState` property that is automatically handled by the ``GeneralizedDataEntryView``. + public static var focusState: String { + "\(Self.self)" + } + + static var isRequired: Bool { + self is any RequiredAccountKey.Type + } +} + + +extension AccountKey where Value: StringProtocol { + /// Default initial value for `String` values. + public static var initialValue: InitialValue { + .empty("") + } +} + + +extension AccountKey where Value: AdditiveArithmetic { + /// Default initial value for numeric values. + public static var initialValue: InitialValue { + // this catches all the numeric types + .empty(.zero) + } +} + + +extension AccountKey where Value: ExpressibleByArrayLiteral { + /// Default initial value for `Array` values. + public static var initialValue: InitialValue { + .empty([]) + } +} + + +extension AccountKey where Value: ExpressibleByDictionaryLiteral { + /// Default initial value for `Dictionary` values. + public static var initialValue: InitialValue { + .empty([:]) + } +} diff --git a/Sources/SpeziAccount/AccountValue/AccountKeyCategory.swift b/Sources/SpeziAccount/AccountValue/AccountKeyCategory.swift new file mode 100644 index 00000000..54ba9801 --- /dev/null +++ b/Sources/SpeziAccount/AccountValue/AccountKeyCategory.swift @@ -0,0 +1,75 @@ +// +// 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 +// + +import Foundation + + +/// Provide categories ui components of an ``AccountKey``. +/// +/// A `AccountKeyCategory` is used in views like ``SignupForm`` to visually separate ``AccountKey`` +/// and group similar elements into sections. +/// +/// ## Topics +/// +/// ### Default Categories +/// - ``credentials`` +/// - ``name`` +/// - ``contactDetails`` +/// - ``personalDetails`` +/// - ``other`` +public struct AccountKeyCategory { + /// A category to group account credentials. + public static let credentials = AccountKeyCategory(title: LocalizedStringResource("UP_CREDENTIALS", bundle: .atURL(from: .module))) + + /// A category to group account values capturing the user's name. + public static let name = AccountKeyCategory(title: LocalizedStringResource("UP_NAME", bundle: .atURL(from: .module))) + + /// A category to group account values that capture the user's contact details. + public static let contactDetails = AccountKeyCategory(title: LocalizedStringResource("UP_CONTACT_DETAILS", bundle: .atURL(from: .module))) + + /// A category to group account values that capture other personal details. + public static let personalDetails = AccountKeyCategory(title: LocalizedStringResource("UP_PERSONAL_DETAILS", bundle: .atURL(from: .module))) + + /// A default, unnamed category for any account values + public static let other = AccountKeyCategory() + + + /// The localized section title. + public let categoryTitle: LocalizedStringResource? + + // the only category allowed without a title is the `other` category + private init(title categoryTitle: LocalizedStringResource? = nil) { + self.categoryTitle = categoryTitle + } + + /// Instantiate a new ``AccountKeyCategory``. + /// - Parameter categoryTitle: The localized section title. The key is also used a identifier for the `Identifiable` conformance. + public init(title categoryTitle: LocalizedStringResource) { + self.categoryTitle = categoryTitle + } +} + + +extension AccountKeyCategory: Identifiable, Hashable { + /// A string based identifier relying on the key of the ``categoryTitle``. + /// + /// The magic constant `#none#` is used for the ``other`` category. + public var id: String { + categoryTitle?.key ?? "#none#" // magic constant for the "other" category + } + + /// Default `Equatable` implementation. + public static func == (lhs: AccountKeyCategory, rhs: AccountKeyCategory) -> Bool { + lhs.id == rhs.id + } + + /// Default `Hashable` implementation. + public func hash(into hasher: inout Hasher) { + id.hash(into: &hasher) + } +} diff --git a/Sources/SpeziAccount/AccountValue/AccountKeyCollection.swift b/Sources/SpeziAccount/AccountValue/AccountKeyCollection.swift new file mode 100644 index 00000000..25fbc1b9 --- /dev/null +++ b/Sources/SpeziAccount/AccountValue/AccountKeyCollection.swift @@ -0,0 +1,92 @@ +// +// 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 +// + +import Foundation + + +/// A ``AccountKey`` refined with a KeyPath-based description. +/// +/// A custom description is derived from the KeyPath name. E.g., for the ``UserIdKey`` we derive a description +/// like `"\.userId"` (as it's extension defined on ``AccountValues``) for a more user friendly description. +public protocol AccountKeyWithDescription: Sendable, CustomStringConvertible, CustomDebugStringConvertible { + /// The associated `Key` type. + associatedtype Key: AccountKey + + /// Access to the ``AccountKey`` metatype. + var key: Key.Type { get } +} + + +struct AccountKeyWithKeyPathDescription: AccountKeyWithDescription { + let key: Key.Type + let description: String + + var debugDescription: String { + description + } + + init(_ keyPath: KeyPath) { + self.key = Key.self + self.description = keyPath.shortDescription + } +} + + +/// A collection of ``AccountKey``s that is built using `KeyPath`-based specification. +/// +/// Using the `KeyPath`-based result builder ``AccountKeyCollectionBuilder`` we can preserve user-friendly +/// naming in debug messages (see ``AccountKeyWithDescription``). +/// +/// ## Topics +/// +/// ### Result Builder +/// +/// - ``AccountKeyCollectionBuilder`` +/// - ``AccountKeyWithDescription`` +public struct AccountKeyCollection: Sendable, AcceptingAccountKeyVisitor { + private let elements: [any AccountKeyWithDescription] + + + /// Initialize a empty collection. + public init() { + self.elements = [] + } + + /// Initialize a new collection with elements. + /// - Parameter keys: The result builder to build the collection. + public init(@AccountKeyCollectionBuilder _ keys: () -> [any AccountKeyWithDescription]) { + self.elements = keys() + } + + + public func acceptAll(_ visitor: inout Visitor) -> Visitor.Final { + self + .map { $0.key } + .acceptAll(&visitor) + } +} + + +extension AccountKeyCollection: Collection { + public var startIndex: Int { + elements.startIndex + } + + public var endIndex: Int { + elements.endIndex + } + + public func index(after index: Int) -> Int { + elements.index(after: index) + } + + + public subscript(position: Int) -> any AccountKeyWithDescription { + elements[position] + } +} diff --git a/Sources/SpeziAccount/AccountValue/AccountKeyCollectionBuilder.swift b/Sources/SpeziAccount/AccountValue/AccountKeyCollectionBuilder.swift new file mode 100644 index 00000000..63d9304b --- /dev/null +++ b/Sources/SpeziAccount/AccountValue/AccountKeyCollectionBuilder.swift @@ -0,0 +1,50 @@ +// +// 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 +// + + +/// A result builder to build a collection of ``AccountKeyWithDescription`` metatypes. +@resultBuilder +public enum AccountKeyCollectionBuilder { + /// Build a single ``AccountKeyWithDescription`` metatype expression using `KeyPath` notation. + public static func buildExpression(_ expression: KeyPath) -> [any AccountKeyWithDescription] { + [AccountKeyWithKeyPathDescription(expression)] + } + + /// Build a block of ``AccountKeyWithDescription`` metatypes. + public static func buildBlock(_ components: [any AccountKeyWithDescription]...) -> [any AccountKeyWithDescription] { + buildArray(components) + } + + /// Build the first block of an conditional ``AccountKeyWithDescription`` metatype component. + public static func buildEither(first component: [any AccountKeyWithDescription]) -> [any AccountKeyWithDescription] { + component + } + + /// Build the second block of an conditional ``AccountKeyWithDescription`` metatype component. + public static func buildEither(second component: [any AccountKeyWithDescription]) -> [any AccountKeyWithDescription] { + component + } + + /// Build an optional ``AccountKeyWithDescription`` metatype component. + public static func buildOptional(_ component: [any AccountKeyWithDescription]?) -> [any AccountKeyWithDescription] { + // swiftlint:disable:previous discouraged_optional_collection + component ?? [] + } + + /// Build an ``AccountKeyWithDescription`` metatype component with limited availability. + public static func buildLimitedAvailability(_ component: [any AccountKeyWithDescription]) -> [any AccountKeyWithDescription] { + component + } + + /// Build an array of ``AccountKeyWithDescription`` metatype components. + public static func buildArray(_ components: [[any AccountKeyWithDescription]]) -> [any AccountKeyWithDescription] { + components.reduce(into: []) { result, components in + result.append(contentsOf: components) + } + } +} diff --git a/Sources/SpeziAccount/AccountValue/AccountKeys.swift b/Sources/SpeziAccount/AccountValue/AccountKeys.swift new file mode 100644 index 00000000..86b37741 --- /dev/null +++ b/Sources/SpeziAccount/AccountValue/AccountKeys.swift @@ -0,0 +1,23 @@ +// +// 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 +// + +/// A collection of ``AccountKeys`` type instances. +/// +/// This type is used across ``SpeziAccount`` API to easily and intuitively access the metatype of an ``AccountKey``. +/// +/// Below is a short example creating a ``AccountServiceConfiguration`` demonstrating the use of `KeyPath`-based access +/// to the metatype of an ``AccountKey``. +/// +/// ```swift +/// AccountServiceConfiguration(name: "TestEmailPasswordAccountService") { +/// FieldValidationRules(for: \.password, rules: .interceptingChain(.nonEmpty), .strongPassword) +/// } +/// ``` +public struct AccountKeys { + private init() {} +} diff --git a/Sources/SpeziAccount/AccountValue/AccountStorage.swift b/Sources/SpeziAccount/AccountValue/AccountStorage.swift new file mode 100644 index 00000000..7f6eb318 --- /dev/null +++ b/Sources/SpeziAccount/AccountValue/AccountStorage.swift @@ -0,0 +1,15 @@ +// +// This source file is part of the Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Spezi + + +/// A `ValueRepository` that stores `KnowledgeSource`s anchored to the ``AccountAnchor``. +/// +/// This is the underlying storage type user in, e.g., ``AccountDetails``, ``SignupDetails`` or ``ModifiedAccountDetails``. +public typealias AccountStorage = ValueRepository diff --git a/Sources/SpeziAccount/AccountValue/AccountValues.swift b/Sources/SpeziAccount/AccountValue/AccountValues.swift new file mode 100644 index 00000000..4582efb0 --- /dev/null +++ b/Sources/SpeziAccount/AccountValue/AccountValues.swift @@ -0,0 +1,113 @@ +// +// 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 +// + +import Spezi + + +/// An arbitrary collection of account values. +public protocol AccountValuesCollection: AcceptingAccountValueVisitor, Collection + where Index == AccountStorage.Index, Element == AccountStorage.Element { + /// Checks if the provided ``AccountKey`` is currently stored in the collection. + func contains(_ key: Key.Type) -> Bool + + /// Checks if the provided type-erase ``AccountKey`` is currently stored in the collection. + func contains(anyKey key: any AccountKey.Type) -> Bool +} + + +/// Storage unit for values of ``AccountKey``s. +/// +/// ## Topics +/// +/// ### Shared Repository +/// +/// - ``AccountAnchor`` +/// - ``AccountStorage`` +public protocol AccountValues: AccountValuesCollection { + /// Builder pattern to build a container of this type. + typealias Builder = AccountValuesBuilder + + /// The underlying storage repository. + var storage: AccountStorage { get } + + /// Retrieve the array of stored key of the repository. + /// + /// - Note: This doesn't contain stored `KnowledgeSources` that don't conform to ``AccountKey``. + var keys: [any AccountKey.Type] { get } + + /// Init from storage repository. Don't use this directly, use a instance of ``Builder``. + /// - Parameter storage: The storage repository. + init(from storage: AccountStorage) + + /// Merge with contents from a different ``AccountValues`` instance creating a new unit. + /// - Parameters: + /// - values: The account values to merge with. + /// - allowOverwrite: Flag indicating the the provided values might overwrite these already contained in here. + /// - Returns: The resulting values containing the combination of both ``AccountValues`` instances. + func merge(with values: Values, allowOverwrite: Bool) -> Self +} + + +extension AccountValues { + /// Default `Collection` implementation. + public var startIndex: Index { + storage.startIndex + } + + /// Default `Collection` implementation. + public var endIndex: Index { + storage.endIndex + } + + /// Default `Collection` implementation. + public func index(after index: Index) -> Index { + storage.index(after: index) + } + + + /// Default `Collection` implementation. + public subscript(position: Index) -> AnyRepositoryValue { + storage[position] + } +} + + +extension AccountValues { + /// Retrieve all keys stored in this collection. + public var keys: [any AccountKey.Type] { + self.compactMap { element in + element.anySource as? any AccountKey.Type + } + } + + /// Default contains implementation forwarding to the Shared Repository. + public func contains(_ key: Key.Type) -> Bool { + storage.contains(key) + } + + /// Default merge implementation. + public func merge(with values: Values, allowOverwrite: Bool = false) -> Self { + let build = AccountValuesBuilder(from: storage) + build.merging(values, allowOverwrite: allowOverwrite) + return build.build() + } +} + +extension AccountValuesCollection { + /// Default type-erased implementation. + public func contains(anyKey key: any AccountKey.Type) -> Bool { + key.anyContains(in: self) + } +} + + +extension AccountKey { + fileprivate static func anyContains(in collection: Collection) -> Bool { + collection.contains(Self.self) + } +} diff --git a/Sources/SpeziAccount/AccountValue/AccountValuesBuilder.swift b/Sources/SpeziAccount/AccountValue/AccountValuesBuilder.swift new file mode 100644 index 00000000..577540cb --- /dev/null +++ b/Sources/SpeziAccount/AccountValue/AccountValuesBuilder.swift @@ -0,0 +1,258 @@ +// +// This source file is part of the Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Foundation +import Spezi + + +private class RemoveVisitor: AccountKeyVisitor { + let builder: AccountValuesBuilder + + init(builder: AccountValuesBuilder) { + self.builder = builder + } + + func visit(_ key: Key.Type) { + builder.remove(key) + } +} + + +private class CopyVisitor: AccountValueVisitor { + let builder: AccountValuesBuilder + let allowOverwrite: Bool + + init(builder: AccountValuesBuilder, allowOverwrite: Bool) { + self.builder = builder + self.allowOverwrite = allowOverwrite + } + + func visit(_ key: Key.Type, _ value: Key.Value) { + if allowOverwrite || !builder.contains(Key.self) { + builder.set(key, value: value) + } + } +} + + +private class CopyKeyVisitor: AccountKeyVisitor { + let destination: AccountValuesBuilder + let source: Source + + init(destination: AccountValuesBuilder, source: Source) { + self.destination = destination + self.source = source + } + + func visit(_ key: Key.Type) { + if let value = source.storage.get(key) { + destination.set(key, value: value) + } + } +} + + +/// A builder interface for any ``AccountValues`` conforming types. +/// +/// This type allows to easily build and modify an instance of ``AccountValues``. +/// Use the ``AccountValues/Builder`` typealias to instantiate a builder for any given ``AccountValues`` implementation. +/// +/// ## Topics +/// +/// ### Creating a new builder +/// - ``AccountValuesBuilder/init()`` +/// - ``AccountValuesBuilder/init(from:)`` +/// +/// ### Setting a Account Value +/// - ``AccountValuesBuilder/set(_:value:)-6s8dc`` +/// - ``AccountValuesBuilder/set(_:value:)-1qcx7`` +/// +/// ### Merging +/// - ``AccountValuesBuilder/merging(_:allowOverwrite:)`` +/// - ``AccountValuesBuilder/merging(with:from:)`` +/// +/// ### Removal +/// - ``AccountValuesBuilder/remove(_:)-99d1h`` +/// - ``AccountValuesBuilder/remove(_:)-5m271`` +/// - ``AccountValuesBuilder/remove(any:)`` +/// - ``AccountValuesBuilder/remove(all:)`` +/// +/// ### Building +/// - ``AccountValuesBuilder/build()-pqt5`` +/// - ``AccountValuesBuilder/build(owner:)`` +/// - ``AccountValuesBuilder/build(checking:)`` +public class AccountValuesBuilder: ObservableObject, AccountValuesCollection { + @Published var storage: AccountStorage + + + init(from storage: AccountStorage) { + self.storage = storage + } + + /// Initialize a new empty builder. + public convenience init() { + self.init(from: .init()) + } + + /// Initialize a new builder by copying the contents of a ``AccountValues`` instance. + /// - Parameter storage: The storage to copy all values from. + public convenience init(from storage: Source) { + self.init(from: storage.storage) + } + + + /// Clear the builder's contents. + public func clear() { + storage = .init() + } + + /// Retrieve the current value stored in the builder. + /// - Parameter key: The ``AccountKey`` metatype. + /// - Returns: The value is present. + public func get(_ key: Key.Type) -> Key.Value? { + storage.get(key) + } + + /// Store a new value in the builder. + /// - Parameters: + /// - key: The ``AccountKey`` metatype. + /// - value: The value to store. + /// - Returns: The builder reference for method chaining. + @discardableResult + public func set(_ key: Key.Type, value: Key.Value?) -> Self { + if let value { + storage[Key.self] = value + } + return self + } + + /// Store a new value in the builder using `KeyPath` notation. + /// - Parameters: + /// - keyPath: The ``AccountKey`` metatype referenced by a `KeyPath`. + /// - value: The value to store. + /// - Returns: The builder reference for method chaining. + @discardableResult + public func set(_ keyPath: KeyPath, value: Key.Value?) -> Self { + set(Key.self, value: value) + } + + /// Merge all values from a ``AccountValues`` instance into this builder. + /// - Parameters: + /// - values: The values. + /// - allowOverwrite: Flag controls if the supplied values might overwrite values in the builder + /// - Returns: The builder reference for method chaining. + @discardableResult + public func merging(_ values: OtherValues, allowOverwrite: Bool) -> Self { + values.acceptAll(CopyVisitor(builder: self, allowOverwrite: allowOverwrite)) + return self + } + + /// Merge all values specified by a collections of ``AccountKey``s which values are stored in some ``AccountValues`` instance. + /// - Parameters: + /// - keys: The keys for which to copy account values. + /// - values: The container from where to retrieve the values. + /// - Returns: The builder reference for method chaining. + @discardableResult + public func merging( + with keys: Keys, + from values: OtherValues + ) -> Self { + keys.acceptAll(CopyKeyVisitor(destination: self, source: values)) + return self + } + + /// Remove a value from the builder. + /// - Parameter key: The ``AccountKey`` metatype. + /// - Returns: The builder reference for method chaining. + @discardableResult + public func remove(_ key: Key.Type) -> Self { + storage[key] = nil + return self + } + + /// Remove a value from the builder using `KeyPath` notation. + /// - Parameter keyPath: The ``AccountKey`` metatype reference by a `KeyPath`. + /// - Returns: The builder reference for method chaining. + @discardableResult + public func remove(_ keyPath: KeyPath) -> Self { + remove(Key.self) + } + + /// Remove a value from the builder using a type-erased ``AccountKey`` metatype. + /// - Parameter accountKey: The type-erased ``AccountKey``. + /// - Returns: The builder reference for method chaining. + @discardableResult + public func remove(any accountKey: any AccountKey.Type) -> Self { + accountKey.accept(RemoveVisitor(builder: self)) + return self + } + + /// Remove a set of values from the builder given an array of ``AccountKey`` metatypes. + /// - Parameter keys: The collection of metatypes (e.g., an array or ``AccountKeyCollection``). + /// - Returns: The builder reference for method chaining. + @discardableResult + public func remove(all keys: Keys) -> Self { + keys.acceptAll(RemoveVisitor(builder: self)) + return self + } + + /// Checks if a value for a ``AccountKey`` is present in the builder. + /// - Parameter key: The ``AccountKey`` metatype to check if a value exists. + /// - Returns: Returns `true` if present, otherwise `false`. + public func contains(_ key: Key.Type) -> Bool { + storage.contains(Key.self) + } + + /// Build a new storage instance. + /// - Returns: The built ``AccountValues``. + public func build() -> Values { + Values(from: storage) + } +} + + +extension AccountValuesBuilder { + /// Default `Collection` implementation. + public typealias Index = AccountStorage.Index + + /// Default `Collection` implementation. + public var startIndex: Index { + storage.startIndex + } + + /// Default `Collection` implementation. + public var endIndex: Index { + storage.endIndex + } + + /// Default `Collection` implementation. + public func index(after index: Index) -> Index { + storage.index(after: index) + } + + + /// Default `Collection` implementation. + public subscript(position: Index) -> AnyRepositoryValue { + storage[position] + } +} + +extension AccountValuesBuilder { + @discardableResult + func setEmptyValue(for accountKey: any AccountKey.Type) -> Self { + accountKey.setEmpty(in: self) + return self + } +} + + +extension AccountKey { + fileprivate static func setEmpty(in builder: AccountValuesBuilder) { + builder.set(Self.self, value: initialValue.value) + } +} diff --git a/Sources/SpeziAccount/AccountValue/Collections/AccountDetails.swift b/Sources/SpeziAccount/AccountValue/Collections/AccountDetails.swift new file mode 100644 index 00000000..1262d749 --- /dev/null +++ b/Sources/SpeziAccount/AccountValue/Collections/AccountDetails.swift @@ -0,0 +1,56 @@ +// +// This source file is part of the Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Spezi + + +/// A typed storage container to easily access any information for the currently signed in user. +/// +/// Refer to ``AccountKey`` for a list of bundled keys. +public struct AccountDetails: Sendable, AccountValues { + public typealias Element = AnyRepositoryValue // compiler is confused otherwise + + public private(set) var storage: AccountStorage + + + public init(from storage: AccountStorage) { + self.storage = storage + precondition(storage[ActiveAccountServiceKey.self] != nil, "Direct init access failed to supply ActiveAccountServiceKey") + } + + + fileprivate init(from storage: AccountStorage, owner accountService: Service) { + var storage = storage + + // patch the storage to make sure we make sure to not expose the plaintext password + storage[PasswordKey.self] = nil + storage[ActiveAccountServiceKey.self] = accountService + self.init(from: storage) + } + + mutating func patchAccountService(_ service: any AccountService) { + storage[ActiveAccountServiceKey.self] = service + } +} + + +extension AccountValuesBuilder where Values == AccountDetails { + /// ``AccountDetails`` must always be created through the ``build(owner:)`` method. + @available(*, deprecated, message: "You must use the build(owner:) method to build AccountDetails. This method will result in a runtime crash!") + public func build() -> AccountDetails { + // swiftlint:disable:previous unavailable_function + preconditionFailure("You need to build AccountDetails using build(owner:)") + } + + /// Build a new ``AccountDetails`` instance by linking the active ``AccountService`` responsible for managing this instance. + /// - Parameter accountService: The account service that created this instance. + /// - Returns: The ``AccountDetails`` instance. + public func build(owner accountService: Service) -> Values { + AccountDetails(from: self.storage, owner: accountService) + } +} diff --git a/Sources/SpeziAccount/AccountValue/Collections/AccountModifications.swift b/Sources/SpeziAccount/AccountValue/Collections/AccountModifications.swift new file mode 100644 index 00000000..ca535e46 --- /dev/null +++ b/Sources/SpeziAccount/AccountValue/Collections/AccountModifications.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 +// + + +/// A container that bundles added or modified ``AccountKey``s and removed ``AccountKey``s. +public struct AccountModifications: Sendable { + /// The set of modified (or added) ``AccountKey``s. + public let modifiedDetails: ModifiedAccountDetails + /// The set of removed ``AccountKey``s. + public let removedAccountDetails: RemovedAccountDetails + + init(modifiedDetails: ModifiedAccountDetails, removedAccountDetails: RemovedAccountDetails) { + self.modifiedDetails = modifiedDetails + self.removedAccountDetails = removedAccountDetails + } +} diff --git a/Sources/SpeziAccount/AccountValue/Collections/ModifiedAccountDetails.swift b/Sources/SpeziAccount/AccountValue/Collections/ModifiedAccountDetails.swift new file mode 100644 index 00000000..f2de3db1 --- /dev/null +++ b/Sources/SpeziAccount/AccountValue/Collections/ModifiedAccountDetails.swift @@ -0,0 +1,21 @@ +// +// This source file is part of the Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Spezi + + +/// Set of ``AccountValues`` that were modified or added. +public struct ModifiedAccountDetails: Sendable, AccountValues { + public typealias Element = AnyRepositoryValue // compiler is confused otherwise + + public let storage: AccountStorage + + public init(from storage: AccountStorage) { + self.storage = storage + } +} diff --git a/Sources/SpeziAccount/AccountValue/Collections/PartialAccountDetails.swift b/Sources/SpeziAccount/AccountValue/Collections/PartialAccountDetails.swift new file mode 100644 index 00000000..ca63d6bf --- /dev/null +++ b/Sources/SpeziAccount/AccountValue/Collections/PartialAccountDetails.swift @@ -0,0 +1,23 @@ +// +// 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 +// + +import Spezi + + +/// Set of ``AccountValues`` that resembled ``AccountDetails`` but may be incomplete in respect to the +/// ``AccountValueConfiguration`` defined by the user. +public struct PartialAccountDetails: Sendable, AccountValues { + public typealias Element = AnyRepositoryValue // compiler is confused otherwise + + public let storage: AccountStorage + + + public init(from storage: AccountStorage) { + self.storage = storage + } +} diff --git a/Sources/SpeziAccount/AccountValue/Collections/RemovedAccountDetails.swift b/Sources/SpeziAccount/AccountValue/Collections/RemovedAccountDetails.swift new file mode 100644 index 00000000..45eac818 --- /dev/null +++ b/Sources/SpeziAccount/AccountValue/Collections/RemovedAccountDetails.swift @@ -0,0 +1,22 @@ +// +// This source file is part of the Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Spezi + + +/// Set of ``AccountValues`` that were removed providing access to the old values. +public struct RemovedAccountDetails: Sendable, AccountValues { + public typealias Element = AnyRepositoryValue // compiler is confused otherwise + + public let storage: AccountStorage + + + public init(from storage: AccountStorage) { + self.storage = storage + } +} diff --git a/Sources/SpeziAccount/AccountValue/Collections/SignupDetails.swift b/Sources/SpeziAccount/AccountValue/Collections/SignupDetails.swift new file mode 100644 index 00000000..59540550 --- /dev/null +++ b/Sources/SpeziAccount/AccountValue/Collections/SignupDetails.swift @@ -0,0 +1,53 @@ +// +// This source file is part of the Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Spezi + + +/// Set of ``AccountValues`` that were collected at signup to create a new user account. +public struct SignupDetails: Sendable, AccountValues { + public typealias Element = AnyRepositoryValue // compiler is confused otherwise + + public let storage: AccountStorage + + + public init(from storage: AccountStorage) { + self.storage = storage + } + + + fileprivate func validateRequirements(checking configuration: AccountValueConfiguration) throws { + let missing = configuration.filter { configuration in + configuration.requirement == .required && !self.contains(configuration.key) + } + + if !missing.isEmpty { + let keyNames = missing.map { $0.keyPathDescription } + + LoggerKey.defaultValue.warning("\(keyNames) was/were required to be provided but wasn't/weren't provided!") + throw AccountValueConfigurationError.missingAccountValue(keyNames) + } + } +} + + +extension AccountValuesBuilder where Values == SignupDetails { + /// Building new ``SignupDetails`` while checking it's contents against the user-defined ``AccountValueConfiguration``. + /// - Parameter configuration: The configured provided by the user (see ``Account/configuration``). + /// - Returns: The built ``SignupDetails``. + /// - Throws: Throws potential ``AccountValueConfigurationError`` if requirements are not fulfilled. + public func build( + checking configuration: AccountValueConfiguration + ) throws -> Values { + let details = self.build() + + try details.validateRequirements(checking: configuration) + + return details + } +} diff --git a/Sources/SpeziAccount/AccountValue/Configuration/AccountKeyConfigurationImpl.swift b/Sources/SpeziAccount/AccountValue/Configuration/AccountKeyConfigurationImpl.swift new file mode 100644 index 00000000..749cd450 --- /dev/null +++ b/Sources/SpeziAccount/AccountValue/Configuration/AccountKeyConfigurationImpl.swift @@ -0,0 +1,68 @@ +// +// 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 +// + + +/// A user-configured ``AccountKey``. +public protocol AccountKeyConfiguration: CustomStringConvertible, CustomDebugStringConvertible, Identifiable, Hashable + where ID == ObjectIdentifier { + /// The associated ``AccountKey``. + associatedtype Key: AccountKey + + /// Access the ``AccountKey`` meta-type. + var key: Key.Type { get } + /// The requirement level that was defined for the ``AccountKey``. + var requirement: AccountKeyRequirement { get } + + /// A user-friendly, human-readable string description that resembles the `KeyPath` description. + var keyPathDescription: String { get } +} + + +struct AccountKeyConfigurationImpl: AccountKeyConfiguration { + let key: Key.Type + let requirement: AccountKeyRequirement + + let keyPathDescription: String + + init(_ keyPath: KeyPath, type: AccountKeyRequirement) { + self.key = Key.self + self.requirement = type + self.keyPathDescription = keyPath.shortDescription + } +} + + +extension AccountKeyConfigurationImpl { + var id: ObjectIdentifier { + key.id + } + + var description: String { + switch requirement { + case .required: + return ".requires(\(keyPathDescription))" + case .collected: + return ".collects(\(keyPathDescription))" + case .supported: + return ".supports(\(keyPathDescription))" + } + } + + var debugDescription: String { + description + } + + + static func == (lhs: AccountKeyConfigurationImpl, rhs: AccountKeyConfigurationImpl) -> Bool { + lhs.id == rhs.id + } + + func hash(into hasher: inout Hasher) { + id.hash(into: &hasher) + } +} diff --git a/Sources/SpeziAccount/AccountValue/Configuration/AccountKeyRequirement.swift b/Sources/SpeziAccount/AccountValue/Configuration/AccountKeyRequirement.swift new file mode 100644 index 00000000..0b20d42f --- /dev/null +++ b/Sources/SpeziAccount/AccountValue/Configuration/AccountKeyRequirement.swift @@ -0,0 +1,26 @@ +// +// This source file is part of the Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + + +/// Describes the user-configured requirement level for an ``AccountKey``. +public enum AccountKeyRequirement: Sendable { + /// The associated account value **must** be provided by the user at signup. + /// + /// It is mandatory to use the ``RequiredAccountKey`` protocol. + case required + /// The associated account value **can** be provided by the user at signup. + /// + /// The account value is collected at signup but there is no obligation for the user + /// to provide a value. + case collected + /// The associated account value **can** be provided by the user at a later point in time. + /// + /// The account value is **not** collected at signup. However, it is displayed in the account overview + /// and a user can supply a value by editing their account details. + case supported +} diff --git a/Sources/SpeziAccount/AccountValue/Configuration/AccountValueConfiguration.swift b/Sources/SpeziAccount/AccountValue/Configuration/AccountValueConfiguration.swift new file mode 100644 index 00000000..b4a3961b --- /dev/null +++ b/Sources/SpeziAccount/AccountValue/Configuration/AccountValueConfiguration.swift @@ -0,0 +1,97 @@ +// +// 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 +// + +import OrderedCollections + + +/// The user-defined configuration of account values that all user accounts need to support. +/// +/// Using a ``AccountValueConfiguration`` instance, the user can defined what ``AccountKey``s are required, +/// collected at signup or generally supported. You configure them by supplying an array of ``ConfiguredAccountKey``s. +/// +/// A configuration instance is created using ``AccountConfiguration`` and stored at ``Account/configuration``. +public struct AccountValueConfiguration { + /// The default set of ``ConfiguredAccountKey``s that `SpeziAccount` provides. + public static let `default` = AccountValueConfiguration(.default) + + + private var configuration: OrderedDictionary + + + init(_ configuration: [ConfiguredAccountKey]) { + self.configuration = configuration + .map { $0.configuration } + .reduce(into: [:]) { result, configuration in + result[configuration.id] = configuration + } + } + + + /// Retrieve the configuration for a given type-erased ``AccountKey``. + /// - Parameter key: The account key to query. + /// - Returns: The configuration for a given ``AccountKey`` if it exists. + public subscript(_ key: any AccountKey.Type) -> (any AccountKeyConfiguration)? { + configuration[key.id] + } + + /// Retrieve the configuration for a given ``AccountKey``. + /// - Parameter key: The account key to query. + /// - Returns: The configuration for a given ``AccountKey`` if it exists. + public subscript(_ key: Key.Type) -> (any AccountKeyConfiguration)? { + configuration[Key.id] + } + + /// Retrieve the configuration for a given ``AccountKey`` using `KeyPath` notation. + /// - Parameter keyPath: The `KeyPath` referencing the ``AccountKey``. + /// - Returns: The configuration for a given ``AccountKey`` if it exists. + public subscript(_ keyPath: KeyPath) -> (any AccountKeyConfiguration)? { + self[Key.self] + } +} + + +extension Array where Element == ConfiguredAccountKey { + /// The default array of ``ConfiguredAccountKey``s that `SpeziAccount` provides. + public static let `default`: [ConfiguredAccountKey] = [ + .requires(\.userId), + .requires(\.password), + .requires(\.name), + .collects(\.dateOfBirth), + .collects(\.genderIdentity) + ] +} + + +extension AccountValueConfiguration: Collection { + public typealias Index = OrderedDictionary.Index + + public var startIndex: Index { + configuration.values.startIndex + } + + public var endIndex: Index { + configuration.values.endIndex + } + + + public func index(after index: Index) -> Index { + configuration.values.index(after: index) + } + + + public subscript(position: Index) -> any AccountKeyConfiguration { + configuration.values[position] + } +} + + +extension AccountValueConfiguration: ExpressibleByArrayLiteral { + public init(arrayLiteral elements: ConfiguredAccountKey...) { + self.init(elements) + } +} diff --git a/Sources/SpeziAccount/AccountValue/Configuration/AccountValueConfigurationError.swift b/Sources/SpeziAccount/AccountValue/Configuration/AccountValueConfigurationError.swift new file mode 100644 index 00000000..2cac4e63 --- /dev/null +++ b/Sources/SpeziAccount/AccountValue/Configuration/AccountValueConfigurationError.swift @@ -0,0 +1,55 @@ +// +// This source file is part of the Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Foundation + + +/// An error that occurs due to restrictions or requirements of a ``AccountValueConfiguration``. +public enum AccountValueConfigurationError: LocalizedError { + /// A ``AccountKeyRequirement/required`` ``AccountKey`` that was not supplied by the signup view before + /// being passed to the ``AccountService``. + /// + /// - Note: This is an error in the view logic due to missing user-input sanitization or simply the view + /// forgot to supply the ``AccountKey`` when building the ``SignupDetails``. + case missingAccountValue(_ keyNames: [String]) + + + public var errorDescription: String? { + .init(localized: errorDescriptionValue, bundle: .module) + } + + public var failureReason: String? { + .init(localized: failureReasonValue, bundle: .module) + } + + public var recoverySuggestion: String? { + .init(localized: recoverySuggestionValue, bundle: .module) + } + + + private var errorDescriptionValue: String.LocalizationValue { + switch self { + case .missingAccountValue: + return "ACCOUNT_VALUES_MISSING_VALUE_DESCRIPTION" + } + } + + private var failureReasonValue: String.LocalizationValue { + switch self { + case let .missingAccountValue(keyName): + return "ACCOUNT_VALUES_MISSING_VALUE_REASON \(keyName.joined(separator: ", "))" + } + } + + private var recoverySuggestionValue: String.LocalizationValue { + switch self { + case .missingAccountValue: + return "ACCOUNT_VALUES_MISSING_VALUE_RECOVERY" + } + } +} diff --git a/Sources/SpeziAccount/AccountValue/Configuration/ConfiguredAccountKey.swift b/Sources/SpeziAccount/AccountValue/Configuration/ConfiguredAccountKey.swift new file mode 100644 index 00000000..e391b4a6 --- /dev/null +++ b/Sources/SpeziAccount/AccountValue/Configuration/ConfiguredAccountKey.swift @@ -0,0 +1,71 @@ +// +// 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 +// + + +/// A wrapper for ``AccountKeyConfiguration`` that provides accessors to create new configuration entries. +/// +/// Below is a code example demonstrating how you could instantiate new configuration entries. +/// +/// ```swift +/// let configurations: [ConfiguredAccountKey] = [ +/// .requires(\.userId), +/// .requires(\.password), +/// .collects(\.name), +/// .supported(\.genderIdentity) +/// ] +/// ``` +public struct ConfiguredAccountKey { + let configuration: any AccountKeyConfiguration + + + // once parameter packs arrive in swift we don't need this extra type and can just use `AccountKeyConfiguration` + private init(configuration: AccountKeyConfigurationImpl) { + self.configuration = configuration + } + + + /// Configure an ``AccountKey`` as ``AccountKeyRequirement/required``. + /// - Parameter keyPath: The `KeyPath` referencing the ``AccountKey``. + /// - Returns: Returns the ``AccountKey`` configuration. + public static func requires(_ keyPath: KeyPath) -> ConfiguredAccountKey { + .init(configuration: AccountKeyConfigurationImpl(keyPath, type: .required)) + } + + /// Configure an ``AccountKey`` as ``AccountKeyRequirement/collected``. + /// - Parameter keyPath: The `KeyPath` referencing the ``AccountKey``. + /// - Returns: Returns the ``AccountKey`` configuration. + @_disfavoredOverload + public static func collects(_ keyPath: KeyPath) -> ConfiguredAccountKey { + .init(configuration: AccountKeyConfigurationImpl(keyPath, type: .collected)) + } + + /// Configure an ``AccountKey`` as ``AccountKeyRequirement/supported``. + /// - Parameter keyPath: The `KeyPath` referencing the ``AccountKey``. + /// - Returns: Returns the ``AccountKey`` configuration. + @_disfavoredOverload + public static func supports(_ keyPath: KeyPath) -> ConfiguredAccountKey { + .init(configuration: AccountKeyConfigurationImpl(keyPath, type: .supported)) + } + + /// Configure an ``AccountKey`` as ``AccountKeyRequirement/required`` as ``RequiredAccountKey`` can only be configured as required. + /// - Parameter keyPath: The `KeyPath` referencing the ``AccountKey``. + /// - Returns: Returns the ``AccountKey`` configuration. + @available(*, deprecated, renamed: "requires", message: "A 'RequiredAccountKey' must always be supplied as required using requires(_:)") + public static func collects(_ keyPath: KeyPath) -> ConfiguredAccountKey { + // sadly we can't make this a compiler error. using `unavailable` makes it unavailable as an overload completely. + requires(keyPath) + } + + /// Configure an ``AccountKey`` as ``AccountKeyRequirement/required`` as ``RequiredAccountKey`` can only be configured as required. + /// - Parameter keyPath: The `KeyPath` referencing the ``AccountKey``. + /// - Returns: Returns the ``AccountKey`` configuration. + @available(*, deprecated, renamed: "requires", message: "A 'RequiredAccountKey' must always be supplied as required using requires(_:)") + public static func supports(_ keyPath: KeyPath) -> ConfiguredAccountKey { + requires(keyPath) + } +} diff --git a/Sources/SpeziAccount/AccountValue/InitialValue.swift b/Sources/SpeziAccount/AccountValue/InitialValue.swift new file mode 100644 index 00000000..634dcf6b --- /dev/null +++ b/Sources/SpeziAccount/AccountValue/InitialValue.swift @@ -0,0 +1,34 @@ +// +// 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 +// + +import Foundation + + +/// Provides the type of initial value for an ``AccountKey``. +public enum InitialValue { + /// The initial value is considered an empty value and the user is forced + /// to provide their own input. + /// + /// This applies for example to most `String`-based values. `""` is an empty value that is considered + /// to be no value at all. The user must provide some non-empty string. + case empty(_ value: Value) + /// The initial value is considered a default value and the user might change the selection + /// or continue with the default provided. + /// + /// This often times applies to enum based values where there is a default case that already is valid selection. + case `default`(_ value: Value) + + var value: Value { + switch self { + case let .empty(value): + return value + case let .default(value): + return value + } + } +} diff --git a/Sources/SpeziAccount/AccountValue/Keys/ActiveAccountServiceKey.swift b/Sources/SpeziAccount/AccountValue/Keys/ActiveAccountServiceKey.swift new file mode 100644 index 00000000..6ab4ea6c --- /dev/null +++ b/Sources/SpeziAccount/AccountValue/Keys/ActiveAccountServiceKey.swift @@ -0,0 +1,50 @@ +// +// 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 +// + +import Spezi + + +/// A `KnowledgeSource` to access the ``AccountService`` associated with the ``AccountDetails``. +public struct ActiveAccountServiceKey: KnowledgeSource { + public typealias Anchor = AccountAnchor + public typealias Value = any AccountService +} + + +extension AccountDetails { + /// Access the ``AccountService`` associated with the ``AccountDetails``. + /// + /// - Note: You should not assume the type of the active ``AccountService``. Depending on the configuration, + /// the active ``AccountService`` might be wrapped into other account service types and, therefore, + /// type cast will fail unexpectedly. + /// An ``AccountService`` might communicate extra functionality using the ``AccountServiceConfiguration``. + public var accountService: any AccountService { + guard let accountService = storage[ActiveAccountServiceKey.self] else { + fatalError(""" + The active Account Service is not present in the AccountDetails storage. \ + This could happen, e.g., if you did not properly set up your PreviewProvider. + """) + } + + return accountService + } +} + + +extension AccountDetails { + /// Short-hand access to the ``AccountServiceConfiguration``. + public var accountServiceConfiguration: AccountServiceConfiguration { + accountService.configuration + } + + /// Short-hand access to the ``UserIdType`` stored in the ``UserIdConfiguration`` of + /// the ``AccountServiceConfiguration`` of the currently active ``AccountService``. + public var userIdType: UserIdType { + accountService.configuration.userIdConfiguration.idType + } +} diff --git a/Sources/SpeziAccount/AccountValue/Keys/DateOfBirthKey.swift b/Sources/SpeziAccount/AccountValue/Keys/DateOfBirthKey.swift new file mode 100644 index 00000000..ea2b17a7 --- /dev/null +++ b/Sources/SpeziAccount/AccountValue/Keys/DateOfBirthKey.swift @@ -0,0 +1,71 @@ +// +// This source file is part of the Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SwiftUI + + +/// The date of birth of a user. +public struct DateOfBirthKey: AccountKey { + public typealias Value = Date + public typealias DataEntry = DateOfBirthPicker + + public static let name = LocalizedStringResource("UAP_SIGNUP_DATE_OF_BIRTH_TITLE", bundle: .atURL(from: .module)) + + public static let category: AccountKeyCategory = .personalDetails + + public static var initialValue: InitialValue { + .empty(Date()) + } +} + +extension AccountKeys { + /// The date of birth ``AccountKey`` metatype. + public var dateOfBirth: DateOfBirthKey.Type { + DateOfBirthKey.self + } +} + + +extension AccountValues { + /// Access the date of birth of a user. + public var dateOfBrith: Date? { + storage[DateOfBirthKey.self] + } +} + + +// MARK: - UI + +extension DateOfBirthKey { + public struct DataDisplay: DataDisplayView { + public typealias Key = DateOfBirthKey + + private let value: Date + + @Environment(\.locale) private var locale + + private var formatStyle: Date.FormatStyle { + .init() + .locale(locale) + .year(.defaultDigits) + .month(locale.identifier == "en_US" ? .abbreviated : .defaultDigits) + .day(.defaultDigits) + } + + public var body: some View { + SimpleTextRow(name: Key.name) { + Text(value.formatted(formatStyle)) + } + } + + + public init(_ value: Date) { + self.value = value + } + } +} diff --git a/Sources/SpeziAccount/AccountValue/Keys/EmailAddressKey.swift b/Sources/SpeziAccount/AccountValue/Keys/EmailAddressKey.swift new file mode 100644 index 00000000..be930c3b --- /dev/null +++ b/Sources/SpeziAccount/AccountValue/Keys/EmailAddressKey.swift @@ -0,0 +1,74 @@ +// +// 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 +// + +import Spezi +import SwiftUI + + +/// The email address of a user. +public struct EmailAddressKey: AccountKey, OptionalComputedKnowledgeSource { + public typealias StoragePolicy = AlwaysCompute + public typealias Value = String + + public static let name = LocalizedStringResource("USER_ID_EMAIL", bundle: .atURL(from: .module)) + + public static let category: AccountKeyCategory = .contactDetails + + + public static func compute>(from repository: Repository) -> String? { + if let email = repository.get(Self.self) { + // if we have manually stored a value for this key we return it + return email + } + + + guard let activeService = repository[ActiveAccountServiceKey.self], + activeService.configuration.userIdConfiguration.idType == .emailAddress else { + return nil + } + + // return the userId if it's a email address + return repository[UserIdKey.self] + } +} + + +extension AccountKeys { + /// The email ``EmailAddressKey`` metatype. + public var email: EmailAddressKey.Type { + EmailAddressKey.self + } +} + + +extension AccountValues { + /// Access the email address of a user. + public var email: String? { + storage[EmailAddressKey.self] + } +} + + +// MARK: - UI +extension EmailAddressKey { + public struct DataEntry: DataEntryView { + public typealias Key = EmailAddressKey + + @Binding private var email: Value + + public init(_ value: Binding) { + self._email = value + } + + public var body: some View { + VerifiableTextField(Key.name, text: $email) + .textContentType(.emailAddress) + .disableFieldAssistants() + } + } +} diff --git a/Sources/SpeziAccount/AccountValue/Keys/GenderIdentityKey.swift b/Sources/SpeziAccount/AccountValue/Keys/GenderIdentityKey.swift new file mode 100644 index 00000000..1e36045b --- /dev/null +++ b/Sources/SpeziAccount/AccountValue/Keys/GenderIdentityKey.swift @@ -0,0 +1,53 @@ +// +// This source file is part of the Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SwiftUI + + +/// The gender identity of a user. +/// +/// ## Topics +/// +/// ### Model +/// - ``GenderIdentity`` +public struct GenderIdentityKey: AccountKey { + public typealias Value = GenderIdentity + public typealias DataEntry = GenderIdentityPicker + + public static let name = LocalizedStringResource("GENDER_IDENTITY_TITLE", bundle: .atURL(from: .module)) + + public static let category: AccountKeyCategory = .personalDetails + + public static let initialValue: InitialValue = .default(.preferNotToState) +} + + +extension AccountKeys { + /// The gender identity ``AccountKey`` metatype. + public var genderIdentity: GenderIdentityKey.Type { + GenderIdentityKey.self + } +} + + +extension AccountValues { + /// Access the gender identity of a user. + public var genderIdentity: GenderIdentity? { + storage[GenderIdentityKey.self] + } +} + + +// MARK: - UI +extension GenderIdentityPicker: DataEntryView { + public typealias Key = GenderIdentityKey + + public init(_ value: Binding) { + self.init(genderIdentity: value) + } +} diff --git a/Sources/SpeziAccount/AccountValue/Keys/PasswordKey.swift b/Sources/SpeziAccount/AccountValue/Keys/PasswordKey.swift new file mode 100644 index 00000000..a077da35 --- /dev/null +++ b/Sources/SpeziAccount/AccountValue/Keys/PasswordKey.swift @@ -0,0 +1,108 @@ +// +// This source file is part of the Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SpeziViews +import SwiftUI + + +/// The password of a user account. +/// +/// This ``AccountKey`` transports the plain-text password of a user account. +/// - Note: This account value is only ever present in the ``SignupDetails`` and ``ModifiedAccountDetails`` and +/// never present in any of the other ``AccountValues``. +/// +/// ## Topics +/// +/// ### Password UI +/// +/// - ``PasswordFieldType`` +public struct PasswordKey: RequiredAccountKey { + public typealias Value = String + + public static let name = LocalizedStringResource("UP_PASSWORD", bundle: .atURL(from: .module)) + + public static let category: AccountKeyCategory = .credentials +} + + +extension AccountKeys { + /// The password ``AccountKey`` metatype. + /// + /// - Note: This account value is only present in the ``SignupDetails``. + public var password: PasswordKey.Type { + PasswordKey.self + } +} + + +extension SignupDetails { + /// Access the password of a user in the ``SignupDetails``. + public var password: String { + storage[PasswordKey.self] + } +} + +extension ModifiedAccountDetails { + /// Access the changed password of a user in the ``ModifiedAccountDetails``. + public var password: String? { + storage[PasswordKey.self] + } +} + + +// MARK: - UI + +extension PasswordKey { + public struct DataEntry: DataEntryView { + public typealias Key = PasswordKey + + @Environment(\.accountViewType) private var accountViewType + @Environment(\.passwordFieldType) private var fieldType + + @EnvironmentObject var validationEngine: ValidationEngine + + @Binding private var password: Value + + + public init(_ value: Binding) { + self._password = value + } + + public var body: some View { + switch accountViewType { + case .signup, .none: + VerifiableTextField(fieldType.localizedStringResource, text: $password, type: .secure) + .textContentType(.newPassword) + .disableFieldAssistants() + case .overview: // display description labels in the PasswordChangeSheet (as we have two password fields) + DescriptionGridRow { + Text(fieldType.localizedStringResource) + } content: { + SecureField(text: $password) { + Text(fieldType.localizedPrompt) + } + .textContentType(.newPassword) + .disableFieldAssistants() + .onChange(of: password) { _ in + validationEngine.submit(input: password, debounce: true) + } + .onSubmit { + validationEngine.submit(input: password, debounce: false) + } + } + + if !validationEngine.displayedValidationResults.isEmpty { // otherwise we have some weird layout issues + HStack { + ValidationResultsView(results: validationEngine.displayedValidationResults) + Spacer() + } + } + } + } + } +} diff --git a/Sources/SpeziAccount/AccountValue/Keys/PersonNameKey.swift b/Sources/SpeziAccount/AccountValue/Keys/PersonNameKey.swift new file mode 100644 index 00000000..9b4f3ae0 --- /dev/null +++ b/Sources/SpeziAccount/AccountValue/Keys/PersonNameKey.swift @@ -0,0 +1,163 @@ +// +// This source file is part of the Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SpeziViews +import SwiftUI + + +/// The name of a user. +public struct PersonNameKey: AccountKey { + public typealias Value = PersonNameComponents + + public static let name = LocalizedStringResource("NAME", bundle: .atURL(from: .module)) + + public static let category: AccountKeyCategory = .name + + public static let initialValue: InitialValue = .empty(PersonNameComponents()) +} + + +extension AccountKeys { + /// The name ``AccountKey`` metatype. + public var name: PersonNameKey.Type { + PersonNameKey.self + } +} + + +extension AccountValues { + /// Access the name of a user. + public var name: PersonNameComponents? { + storage[PersonNameKey.self] + } +} + + +// MARK: - UI + +extension PersonNameKey { + public struct DataDisplay: DataDisplayView { + public typealias Key = PersonNameKey + + private let value: PersonNameComponents + + public var body: some View { + Text(Key.name) + Spacer() + Text(value.formatted(.name(style: .long))) + .foregroundColor(.secondary) + } + + + public init(_ value: PersonNameComponents) { + self.value = value + } + } +} + +extension PersonNameKey { + public struct DataEntry: DataEntryView { + public typealias Key = PersonNameKey + + + private static let givenNameRule = ValidationRule( + copy: .nonEmpty, + message: .init("VALIDATION_RULE_GIVEN_NAME_EMPTY", bundle: .atURL(from: .module)) + ) + + private static let familyNameRule = ValidationRule( + copy: .nonEmpty, + message: .init("VALIDATION_RULE_FAMILY_NAME_EMPTY", bundle: .atURL(from: .module)) + ) + + + @EnvironmentObject private var account: Account + @EnvironmentObject private var focusState: FocusStateObject + @EnvironmentObject private var engines: ValidationEngines + + @StateObject private var validationGivenName = ValidationEngine(rules: givenNameRule) + @StateObject private var validationFamilyName = ValidationEngine(rules: familyNameRule) + + @Binding private var name: Value + + private var nameIsRequired: Bool { + account.configuration[Key.self]?.requirement == .required + } + + private var givenNameField: String { + Key.focusState + } + + private var familyNameField: String { + Key.focusState + "-familyName" + } + + public var body: some View { + let fields = NameFields( + name: $name, + givenNameField: FieldLocalizationResource( + title: "UAP_SIGNUP_GIVEN_NAME_TITLE", + placeholder: "UAP_SIGNUP_GIVEN_NAME_PLACEHOLDER", + bundle: .module + ), + givenNameFieldIdentifier: givenNameField, + familyNameField: FieldLocalizationResource( + title: "UAP_SIGNUP_FAMILY_NAME_TITLE", + placeholder: "UAP_SIGNUP_FAMILY_NAME_PLACEHOLDER", + bundle: .module + ), + familyNameFieldIdentifier: familyNameField, + focusedState: focusState.focusedField + ) + .onChange(of: name.givenName ?? "") { newValue in + submit(value: newValue, to: \.validationGivenName) + } + .onChange(of: name.familyName ?? "") { newValue in + submit(value: newValue, to: \.validationFamilyName) + } + .onChange(of: name.givenName) { newValue in + // patch value, such that the empty check in the GeneralizedDataEntry works + if newValue?.isEmpty == true { + name.givenName = nil + } + } + .onChange(of: name.familyName) { newValue in + // patch value, such that the empty check in the GeneralizedDataEntry works + if newValue?.isEmpty == true { + name.familyName = nil + } + } + + if nameIsRequired { + fields + .register(engine: validationGivenName, with: engines, for: givenNameField, input: name.givenName ?? "") + .register(engine: validationFamilyName, with: engines, for: familyNameField, input: name.familyName ?? "") + } else { + fields + } + + HStack { + ValidationResultsView(results: validationGivenName.displayedValidationResults + validationFamilyName.displayedValidationResults) + Spacer() + } + } + + + public init(_ value: Binding) { + self._name = value + } + + private func submit(value: String, to validationEngine: KeyPath) { + guard nameIsRequired else { + return + } + + self[keyPath: validationEngine].submit(input: value, debounce: true) + } + } +} diff --git a/Sources/SpeziAccount/AccountValue/Keys/UserIdKey.swift b/Sources/SpeziAccount/AccountValue/Keys/UserIdKey.swift new file mode 100644 index 00000000..c838721c --- /dev/null +++ b/Sources/SpeziAccount/AccountValue/Keys/UserIdKey.swift @@ -0,0 +1,84 @@ +// +// This source file is part of the Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SwiftUI + + +/// A string-based, unique user identifier. +/// +/// The `userId` is used to uniquely identify a given account. The value might carry +/// additional semantics. For example, the `userId` might, at the same time, be the primary email address +/// of the user. Such semantics can be controlled by the ``AccountService`` +/// using the ``UserIdType`` configuration. +/// +/// - Note: You may also refer to the ``EmailAddressKey`` to query the email address of an account. +public struct UserIdKey: RequiredAccountKey { + public typealias Value = String + + public static let name = LocalizedStringResource("USER_ID", bundle: .atURL(from: .module)) + + public static let category: AccountKeyCategory = .credentials +} + + +extension AccountKeys { + /// The userid ``UserIdKey`` metatype. + public var userId: UserIdKey.Type { + UserIdKey.self + } +} + + +extension AccountValues { + /// Access the user id of a user (see ``UserIdKey``). + public var userId: String { + storage[UserIdKey.self] + } +} + + +// MARK: - UI +extension UserIdKey { + public struct DataDisplay: DataDisplayView { + public typealias Key = UserIdKey + + private let value: String + + @Environment(\.accountServiceConfiguration) private var configuration + + public var body: some View { + SimpleTextRow(name: configuration.userIdConfiguration.idType.localizedStringResource) { + Text(verbatim: value) + } + } + + public init(_ value: String) { + self.value = value + } + } + + public struct DataEntry: DataEntryView { + public typealias Key = UserIdKey + + @Environment(\.accountServiceConfiguration) private var configuration + + @Binding var userId: Value + + + public init(_ value: Binding) { + self._userId = value + } + + public var body: some View { + VerifiableTextField(configuration.userIdConfiguration.idType.localizedStringResource, text: $userId) + .textContentType(configuration.userIdConfiguration.textContentType) + .keyboardType(configuration.userIdConfiguration.keyboardType) + .disableFieldAssistants() + } + } +} diff --git a/Sources/SpeziAccount/AccountValue/RequiredAccountKey.swift b/Sources/SpeziAccount/AccountValue/RequiredAccountKey.swift new file mode 100644 index 00000000..4e77c3ce --- /dev/null +++ b/Sources/SpeziAccount/AccountValue/RequiredAccountKey.swift @@ -0,0 +1,38 @@ +// +// 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 +// + +import Spezi + + +/// A typed storage key extending ``AccountKey`` for values that are required for every user account if used. +/// +/// A `RequiredAccountKey` can be used to provide the guarantee on a type-level that a given account key +/// will be present in every case. +/// +/// - Important: When using a ``RequiredAccountKey`` the user is forced to configure it as ``AccountKeyRequirement/required``. +/// But a user might still choose to not configure it at all. +/// +/// Use this protocol with care. Accessing the value of a `RequiredAccountKey` when it isn't present, +/// will result in a runtime crash. When you introduce a new required account key after user accounts +/// have already been created without it, make sure to use optional bindings by directly accessing ``AccountValues/storage`` +/// to safely unwrap the value. +public protocol RequiredAccountKey: AccountKey, DefaultProvidingKnowledgeSource {} + +extension RequiredAccountKey { + /// A default implementation that results in a fatal error. + /// + /// - Important: While the ``RequiredAccountKey`` conforms to `DefaultProvidingKnowledgeSource` in carries + /// inherently different meaning and just relies on the fact that accessors for `DefaultProvidingKnowledgeSource` + /// return a non-optional `Value`. + public static var defaultValue: Value { + preconditionFailure(""" + The required account key \(Self.self) was tried to be accessed but wasn't provided! \ + Please verify your `AccountConfiguration` or the implementation of your `AccountService`. + """) + } +} diff --git a/Sources/SpeziAccount/AccountValue/Visitor/AccountKey+Visitor.swift b/Sources/SpeziAccount/AccountValue/Visitor/AccountKey+Visitor.swift new file mode 100644 index 00000000..9dfa44ad --- /dev/null +++ b/Sources/SpeziAccount/AccountValue/Visitor/AccountKey+Visitor.swift @@ -0,0 +1,55 @@ +// +// 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 +// + +import Spezi + + +extension AccountKey { + /// Accept a ``AccountValueVisitor`` on a single ``AccountKey`` metatype given an associated value. + public static func accept(_ visitor: inout Visitor, _ value: Value) { + if let requiredKey = self as? any RequiredAccountKey.Type { + requiredKey.acceptRequired(&visitor, value) + } else { + visitor.visit(Self.self, value) + } + } + + /// Accept a ``AccountValueVisitor`` on a single ``AccountKey`` metatype given an associated value. + public static func accept(_ visitor: Visitor, _ value: Value) where Visitor: AnyObject { + var visitor = visitor + accept(&visitor, value) + } + + /// Accept a ``AccountKeyVisitor`` on a single ``AccountKey`` metatype. + public static func accept(_ visitor: inout Visitor) { + if let requiredKey = self as? any RequiredAccountKey.Type { + requiredKey.acceptRequired(&visitor) + } else { + visitor.visit(Self.self) + } + } + + /// Accept a ``AccountKeyVisitor`` on a single ``AccountKey`` metatype. + public static func accept(_ visitor: Visitor) where Visitor: AnyObject { + var visitor = visitor + accept(&visitor) + } +} + +extension RequiredAccountKey { + fileprivate static func acceptRequired(_ visitor: inout Visitor, _ value: Any) { + guard let value = value as? Value else { + preconditionFailure("Tried to visit \(Self.self) with value \(value) which is not of type \(Value.self)") + } + visitor.visit(Self.self, value) + } + + fileprivate static func acceptRequired(_ visitor: inout Visitor) { + visitor.visit(Self.self) + } +} diff --git a/Sources/SpeziAccount/AccountValue/Visitor/AccountKeyVisitor.swift b/Sources/SpeziAccount/AccountValue/Visitor/AccountKeyVisitor.swift new file mode 100644 index 00000000..af16e4a3 --- /dev/null +++ b/Sources/SpeziAccount/AccountValue/Visitor/AccountKeyVisitor.swift @@ -0,0 +1,100 @@ +// +// 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 +// + + +/// A collection type that is capable of accepting an ``AccountKeyVisitor``. +public protocol AcceptingAccountKeyVisitor { + /// Accepts an ``AccountKeyVisitor`` for all elements of the collection. + /// - Parameter visitor: The visitor to accept. + /// - Returns: The ``AccountKeyVisitor/Final`` result or `Void`. + func acceptAll(_ visitor: inout Visitor) -> Visitor.Final + + /// Accepts an ``AccountKeyVisitor`` for all elements of the collection. + /// - Parameter visitor: The visitor to accept. Provided as a reference type. + /// - Returns: The ``AccountKeyVisitor/Final`` result or `Void`. + func acceptAll(_ visitor: Visitor) -> Visitor.Final where Visitor: AnyObject +} + + +/// A visitor to visit ``AccountKey`` metatypes. +/// +/// Use the ``AcceptingAccountKeyVisitor/acceptAll(_:)-1ytax`` method on supporting types to +/// visit all contained metatypes. +public protocol AccountKeyVisitor { + /// A optional final result type returned by ``final()-66gfx``. + associatedtype Final = Void + + /// Visit a single ``AccountKey`` metatype. + /// - Parameter key: The ``AccountKey`` metatype. + mutating func visit(_ key: Key.Type) + + /// Visit a single ``RequiredAccountKey`` metatype. + /// + /// - Note: If the implementation is not provided, the call is automatically forwarded to ``visit(_:)-3qt1c``. + /// - Parameter key: The ``RequiredAccountKey`` metatype. + mutating func visit(_ key: Key.Type) + + /// The final result of the visitor. + /// + /// This method can be used to deliver a final result of the visitor. This method has a `Void` default implementation. + /// + /// - Note: This method is only called if the visitor is used using ``AcceptingAccountKeyVisitor/acceptAll(_:)-1ytax``. + /// If you directly call ``AccountKey/accept(_:)-8wakg`` this will not be called and has no effect. + /// - Returns: The final result. + mutating func final() -> Final +} + + +extension AccountKeyVisitor { + /// Default implementation forwarding to ``visit(_:)-3qt1c``. + public mutating func visit(_ key: Key.Type) { + key.defaultAccept(&self) + } +} + + +extension AccountKeyVisitor where Final == Void { + /// Default `Void` implementation. + public func final() {} +} + + +extension AccountKey { + fileprivate static func defaultAccept(_ visitor: inout Visitor) { + visitor.visit(Self.self) + } + + // use by acceptAll + fileprivate static func anyAccept(_ visitor: inout Visitor) { + accept(&visitor) + } +} + + +extension AcceptingAccountKeyVisitor { + /// Default acceptAll visitor for reference types. + public func acceptAll(_ visitor: Visitor) -> Visitor.Final where Visitor: AnyObject { + var visitor = visitor + return acceptAll(&visitor) + } +} + + +extension AcceptingAccountKeyVisitor where Self: Collection, Element == any AccountKey.Type { + /// Default acceptAll visitor. + public func acceptAll(_ visitor: inout Visitor) -> Visitor.Final { + for key in self { + key.anyAccept(&visitor) + } + + return visitor.final() + } +} + + +extension Array: AcceptingAccountKeyVisitor where Element == any AccountKey.Type {} diff --git a/Sources/SpeziAccount/AccountValue/Visitor/AccountValueVisitor.swift b/Sources/SpeziAccount/AccountValue/Visitor/AccountValueVisitor.swift new file mode 100644 index 00000000..de87e0c5 --- /dev/null +++ b/Sources/SpeziAccount/AccountValue/Visitor/AccountValueVisitor.swift @@ -0,0 +1,108 @@ +// +// 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 +// + +import Spezi + + +/// A collection type that is capable of accepting an ``AccountValueVisitor``. +public protocol AcceptingAccountValueVisitor { + /// Accepts an ``AccountValueVisitor`` for all elements of the collection. + /// - Parameter visitor: The visitor to accept. + /// - Returns: The ``AccountValueVisitor/Final`` result or `Void`. + func acceptAll(_ visitor: inout Visitor) -> Visitor.Final + + /// Accepts an ``AccountValueVisitor`` for all elements of the collection. + /// - Parameter visitor: The visitor to accept. Provided as a reference type. + /// - Returns: The ``AccountValueVisitor/Final`` result or `Void`. + func acceptAll(_ visitor: Visitor) -> Visitor.Final where Visitor: AnyObject +} + + +/// A visitor to visit ``AccountKey``s and their corresponding values. +/// +/// Use the ``AcceptingAccountValueVisitor/acceptAll(_:)-9hgw5`` method on supporting types to visit all contained values. +public protocol AccountValueVisitor { + /// A optional final result type returned by ``final()-7apm4``. + associatedtype Final = Void + + /// Visit a single ``AccountKey`` and it's value. + /// - Parameters: + /// - key: The ``AccountKey`` metatype. + /// - value: The stored value. + mutating func visit(_ key: Key.Type, _ value: Key.Value) + + /// Visit a single ``RequiredAccountKey`` and it's value. + /// + /// - Note: If the implementation is not provided, the call is automatically forwarded to ``visit(_:_:)-35w7i``. + /// - Parameters: + /// - key: The ``RequiredAccountKey`` metatype. + /// - value: The stored value + mutating func visit(_ key: Key.Type, _ value: Key.Value) + + /// The final result of the visitor. + /// + /// This method can be used to deliver a final result of the visitor. This method has a `Void` default implementation. + /// + /// - Note: This method is only called if the visitor is used using ``AcceptingAccountValueVisitor/acceptAll(_:)-9hgw5``. + /// If you directly call ``AccountKey/accept(_:_:)-8fw0g`` this will not be called and has no effect. + /// - Returns: The final result. + mutating func final() -> Final +} + + +extension AccountValueVisitor { + /// Default implementation forwarding to ``visit(_:_:)-35w7i``. + public mutating func visit(_ key: Key.Type, _ value: Key.Value) { + key.defaultAccept(&self, value) + } +} + + +extension AccountValueVisitor where Final == Void { + /// Default `Void` implementation. + public func final() {} +} + + +extension AccountKey { + fileprivate static func defaultAccept(_ visitor: inout Visitor, _ value: Value) { + visitor.visit(Self.self, value) + } + + // use by acceptAll + fileprivate static func anyAccept(_ visitor: inout Visitor, _ value: Any) { + guard let value = value as? Value else { + preconditionFailure("Tried to visit \(Self.self) with value \(value) which is not of type \(Value.self)") + } + + accept(&visitor, value) + } +} + + +extension AccountValuesCollection { + /// Default acceptAll visitor. + public func acceptAll(_ visitor: inout Visitor) -> Visitor.Final { + for entry in self { + // not all knowledge sources are `AccountKey` + guard let accountKey = entry.anySource as? any AccountKey.Type else { + continue + } + + accountKey.anyAccept(&visitor, entry.anyValue) + } + + return visitor.final() + } + + /// Default acceptAll visitor for reference types. + public func acceptAll(_ visitor: Visitor) -> Visitor.Final where Visitor: AnyObject { + var visitor = visitor + return acceptAll(&visitor) + } +} diff --git a/Sources/SpeziAccount/Email and Password/EmailPasswordAccountService.swift b/Sources/SpeziAccount/Email and Password/EmailPasswordAccountService.swift deleted file mode 100644 index 1186c29a..00000000 --- a/Sources/SpeziAccount/Email and Password/EmailPasswordAccountService.swift +++ /dev/null @@ -1,93 +0,0 @@ -// -// This source file is part of the Spezi open-source project -// -// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -import RegexBuilder -import Spezi -import SpeziViews -import SwiftUI - - -/// Account service that enables a email and password based account management. -/// -/// The ``EmailPasswordAccountService`` enables a email and password based account management based on the ``UsernamePasswordAccountService``. -/// -/// Other ``AccountService``s can be created by subclassing the ``EmailPasswordAccountService`` and overriding the ``EmailPasswordAccountService/localization``, -/// buttons like the ``EmailPasswordAccountService/loginButton``, or overriding the ``EmailPasswordAccountService/button(_:destination:)`` function. -open class EmailPasswordAccountService: UsernamePasswordAccountService { - public var emailValidationRule: ValidationRule { - guard let regex = try? Regex("[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}") else { - fatalError("Invalid E-Mail Regex in the EmailPasswordAccountService") - } - - return ValidationRule( - regex: regex, - message: String(localized: "EAP_EMAIL_VERIFICATION_ERROR", bundle: .module) - ) - } - - override open var localization: Localization { - let usernameField = FieldLocalizationResource( - title: LocalizedStringResource("EAP_LOGIN_USERNAME_TITLE", bundle: .atURL(from: .module)), - placeholder: LocalizedStringResource("EAP_LOGIN_USERNAME_PLACEHOLDER", bundle: .atURL(from: .module)) - ) - return Localization( - login: .init(buttonTitle: LocalizedStringResource("EAP_LOGIN_BUTTON_TITLE", bundle: .atURL(from: .module)), username: usernameField), - signUp: .init(buttonTitle: LocalizedStringResource("EAP_SIGNUP_BUTTON_TITLE", bundle: .atURL(from: .module)), username: usernameField), - resetPassword: .init(username: usernameField) - ) - } - - override open var loginButton: AnyView { - button( - localization.login.buttonTitle, - destination: UsernamePasswordLoginView( - usernameValidationRules: [emailValidationRule] - ) - ) - } - - override open var signUpButton: AnyView { - button( - localization.signUp.buttonTitle, - destination: UsernamePasswordSignUpView( - usernameValidationRules: [emailValidationRule] - ) - ) - } - - override open var resetPasswordButton: AnyView { - AnyView( - NavigationLink { - UsernamePasswordResetPasswordView( - usernameValidationRules: [emailValidationRule] - ) { - processSuccessfulResetPasswordView - } - .environmentObject(self as UsernamePasswordAccountService) - } label: { - Text(localization.resetPassword.buttonTitle) - } - ) - } - - - override open func button(_ title: LocalizedStringResource, destination: V) -> AnyView { - AnyView( - NavigationLink { - destination - .environmentObject(self as UsernamePasswordAccountService) - } label: { - AccountServiceButton { - Image(systemName: "envelope.fill") - .font(.title2) - Text(title) - } - } - ) - } -} diff --git a/Sources/SpeziAccount/Environment/AccountServiceConfiguration+Environment.swift b/Sources/SpeziAccount/Environment/AccountServiceConfiguration+Environment.swift new file mode 100644 index 00000000..67fca57b --- /dev/null +++ b/Sources/SpeziAccount/Environment/AccountServiceConfiguration+Environment.swift @@ -0,0 +1,33 @@ +// +// 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 +// + +import SwiftUI + + +// swiftlint:disable:next type_name +private struct AccountServiceConfigurationEnvironmentKey: EnvironmentKey { + static var defaultValue: AccountServiceConfiguration { + .init(name: "DEFAULT", supportedKeys: .arbitrary) + } +} + + +extension EnvironmentValues { + /// Access the ``AccountServiceConfiguration`` within the context of a ``DataDisplayView`` or ``DataEntryView``. + /// + /// - Note: Accessing this environment value outside the view body of such view types, you will receive a unusable + /// mock value. + public var accountServiceConfiguration: AccountServiceConfiguration { + get { + self[AccountServiceConfigurationEnvironmentKey.self] + } + set { + self[AccountServiceConfigurationEnvironmentKey.self] = newValue + } + } +} diff --git a/Sources/SpeziAccount/Environment/AccountViewType.swift b/Sources/SpeziAccount/Environment/AccountViewType.swift new file mode 100644 index 00000000..aa2c1c45 --- /dev/null +++ b/Sources/SpeziAccount/Environment/AccountViewType.swift @@ -0,0 +1,65 @@ +// +// 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 +// + +import SwiftUI + + +/// The mode in which a subview of a ``AccountOverview`` operates in. +public enum OverviewEntryMode { + /// New data is entered. + case new + /// Existing data is provided to the ``DataEntryView``. + case existing + /// Data is used to display data. + case display +} + + +/// Defines the type of `SpeziAccount` view a ``DataEntryView`` or ``DataDisplayView`` is placed in. +/// +/// Access this property inside supporting views using the ``SwiftUI/EnvironmentValues/accountViewType`` environment key. +/// +/// ```swift +/// struct MyView: View { +/// @Environment(\.accountViewType) +/// var accountViewType +/// } +/// ``` +public enum AccountViewType: EnvironmentKey { + /// The view is part of a ``SignupForm`` view hierarchy. + case signup + /// The view is part of a ``AccountOverview`` view hierarchy in a given ``OverviewEntryMode``. + case overview(mode: OverviewEntryMode) + + + public static var defaultValue: AccountViewType? + + + /// Determines if the view type represents a view mode where new data is provided for an account value. + public var enteringNewData: Bool { + switch self { + case.signup: + return true + case let .overview(mode): + return mode == .new + } + } +} + + +extension EnvironmentValues { + /// The type of `SpeziAccount` view a ``DataEntryView`` or ``DataDisplayView`` is placed in. + public var accountViewType: AccountViewType? { + get { + self[AccountViewType.self] + } + set { + self[AccountViewType.self] = newValue + } + } +} diff --git a/Sources/SpeziAccount/Environment/LoggerKey.swift b/Sources/SpeziAccount/Environment/LoggerKey.swift new file mode 100644 index 00000000..a15d471c --- /dev/null +++ b/Sources/SpeziAccount/Environment/LoggerKey.swift @@ -0,0 +1,28 @@ +// +// 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 +// + +import os +import SwiftUI + + +struct LoggerKey: EnvironmentKey { + // this is currently internal to SpeziAccount but will be addressed on a framework level with https://github.com/StanfordSpezi/SpeziViews/issues/9 + static let defaultValue = Logger(subsystem: "edu.stanford.spezi", category: "SpeziAccount") +} + + +extension EnvironmentValues { + var logger: Logger { + get { + self[LoggerKey.self] + } + set { + self[LoggerKey.self] = newValue + } + } +} diff --git a/Sources/SpeziAccount/Environment/PasswordFieldType.swift b/Sources/SpeziAccount/Environment/PasswordFieldType.swift new file mode 100644 index 00000000..67894617 --- /dev/null +++ b/Sources/SpeziAccount/Environment/PasswordFieldType.swift @@ -0,0 +1,59 @@ +// +// 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 +// + +import SwiftUI + + +/// The semantic use of a password field. +public enum PasswordFieldType: EnvironmentKey, CustomLocalizedStringResourceConvertible { + /// Standard password field + case password + /// New password field + case new + /// Password repeat field + case `repeat` + + + public static let defaultValue: PasswordFieldType = .password + + + public var localizedStringResource: LocalizedStringResource { + switch self { + case .password: + return PasswordKey.name + case .new: + return .init("NEW_PASSWORD", bundle: .atURL(from: .module)) + case .repeat: + return .init("REPEAT_PASSWORD", bundle: .atURL(from: .module)) + } + } + + public var localizedPrompt: LocalizedStringResource { + switch self { + case .password: + return PasswordKey.name + case .new: + return .init("NEW_PASSWORD_PROMPT", bundle: .atURL(from: .module)) + case .repeat: + return .init("REPEAT_PASSWORD_PROMPT", bundle: .atURL(from: .module)) + } + } +} + + +extension EnvironmentValues { + /// The semantic use of a password field. + public var passwordFieldType: PasswordFieldType { + get { + self[PasswordFieldType.self] + } + set { + self[PasswordFieldType.self] = newValue + } + } +} diff --git a/Sources/SpeziAccount/Login.swift b/Sources/SpeziAccount/Login.swift deleted file mode 100644 index c7134ff3..00000000 --- a/Sources/SpeziAccount/Login.swift +++ /dev/null @@ -1,56 +0,0 @@ -// -// This source file is part of the Spezi open-source project -// -// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -import Spezi -import SwiftUI - - -/// Display login buttons for all configured ``AccountService``s using the ``Account/Account`` module. -/// -/// The view display default Localization: "en",s a list of login buttons as well as a customizable header view that can be defined using the ``Login/init(header:)`` initializer. -public struct Login: View { - private var header: Header - - - public var body: some View { - AccountServicesView(header: header) { accountService in - accountService.loginButton - } - .navigationTitle(String(localized: "LOGIN", bundle: .module)) - } - - - public init() where Header == EmptyView { - self.header = EmptyView() - } - - /// - Parameter header: A SwiftUI `View` displayed as a header above all login buttons. - public init(@ViewBuilder header: () -> Header) { - self.header = header() - } -} - - -#if DEBUG -struct Login_Previews: PreviewProvider { - @StateObject private static var account = Account(accountServices: [UsernamePasswordAccountService(), EmailPasswordAccountService()]) - @StateObject private static var emptyAccount = Account(accountServices: []) - - static var previews: some View { - NavigationStack { - Login() - } - .environmentObject(account) - - NavigationStack { - Login() - } - .environmentObject(emptyAccount) - } -} -#endif diff --git a/Sources/SpeziAccount/Mock/MockSignInWithAppleProvider.swift b/Sources/SpeziAccount/Mock/MockSignInWithAppleProvider.swift new file mode 100644 index 00000000..b1dbc2a0 --- /dev/null +++ b/Sources/SpeziAccount/Mock/MockSignInWithAppleProvider.swift @@ -0,0 +1,75 @@ +// +// 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 +// + +import AuthenticationServices +import SwiftUI + + +private struct MockButton: View { + @Environment(\.colorScheme) var colorScheme + + var body: some View { + SignInWithAppleButton { _ in + // request + } onCompletion: { _ in + // result + } + .frame(height: 55) + .signInWithAppleButtonStyle(colorScheme == .light ? .black : .white) + } +} + + +/// Mock ``IdentityProviderViewStyle`` view style for the ``MockSignInWithAppleProvider``. +public struct MockSignInWithAppleProviderStyle: IdentityProviderViewStyle { + public let service: MockSignInWithAppleProvider + + + fileprivate init(service: MockSignInWithAppleProvider) { + self.service = service + } + + + public func makeSignInButton() -> some View { + MockButton() + } +} + + +/// A mock implementation of a ``IdentityProvider`` that can be used in your SwiftUI Previews. +/// +/// ## Topics +/// ### Mock View Style +/// - ``MockSignInWithAppleProviderStyle`` +public actor MockSignInWithAppleProvider: IdentityProvider { + public let configuration = AccountServiceConfiguration(name: "Mock SignIn with Apple", supportedKeys: .arbitrary) + + public nonisolated var viewStyle: MockSignInWithAppleProviderStyle { + MockSignInWithAppleProviderStyle(service: self) + } + + + public init() {} + + + public func signUp(signupDetails: SignupDetails) async throws { + print("Signup: \(signupDetails)") + } + + public func logout() async throws { + print("Logout") + } + + public func delete() async throws { + print("Remove") + } + + public func updateAccountDetails(_ modifications: AccountModifications) async throws { + print("Modifications: \(modifications)") + } +} diff --git a/Sources/SpeziAccount/Mock/MockSimpleAccountService.swift b/Sources/SpeziAccount/Mock/MockSimpleAccountService.swift new file mode 100644 index 00000000..eeffb056 --- /dev/null +++ b/Sources/SpeziAccount/Mock/MockSimpleAccountService.swift @@ -0,0 +1,58 @@ +// +// This source file is part of the Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SwiftUI + + +/// The view style for the `MockSimpleAccountService` rendering `"Hello World"` text. +struct MockSimpleAccountSetupViewStyle: AccountSetupViewStyle { + var service: MockSimpleAccountService + + + init(using service: MockSimpleAccountService) { + self.service = service + } + + + func makePrimaryView() -> some View { + Text("Hello World") + } +} + + +/// A simple mock ``AccountService`` that is barely implemented but useful for SwiftUI previewing purposes. +actor MockSimpleAccountService: AccountService { + @AccountReference private var account: Account + + let configuration = AccountServiceConfiguration(name: "Mock Simple AccountService", supportedKeys: .arbitrary) + + + nonisolated var viewStyle: some AccountSetupViewStyle { + MockSimpleAccountSetupViewStyle(using: self) + } + + + init() {} + + + func signUp(signupDetails: SignupDetails) async throws { + print("Signup: \(signupDetails)") + } + + func logout() async throws { + print("Logout") + } + + func delete() async throws { + print("Remove") + } + + func updateAccountDetails(_ modifications: AccountModifications) async throws { + print("Modifications: \(modifications)") + } +} diff --git a/Sources/SpeziAccount/Mock/MockUserIdPasswordAccountService.swift b/Sources/SpeziAccount/Mock/MockUserIdPasswordAccountService.swift new file mode 100644 index 00000000..bc25cdbd --- /dev/null +++ b/Sources/SpeziAccount/Mock/MockUserIdPasswordAccountService.swift @@ -0,0 +1,85 @@ +// +// 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 +// + +import Foundation + + +/// A mock implementation of a ``UserIdPasswordAccountService`` that can be used in your SwiftUI Previews. +public actor MockUserIdPasswordAccountService: UserIdPasswordAccountService { + @AccountReference private var account: Account + + public let configuration: AccountServiceConfiguration + + + /// Create a new userId- and password-based account service. + /// - Parameter type: The ``UserIdType`` to use for the account service. + public init(_ type: UserIdType = .emailAddress) { + self.configuration = AccountServiceConfiguration(name: "Mock AccountService", supportedKeys: .arbitrary) { + UserIdConfiguration(type: type, keyboardType: type == .emailAddress ? .emailAddress : .default) + RequiredAccountKeys { + \.userId + \.password + } + } + } + + + public func login(userId: String, password: String) async throws { + print("Mock Login: \(userId) \(password)") + try await Task.sleep(for: .seconds(1)) + + let details = AccountDetails.Builder() + .set(\.userId, value: userId) + .set(\.name, value: PersonNameComponents(givenName: "Andreas", familyName: "Bauer")) + .build(owner: self) + try await account.supplyUserDetails(details) + } + + public func signUp(signupDetails: SignupDetails) async throws { + print("Mock Signup: \(signupDetails)") + try await Task.sleep(for: .seconds(1)) + + let details = AccountDetails.Builder(from: signupDetails) + .remove(\.password) + .build(owner: self) + try await account.supplyUserDetails(details) + } + + public func resetPassword(userId: String) async throws { + print("Mock ResetPassword: \(userId)") + try await Task.sleep(for: .seconds(1)) + } + + public func logout() async throws { + print("Mock Logout") + try await Task.sleep(for: .seconds(1)) + await account.removeUserDetails() + } + + public func delete() async throws { + print("Mock Remove Account") + try await Task.sleep(for: .seconds(1)) + await account.removeUserDetails() + } + + public func updateAccountDetails(_ modifications: AccountModifications) async throws { + guard let details = await account.details else { + return + } + + print("Mock Update: \(modifications)") + + try await Task.sleep(for: .seconds(1)) + + let builder = AccountDetails.Builder(from: details) + .merging(modifications.modifiedDetails, allowOverwrite: true) + .remove(all: modifications.removedAccountDetails.keys) + + try await account.supplyUserDetails(builder.build(owner: self)) + } +} diff --git a/Sources/SpeziAccount/Model/AdditionalRecordId.swift b/Sources/SpeziAccount/Model/AdditionalRecordId.swift new file mode 100644 index 00000000..61274489 --- /dev/null +++ b/Sources/SpeziAccount/Model/AdditionalRecordId.swift @@ -0,0 +1,47 @@ +// +// 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 +// + + +/// A stable identifier used by ``AccountStorageStandard`` instances to identity a set of additionally stored records. +/// +/// The identifier is built by combining a stable ``AccountService`` identifier and the primary userId (see ``UserIdKey``). +/// Using both, additional data records of a user can be uniquely identified across ``AccountService`` implementations. +public struct AdditionalRecordId: CustomStringConvertible, Hashable, Identifiable { + /// A stable ``AccountService`` identifier. See ``AccountService/id-83c6c``. + public let accountServiceId: String + /// The primary user identifier. See ``UserIdKey``. + public let userId: String + + + /// String representation of the record identifier. + public var description: String { + accountServiceId + "-" + userId + } + + /// The identifier. + public var id: String { + description + } + + + init(serviceId accountServiceId: String, userId: String) { + self.accountServiceId = accountServiceId + self.userId = userId + } + + + public static func == (lhs: AdditionalRecordId, rhs: AdditionalRecordId) -> Bool { + lhs.description == rhs.description + } + + + public func hash(into hasher: inout Hasher) { + accountServiceId.hash(into: &hasher) + userId.hash(into: &hasher) + } +} diff --git a/Sources/SpeziAccount/Model/GenderIdentity.swift b/Sources/SpeziAccount/Model/GenderIdentity.swift new file mode 100644 index 00000000..3a35b96f --- /dev/null +++ b/Sources/SpeziAccount/Model/GenderIdentity.swift @@ -0,0 +1,51 @@ +// +// This source file is part of the Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Foundation + + +/// Describes the self-identified gender identity. +public enum GenderIdentity: String, Sendable, CaseIterable, Identifiable, Hashable, Codable { + /// Self-identify as female. + case female + /// Self-identify as male. + case male + /// Self-identify as transgender. + case transgender + /// Self-identify as non-binary. + case nonBinary + /// Prefer not to state the self-identified gender. + case preferNotToState + + + public var id: RawValue { + rawValue + } +} + + +extension GenderIdentity: CustomLocalizedStringResourceConvertible { + private var localizationValue: String.LocalizationValue { + switch self { + case .female: + return "GENDER_IDENTITY_FEMALE" + case .male: + return "GENDER_IDENTITY_MALE" + case .transgender: + return "GENDER_IDENTITY_TRANSGENDER" + case .nonBinary: + return "GENDER_IDENTITY_NON_BINARY" + case .preferNotToState: + return "GENDER_IDENTITY_PREFER_NOT_TO_STATE" + } + } + + public var localizedStringResource: LocalizedStringResource { + LocalizedStringResource(localizationValue, bundle: .atURL(from: .module)) + } +} diff --git a/Sources/SpeziAccount/Model/KeyPath+ShortDescription.swift b/Sources/SpeziAccount/Model/KeyPath+ShortDescription.swift new file mode 100644 index 00000000..62372395 --- /dev/null +++ b/Sources/SpeziAccount/Model/KeyPath+ShortDescription.swift @@ -0,0 +1,24 @@ +// +// 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 +// + + +extension KeyPath { + var shortDescription: String { + if #available(macOS 13.3, iOS 16.4, watchOS 9.4, tvOS 16.4, *) { + // see https://github.com/apple/swift-evolution/blob/main/proposals/0369-add-customdebugdescription-conformance-to-anykeypath.md + var description = self.debugDescription + if let slash = description.firstIndex(of: "\\"), let dot = description.firstIndex(of: ".") { + description.removeSubrange(description.index(after: slash) ..< dot) + } + return description + } else { + // it's an okay fallback + return "\(Value.self)" + } + } +} diff --git a/Sources/SpeziAccount/Model/Validation/FailedValidationResult.swift b/Sources/SpeziAccount/Model/Validation/FailedValidationResult.swift new file mode 100644 index 00000000..fd29765e --- /dev/null +++ b/Sources/SpeziAccount/Model/Validation/FailedValidationResult.swift @@ -0,0 +1,28 @@ +// +// This source file is part of the Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Foundation + +/// Represents the result of a ``ValidationRule``. +/// +/// For more information see ``ValidationRule/validate(_:)``. +public struct FailedValidationResult: Identifiable, CustomLocalizedStringResourceConvertible { + /// The identifier of the ``ValidationRule`` that produced this result. + public var id: ValidationRule.ID + /// A recovery suggestion for the validated input. + public let message: LocalizedStringResource + + public var localizedStringResource: LocalizedStringResource { + message + } + + init(from rule: ValidationRule) { + self.id = rule.id + self.message = rule.message + } +} diff --git a/Sources/SpeziAccount/Model/Validation/ManagedValidationModifier.swift b/Sources/SpeziAccount/Model/Validation/ManagedValidationModifier.swift new file mode 100644 index 00000000..78409370 --- /dev/null +++ b/Sources/SpeziAccount/Model/Validation/ManagedValidationModifier.swift @@ -0,0 +1,111 @@ +// +// 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 +// + +import SwiftUI + + +struct InputValidationModifier: ViewModifier { + private let inputValue: String + private let fieldIdentifier: FieldIdentifier? + + @EnvironmentObject private var engines: ValidationEngines + @Environment(\.validationEngineConfiguration) private var configuration + + @StateObject private var validation: ValidationEngine + + init(input value: String, for fieldIdentifier: FieldIdentifier?, rules: [ValidationRule]) { + self.inputValue = value + self.fieldIdentifier = fieldIdentifier + self._validation = StateObject(wrappedValue: ValidationEngine(rules: rules)) + } + + func body(content: Content) -> some View { + let _ = validation.configuration = configuration // swiftlint:disable:this redundant_discardable_let + + content + .environmentObject(validation) + .register(engine: validation, engines: engines, field: fieldIdentifier, input: inputValue) + } +} + + +extension View { + /// Automatically manage a ``ValidationEngine`` object. + /// + /// This modified creates and manages a ``ValidationEngine`` object and places it into the environment for subviews. + /// + /// The modifier can be used in ``DataEntryView``s or other views where a ``ValidationEngines`` object is present in the environment. + /// + /// - Parameters: + /// - value: The current value to validate. + /// - fieldIdentifier: The field identifier of the field that receives focus if validation fails. + /// - rules: An array of ``ValidationRule``s. + /// - Returns: The modified view. + public func managedValidation( + input value: String, + for fieldIdentifier: FieldIdentifier, + rules: [ValidationRule] + ) -> some View { + modifier(InputValidationModifier(input: value, for: fieldIdentifier, rules: rules)) + } + + /// Automatically manage a ``ValidationEngine`` object. + /// + /// This modified creates and manages a ``ValidationEngine`` object and places it into the environment for subviews. + /// + /// The modifier can be used in ``DataEntryView``s or other views where a ``ValidationEngines`` object is present in the environment. + /// + /// - Parameters: + /// - value: The current value to validate. + /// - fieldIdentifier: The field identifier of the field that receives focus if validation fails. + /// - rules: An array of ``ValidationRule``s. + /// - Returns: The modified view. + public func managedValidation( + input value: String, + rules: [ValidationRule] + ) -> some View { + modifier(InputValidationModifier(input: value, for: nil, rules: rules)) + } + + /// Automatically manage a ``ValidationEngine`` object. + /// + /// This modified creates and manages a ``ValidationEngine`` object and places it into the environment for subviews. + /// + /// The modifier can be used in ``DataEntryView``s or other views where a ``ValidationEngines`` object is present in the environment. + /// + /// - Parameters: + /// - value: The current value to validate. + /// - fieldIdentifier: The field identifier of the field that receives focus if validation fails. + /// - rules: An variadic array of ``ValidationRule``s. + /// - Returns: The modified view. + public func managedValidation( + input value: String, + for fieldIdentifier: FieldIdentifier, + rules: ValidationRule... + ) -> some View { + managedValidation(input: value, for: fieldIdentifier, rules: rules) + } + + /// Automatically manage a ``ValidationEngine`` object. + /// + /// This modified creates and manages a ``ValidationEngine`` object and places it into the environment for subviews. + /// + /// The modifier can be used in ``DataEntryView``s or other views where a ``ValidationEngines`` object is present in the environment. + /// + /// - Parameters: + /// - value: The current value to validate. + /// - fieldIdentifier: The field identifier of the field that receives focus if validation fails. + /// - rules: An variadic array of ``ValidationRule``s. + /// - Returns: The modified view. + public func managedValidation( + input value: String, + rules: ValidationRule... + ) -> some View { + managedValidation(input: value, rules: rules) + } +} diff --git a/Sources/SpeziAccount/Model/Validation/ValidationEngine.swift b/Sources/SpeziAccount/Model/Validation/ValidationEngine.swift new file mode 100644 index 00000000..10fa12a9 --- /dev/null +++ b/Sources/SpeziAccount/Model/Validation/ValidationEngine.swift @@ -0,0 +1,208 @@ +// +// This source file is part of the Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import os +import SwiftUI + + +/// A model that is responsible to verify a list of ``ValidationRule``s. +/// +/// You may use a `ValidationEngine` inside your view hierarchy (using [@StateObject](https://developer.apple.com/documentation/swiftui/stateobject) +/// to manage the evaluation of your ``ValidationRule``s. The Engine provides easy access to bindings for current validity state of a the +/// processed input and a the respective recovery suggestions for failed ``ValidationRule``s. +/// The state of the `ValidationEngine` is updated on each invocation of ``runValidation(input:)`` or ``submit(input:debounce:)``. +public class ValidationEngine: ObservableObject, Identifiable { + /// Determines the source of the last validation run. + private enum Source { + /// The last validation was run due to change in text field or keyboard submit. + case submit + /// The last validation was run due to manual interaction (e.g., a button press). + case manual + } + + + /// The configuration of a ``ValidationEngine``. + public struct Configuration: OptionSet, EnvironmentKey { + /// This configuration controls the behavior of the ``ValidationEngine/displayedValidationResults`` property. + /// + /// If ``ValidationEngine/submit(input:debounce:)`` is called with empty input and this option is set, then the + /// ``ValidationEngine/displayedValidationResults`` will display no failed validations. However, + /// ``ValidationEngine/displayedValidationResults`` will still display all validations if validation is done through a manual call to ``ValidationEngine/runValidation(input:)``. + public static let hideFailedValidationOnEmptySubmit = Configuration(rawValue: 1 << 0) + + /// Default value without any configuration options. + public static let defaultValue: Configuration = [] + + + public let rawValue: UInt + + + public init(rawValue: UInt) { + self.rawValue = rawValue + } + } + + + private static let logger = Logger(subsystem: "edu.stanford.spezi", category: "ValidationEngine") + + + /// Unique identifier for this validation engine. + public let id = UUID() + + /// Access to the underlying validation rules. + public let validationRules: [ValidationRule] + + /// Access the configuration of the validation engine. + public var configuration: Configuration + + /// A property that indicates if the last processed input is considered valid given the supplied ``ValidationRule`` list. + /// + /// The value treats no input at all (a validation that was never executed) as being invalid. Meaning, the default value is `false`. + @MainActor @Published public var inputValid = false + /// A list of ``FailedValidationResult`` for the processed input, providing, e.g., recovery suggestions. + @MainActor @Published public var validationResults: [FailedValidationResult] = [] + + /// Stores the source of the last validation execution. `nil` if validation was never run. + private var source: Source? + /// Input was empty. By default we consider no input as empty input. + private var inputWasEmpty = true + + /// Flag that indicates if ``displayedValidationResults`` returns any ``FailedValidationResult``. + @MainActor public var isDisplayingValidationErrors: Bool { + if configuration.contains(.hideFailedValidationOnEmptySubmit) { + return !inputValid && (source == .manual || !inputWasEmpty) + } + + return !inputValid + } + + + /// A list of ``FailedValidationResult`` for the processed input that should be used by UI components. + /// + /// In certain scenarios it might the desirable to not display any validation results if the user erased the whole + /// input field. You can achieve this by setting the ``ValidationEngine/Configuration-swift.struct/hideFailedValidationOnEmptySubmit`` option + /// and using the ``submit(input:debounce:)`` method. + /// + /// - Note: When calling ``runValidation(input:)`` (e.g., on the button action) this field always delivers + /// the same results as the ``validationResults`` property. + @MainActor public var displayedValidationResults: [FailedValidationResult] { + isDisplayingValidationErrors ? validationResults : [] + } + + private let debounceDuration: Duration + private var debounceTask: Task? { + willSet { + debounceTask?.cancel() + } + } + + + /// Initialize a new `ValidationEngine` by providing a list of ``ValidationRule``s. + /// + /// - Parameters: + /// - validationRules: An array of validation rules. + /// - debounceDuration: The debounce duration used with ``submit(input:debounce:)`` and `debounce` set to `true`. + /// - configuration: The ``Configuration`` of the validation engine. + public init(rules validationRules: [ValidationRule], debounceFor debounceDuration: Duration = .seconds(0.5), configuration: Configuration = []) { + self.debounceDuration = debounceDuration + self.validationRules = validationRules + self.configuration = configuration + } + + /// Initialize a new `ValidationEngine` by providing a list of ``ValidationRule``s. + /// + /// - Parameters: + /// - validationRules: A variadic array of validation rules. + /// - debounceDuration: The debounce duration used with ``submit(input:debounce:)`` and `debounce` set to `true`. + /// - configuration: The ``Configuration`` of the validation engine. + public convenience init( + rules validationRules: ValidationRule..., + debounceFor debounceDuration: Duration = .seconds(0.5), + configuration: Configuration = [] + ) { + self.init(rules: validationRules, debounceFor: debounceDuration, configuration: configuration) + } + + + @MainActor + private func computeFailedValidations(input: String) { + var results: [FailedValidationResult] = [] + for rule in validationRules { + if let failedValidation = rule.validate(input) { + results.append(failedValidation) + Self.logger.debug("Validation for input '\(input.description)' failed with reason: \(failedValidation.localizedStringResource.localizedString())") + + if rule.effect == .intercept { + break + } + } + } + validationResults = results + } + + @MainActor + private func runValidation0(input: String, source: Source) { + self.source = source // assign it first, as this isn't published + self.inputWasEmpty = input.isEmpty + + computeFailedValidations(input: input) + inputValid = validationResults.isEmpty + } + + /// Runs all validations for a given input on text field submission or value change. + /// + /// The input is considered valid if all ``ValidationRule``s succeed or the input is empty. This is particularly + /// useful to reset go back to a valid state if the user submits a empty string in the text field. + /// Make sure to run ``runValidation(input:)`` one last time to process the data (e.g., on a button action). + /// + /// - Parameters: + /// - input: The input to validate. + /// - debounce: If set to `true` calls to this method will be "debounced". The validation will not run as long as + /// there not further calls to this method for the configured `debounceDuration`. If set to `false` the method + /// will run immediately. + @MainActor + public func submit(input: String, debounce: Bool = false) { + guard debounce else { + runValidation0(input: input, source: .submit) + return + } + + debounceTask = Task { + try? await Task.sleep(for: debounceDuration) + + guard !Task.isCancelled else { + return + } + + runValidation0(input: input, source: .submit) + self.debounceTask = nil + } + } + + /// Runs all validations for a given input. + /// + /// The input is considered valid if all ``ValidationRule``s succeed. + /// - Parameter input: The input to validate. + @MainActor + public func runValidation(input: String) { + runValidation0(input: input, source: .manual) + } +} + + +extension EnvironmentValues { + /// Access the ``ValidationEngine/Configuration-swift.struct`` from the environment. + public var validationEngineConfiguration: ValidationEngine.Configuration { + get { + self[ValidationEngine.Configuration.self] + } + set { + self[ValidationEngine.Configuration.self] = newValue + } + } +} diff --git a/Sources/SpeziAccount/Model/Validation/ValidationEngines.swift b/Sources/SpeziAccount/Model/Validation/ValidationEngines.swift new file mode 100644 index 00000000..ac51c5f3 --- /dev/null +++ b/Sources/SpeziAccount/Model/Validation/ValidationEngines.swift @@ -0,0 +1,269 @@ +// +// 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 +// + +import Combine +import OrderedCollections +import SwiftUI + + +private struct FailedResult { + let validationEngineId: UUID + let failedFieldIdentifier: FieldIdentifier? // we store an optional as it might be Never +} + + +private class RegisteredEngine: Identifiable { + let engine: ValidationEngine + // fieldIdentifier might be nil for fieldIdentifiers of type Never + var fieldIdentifier: FieldIdentifier? + var input: String + var anyCancellable: AnyCancellable? + + var id: UUID { + engine.id + } + + + init(engine: ValidationEngine, fieldIdentifier: FieldIdentifier?, input: String) { + self.engine = engine + self.fieldIdentifier = fieldIdentifier + self.input = input + } + + + @MainActor + func callAsFunction() -> FailedResult? { + engine.runValidation(input: input) + + guard !engine.inputValid else { + return nil + } + + return FailedResult(validationEngineId: engine.id, failedFieldIdentifier: fieldIdentifier) + } +} + + +/// Collect a set of ``ValidationEngine``s from a dynamic amount of subviews. +/// +/// This type can be used to if you have a non-static amount of subviews for which each of them provides a +/// ``ValidationEngine`` instance you want to access from the parent view. +/// For example, this is useful when building `Form`s which dynamic amount of input fields. The parent will manage the +/// [@StateObject](https://developer.apple.com/documentation/swiftui/stateobject) and provide access to the subviews +/// using [environmentObject(_:)](https://developer.apple.com/documentation/swiftui/view/environmentobject(_:)). +/// The subviews can then use the ``SwiftUI/View/register(engine:with:for:input:)`` or ``SwiftUI/View/register(engine:with:input:)`` +/// modifiers to register their ``ValidationEngine`` state with the `ValidationEngines` collection. +/// +/// ### Pairing with SwiftUI `FocusState` +/// +/// Apple's [FocusState](https://developer.apple.com/documentation/SwiftUI/FocusState) property wrapper can be used +/// to manage focus state of your text fields. +/// The `ValidationEngines` type is built to work well with SwiftUI's `FocusState` by tracking the `FieldIdentifier` +/// from which a failed validation came from. This can be useful to automatically set focus to the first field +/// that failed validation. To do so, you must specify the type you are using for your `FocusState` within the +/// `FieldIdentifier` generic. If you don't need this feature, there are convenience initializers that automatically +/// assign `Never`. +/// +/// ### Implementation in the parent view +/// The parent view has to create and maintain an instance of `ValidationEngines`. The instance is passed +/// to all subviews as an environment object using the `environmentObject(_:)` modifier. +/// +/// Below is a code example on how to implement `ValidationEngines` in the parent view. +/// +/// - Note: All of the below examples show an implementation using `FocusState`. However, you can easily use +/// `ValidationEngines` without a focus state. +/// +/// ```swift +/// struct MyParentView: View { +/// @FocusState var myFocusedField: String? +/// @StateObject var engines: ValidationEngines() +/// +/// var body: some View { +/// MySubView(_myFocusedField) +/// .environmentObject(engines) +/// +/// Button("Submit", action: submit) +/// } +/// +/// func submit() { +/// guard engines.validateSubviews(focusState: $myFocusedField) else { +/// // inputs are not valid, first invalid field is now in focus automatically +/// return +/// } +/// +/// // process data from your subviews ... +/// } +/// } +/// ``` +/// +/// - Note: While the above example is completely static in the amount of subviews and the scenario could be +/// managed much simpler, the `ValidationEngines` type is purposefully built for scenarios where you +/// have a dynamic amount of subviews. +/// +/// ### Implementation in the child view +/// A typically child view has to do manage two things: +/// * Maintain a ``ValidationEngine`` and continuously call it's ``ValidationEngine/submit(input:debounce:)`` method +/// when the text input changes. +/// * Run the validation engine's ``ValidationEngine/runValidation(input:)`` method one last time when the submit +/// button of the parent view is pressed. +/// +/// These two parts are solved by two different components in the below code example: +/// * We use ``VerifiableTextField`` as a text view that expects a configured ``ValidationEngine`` in the environment +/// and uses that to run validation on changes in the text field and renders recovery suggestions below the text field. +/// * We use the ``SwiftUI/View/register(engine:with:for:input:)`` modifier to register our ``ValidationEngine`` with +/// the ``ValidationEngines`` object in the environment. +/// +/// ```swift +/// struct MySubView: View { +/// let myFieldIdentifier = "MyField" +/// +/// @EnvironmentObject var engines: ValidationEngines +/// @StateObject var validation = ValidationEngine(rules: .asciiLettersOnly) +/// +/// @FocusState var myFocusedField: String? +/// @State var text: String +/// +/// var body: some View { +/// VerifiableTextField(text: $text) +/// .environmentObject(validation) +/// .focused($myFocusedField, equals: myFieldIdentifier) +/// .register(engine: validation, at: engines, for: myFieldIdentifier, input: text) +/// } +/// +/// init(_ focusState: FocusState) { +/// _myFocusedField = focusState +/// } +/// } +/// ``` +/// +/// ## Using managed Validation +/// +/// While we managed our ``ValidationEngine`` ourself in the above code example, we can also rely on the +/// ``SwiftUI/View/managedValidation(input:for:rules:)-5gj5g`` modifier to a) create ana manage a ``ValidationEngine`` +/// and b) automatically register with a ``ValidationEngines`` object in the environment. +/// +/// This simplifies the implementation as follows: +/// +/// ```swift +/// struct MySubView: View { +/// let myFieldIdentifier = "MyField" +/// +/// @FocusState var myFocusedField: String? +/// @State var text: String +/// +/// var body: some View { +/// VerifiableTextField(text: $text) +/// .focused($myFocusedField, equals: myFieldIdentifier) +/// .managedValidation(input: text, for: myFieldIdentifier, rules: .asciiLettersOnly) +/// } +/// +/// init(_ focusState: FocusState) { +/// _myFocusedField = focusState +/// } +/// } +/// ``` +public class ValidationEngines: ObservableObject { + // deliberately not @Published, registered methods should not trigger an UI update + private var storage: OrderedDictionary> + + /// Reports input validity of all registered ``ValidationEngine``s. + @MainActor public var allInputValid: Bool { + storage.values + .allSatisfy { $0.engine.inputValid } + } + + @MainActor public var isDisplayingValidationErrors: Bool { + storage.values.contains(where: { $0.engine.isDisplayingValidationErrors }) + } + + + /// Create a new `ValidationEngines` collection by specifying the type `FieldIdentifier` used with the `FocusState` instance. + /// - Parameter focusStateOf: The underlying type of the `FocusState`. + public init(focusStateOf: FieldIdentifier.Type = FieldIdentifier.self) { + self.storage = [:] + } + + /// Creates a new `ValidationEngines` instance without using the `FocusState` functionality. + public convenience init() where FieldIdentifier == Never { + self.init(focusStateOf: Never.self) + } + + + func contains(_ validation: ValidationEngine) -> Bool { + storage[validation.id] != nil + } + + /// Registers a new validation engine. + /// + /// - Important: You should make sure to guide any accesses through ``SwiftUI/View/register(engine:with:for:input:)`` + /// or ``SwiftUI/View/register(engine:with:input:)``. + func register(engine: ValidationEngine, field: FieldIdentifier?, input: String) -> EmptyView { + if let registration = storage[engine.id] { + registration.input = input // just update the input + registration.fieldIdentifier = field // shouldn't change, but just to be save + return EmptyView() + } + + let registration = RegisteredEngine(engine: engine, fieldIdentifier: field, input: input) + // hook up the stored validation engine with our objectWillChange publisher + registration.anyCancellable = registration.engine.objectWillChange.sink { [weak self] _ in + self?.objectWillChange.send() + } + + storage[engine.id] = registration + return EmptyView() + } + + /// Removes the registered validation engine. + /// + /// - Important: You should make sure to guide any accesses through ``SwiftUI/View/register(engine:with:for:input:)`` + /// or ``SwiftUI/View/register(engine:with:input:)``. + /// - Parameter engine: The ``ValidationEngine`` which was previously registered. + func remove(engine: ValidationEngine) { + if let value = storage.removeValue(forKey: engine.id) { + value.anyCancellable?.cancel() + } + } + + @MainActor + private func collectFailedResults() -> [FailedResult] { + storage.values.compactMap { engine in + engine() + } + } + + /// Run the validation engines of all your subviews + /// + /// - Parameter focusState: The first failed field will receive focus. + /// - Returns: Returns `true` if all subviews reported valid data. Returns `false` if at least one + /// subview reported invalid data. + @MainActor + @discardableResult + public func validateSubviews(focusState: FocusState.Binding) -> Bool { + let results = collectFailedResults() + + if let firstFailedField = results.first { + if let fieldIdentifier = firstFailedField.failedFieldIdentifier { + focusState.wrappedValue = fieldIdentifier + } + + return false + } + + return true + } + + /// Run the validation engines of all your subviews without setting a focus state. + /// - Returns: Returns `true` if all subviews reported valid data. Returns `false` if at least one + /// subview reported invalid data. + @MainActor + @discardableResult + public func validateSubviews() -> Bool { + collectFailedResults().isEmpty + } +} diff --git a/Sources/SpeziAccount/Model/Validation/ValidationEnginesRegistrationModifier.swift b/Sources/SpeziAccount/Model/Validation/ValidationEnginesRegistrationModifier.swift new file mode 100644 index 00000000..bac7b573 --- /dev/null +++ b/Sources/SpeziAccount/Model/Validation/ValidationEnginesRegistrationModifier.swift @@ -0,0 +1,82 @@ +// +// 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 +// + +import SwiftUI + + +private struct ValidationEnginesRegistrationModifier: ViewModifier { + private let engines: ValidationEngines + private let validation: ValidationEngine + private let fieldIdentifier: FieldIdentifier? + private let input: String + + + init(engines: ValidationEngines, validation: ValidationEngine, fieldIdentifier: FieldIdentifier?, input: String) { + self.engines = engines + self.validation = validation + self.fieldIdentifier = fieldIdentifier + self.input = input + } + + + func body(content: Content) -> some View { + // We don't retrieve a binding for the `input` value. + // Therefore we refresh the supplied closure everytime the body gets rebuilt. + engines.register(engine: validation, field: fieldIdentifier, input: input) + + content + .onDisappear { + engines.remove(engine: validation) + } + } +} + + +extension View { + func register( + engine: ValidationEngine, + engines: ValidationEngines, + field: FieldIdentifier?, + input: String + ) -> some View { + self + .modifier(ValidationEnginesRegistrationModifier(engines: engines, validation: engine, fieldIdentifier: field, input: input)) + } + + /// Register a new validation engine by providing an field identifier for focus state handling. + /// + /// - Parameters: + /// - engine: The ``ValidationEngine`` to register. + /// - engines: The collection of ``ValidationEngines`` to register at. + /// - field: The field which should receive focus if the validation reports invalid state on button press. + /// - input: The current text input to validate. + public func register( + engine: ValidationEngine, + with engines: ValidationEngines, + for field: FieldIdentifier, + input: String + ) -> some View { + self + .register(engine: engine, engines: engines, field: field, input: input) + } + + /// Register a new validation engine. + /// + /// - Parameters: + /// - engine: The ``ValidationEngine`` to register. + /// - engines: The collection of ``ValidationEngines`` to register at. + /// - input: The current text input to validate. + public func register( + engine: ValidationEngine, + with engines: ValidationEngines, + input: String + ) -> some View { + self + .register(engine: engine, engines: engines, field: nil, input: input) + } +} diff --git a/Sources/SpeziAccount/Model/Validation/ValidationRule+Defaults.swift b/Sources/SpeziAccount/Model/Validation/ValidationRule+Defaults.swift new file mode 100644 index 00000000..43dd21a7 --- /dev/null +++ b/Sources/SpeziAccount/Model/Validation/ValidationRule+Defaults.swift @@ -0,0 +1,110 @@ +// +// This source file is part of the Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + + +import Foundation + + +extension ValidationRule { + /// A `ValidationRule` that checks that the supplied content is non-empty (`\S+`). + /// + /// The definition of **non-empty** in this context refers to: a string that is not the empty string and + /// does also not just contain whitespace-only characters. + public static var nonEmpty: ValidationRule = { + guard let regex = try? Regex(#".*\S+.*"#) else { + fatalError("Failed to build the nonEmpty validation rule!") + } + + return ValidationRule(regex: regex, message: "VALIDATION_RULE_NON_EMPTY", bundle: .module) + }() + + /// A `ValidationRule` that checks that the supplied content only contains unicode letters. + /// + /// - See: `Character/isLetter`. + public static var unicodeLettersOnly: ValidationRule = { + ValidationRule(rule: { $0.allSatisfy { $0.isLetter } }, message: "VALIDATION_RULE_UNICODE_LETTERS", bundle: .module) + }() + + /// A `ValidationRule` that checks that the supplied contain only contains ASCII letters. + /// + /// - Note: It is recommended to use ``unicodeLettersOnly`` in production environments. + /// - See: `Character/isASCII`. + public static var asciiLettersOnly: ValidationRule = { + ValidationRule(rule: { $0.allSatisfy { $0.isASCII } }, message: "VALIDATION_RULE_UNICODE_LETTERS_ASCII", bundle: .module) + }() + + /// A `ValidationRule` that imposes minimal constraints on a E-Mail input. + /// + /// This ValidationRule matches with any strings that contain at least one `@` symbol followed by at least one + /// character (`.*@.+`). Use this in environments where you verify the existence and ownership of the E-Mail + /// address (e.g., by sending a verification link to the address). + /// + /// - See: A more detailed discussion about validation E-Mail inout can be found [here](https://stackoverflow.com/a/48170419). + public static var minimalEmail: ValidationRule = { + guard let regex = try? Regex(".*@.+") else { + fatalError("Failed to build the minimalEmail validation rule!") + } + + return ValidationRule( + regex: regex, + message: "VALIDATION_RULE_MINIMAL_EMAIL", + bundle: .module + ) + }() + + /// A `ValidationRule` that requires a password of at least 8 characters for minimal password complexity. + /// + /// An application must make sure that users choose sufficiently secure passwords while at the same time ensuring that + /// usability is not affected due to too complex restrictions. This basic motivation stems from `ORP.4.A22 Regulating Password Quality` + /// of the [IT-Grundschutz Compendium](https://www.bsi.bund.de/EN/Themen/Unternehmen-und-Organisationen/Standards-und-Zertifizierung/IT-Grundschutz/it-grundschutz_node.html) + /// of the German Federal Office for Information Security. + /// We propose to use the password length as the sole factor to determine password complexity. We rely on the + /// recommendations of NIST who discuss the [Strength of Memorized Secrets](https://pages.nist.gov/800-63-3/sp800-63b.html#appA) + /// great detail and recommend against password rules that mandated a certain mix of character types. + public static var minimalPassword: ValidationRule = { + guard let regex = try? Regex(#".{8,}"#) else { + fatalError("Failed to build the minimalPassword validation rule!") + } + + return ValidationRule( + regex: regex, + message: "VALIDATION_RULE_PASSWORD_LENGTH \(8)", + bundle: .module + ) + }() + + /// A `ValidationRule` that requires a password of at least 10 characters for improved password complexity. + /// + /// See ``minimalPassword`` for a discussion and recommendation on password complexity rules. + public static var mediumPassword: ValidationRule = { + guard let regex = try? Regex(#".{10,}"#) else { + fatalError("Failed to build the mediumPassword validation rule!") + } + + return ValidationRule( + regex: regex, + message: "VALIDATION_RULE_PASSWORD_LENGTH \(10)", + bundle: .module + ) + }() + + /// A `ValidationRule` that requires a password of at least 10 characters for extended password complexity. + /// + /// See ``minimalPassword`` for a discussion and recommendation on password complexity rules. + public static var strongPassword: ValidationRule = { + guard let regex = try? Regex(#".{12,}"#) else { + fatalError("Failed to build the strongPassword validation rule!") + } + + return ValidationRule( + regex: regex, + message: "VALIDATION_RULE_PASSWORD_LENGTH \(12)", + bundle: .module + ) + }() +} diff --git a/Sources/SpeziAccount/Model/Validation/ValidationRule.swift b/Sources/SpeziAccount/Model/Validation/ValidationRule.swift new file mode 100644 index 00000000..0023a762 --- /dev/null +++ b/Sources/SpeziAccount/Model/Validation/ValidationRule.swift @@ -0,0 +1,153 @@ +// +// This source file is part of the Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + + +import Foundation + + +/// Controls how a ``ValidationEngine`` deals with subsequent validation rules if a given validation rule reports invalid input. +enum CascadingValidationEffect { + /// The ``ValidationEngine`` continues to validate input against subsequent ``ValidationRule``s. + case `continue` + /// The ``ValidationEngine`` intercepts the current processing chain if the current rule reports invalid input and + /// does not validate input against subsequent ``ValidationRule``s. + case intercept +} + + +/// A rule used for validating text along with a message to display if the validation fails. +/// +/// The following example demonstrates a ``ValidationRule`` using a regex expression for an email. +/// ```swift +/// ValidationRule( +/// regex: try? Regex("[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}"), +/// message: "The entered email is not correct." +/// ) +/// ``` +/// +/// - Important: Never rely on security-relevant validations with `ValidationRule`. These are client-side validations only! +/// Security-related validations MUST be checked at the server side (e.g., password length) and are just checked +/// on client-side for visualization. +public struct ValidationRule: Identifiable, @unchecked Sendable { // we guarantee that the closure is only executed on the main thread + /// A unique identifier for the ``ValidationRule``. Can be used to, e.g., match a ``FailedValidationResult`` to the ValidationRule. + public let id: UUID + private let rule: (String) -> Bool + /// A localized message that describes a recovery suggestion if the validation rule fails. + public let message: LocalizedStringResource + let effect: CascadingValidationEffect + + + // swiftlint:disable:next function_default_parameter_at_end + init( + id: UUID = UUID(), + ruleClosure: @escaping (String) -> Bool, + message: LocalizedStringResource, + effect: CascadingValidationEffect = .continue + ) { + self.id = id + self.rule = ruleClosure + self.message = message + self.effect = effect + } + + + /// Creates a validation rule from an escaping closure. + /// + /// - Parameters: + /// - rule: An escaping closure that validates a `String` and returns a boolean result. + /// - message: A `String` message to display if validation fails. + public init(rule: @escaping (String) -> Bool, message: LocalizedStringResource) { + self.init(ruleClosure: rule, message: message) + } + + /// Creates a validation rule from an escaping closure. + /// + /// - Parameters: + /// - rule: An escaping closure that validates a `String` and returns a boolean result. + /// - message: A `String` message to display if validation fails. + /// - bundle: The Bundle to localize for. + public init(rule: @escaping (String) -> Bool, message: String.LocalizationValue, bundle: Bundle) { + self.init(ruleClosure: rule, message: LocalizedStringResource(message, bundle: .atURL(from: bundle))) + } + + /// Creates a validation rule from a regular expression. + /// + /// - Parameters: + /// - regex: A `Regex` regular expression to match for validating text. Note, the `wholeMatch` operation is used. + /// - message: A `LocalizedStringResource` message to display if validation fails. + public init(regex: Regex, message: LocalizedStringResource) { + self.init(ruleClosure: { (try? regex.wholeMatch(in: $0) != nil) ?? false }, message: message) + } + + /// Creates a validation rule from a regular expression. + /// + /// - Parameters: + /// - regex: A `Regex` regular expression to match for validating text. Note, the `wholeMatch` operation is used. + /// - message: A `String` message to display if validation fails. + /// - bundle: The Bundle to localize for. + public init(regex: Regex, message: String.LocalizationValue, bundle: Bundle) { + self.init(regex: regex, message: LocalizedStringResource(message, bundle: .atURL(from: bundle))) + } + + /// Creates a validation rule by copying the rule contents from another `ValidationRule`. + /// - Parameters: + /// - validationRule: The `ValidationRule` to copy the rule from. + /// - message: A new message for the copied validation rule. + public init(copy validationRule: ValidationRule, message: LocalizedStringResource) { + self.init(ruleClosure: validationRule.rule, message: message) + } + + + /// Validates the contents of a given `String` input. + /// - Parameter input: The input to validate. + /// - Returns: Returns a ``FailedValidationResult`` if validation failed, otherwise `nil`. + @MainActor + public func validate(_ input: String) -> FailedValidationResult? { + guard !rule(input) else { + return nil + } + + return FailedValidationResult(from: self) + } +} + + +extension ValidationRule { + /// Annotates an given ``ValidationRule`` such that a processing ``ValidationEngine`` intercepts the current + /// processing chain of validation rules, if the current validation rule determines a given input to be invalid. + /// - Parameter rule: The ``ValidationRule`` to modify. + /// - Returns: Returns a modified ``ValidationRule`` + public var intercepting: ValidationRule { + ValidationRule(id: id, ruleClosure: rule, message: message, effect: .intercept) + } +} + + +extension ValidationRule: Decodable { + enum CodingKeys: String, CodingKey { + case rule + case message + } + + public init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + + let regexString = try values.decode(String.self, forKey: .rule) + let regex = try Regex(regexString) + + let message: LocalizedStringResource + do { + // backwards compatibility. An earlier version of `ValidationRule` used a non-localized string field. + message = LocalizedStringResource(stringLiteral: try values.decode(String.self, forKey: .message)) + } catch { + message = try values.decode(LocalizedStringResource.self, forKey: .message) + } + + self.init(regex: regex, message: message) + } +} diff --git a/Sources/SpeziAccount/Model/WeakInjectable.swift b/Sources/SpeziAccount/Model/WeakInjectable.swift new file mode 100644 index 00000000..2076e794 --- /dev/null +++ b/Sources/SpeziAccount/Model/WeakInjectable.swift @@ -0,0 +1,50 @@ +// +// This source file is part of the Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +/// The property wrapper to transparently declare a injectable, weak property for a given class type. +@propertyWrapper +public struct _WeakInjectable { + // swiftlint:disable:previous type_name + // should not appear in documentation nor in autocomplete + + // we split that out into it's own type such that we don't need to make the whole `WeakInjectable` unchecked. + fileprivate final class UncheckedWeakBox { + fileprivate weak var reference: ObjectType? + } + + private let storage: UncheckedWeakBox = .init() + + /// Queries if the reference was already injected. + public var isInjected: Bool { + storage.reference != nil + } + + /// Access the underlying weak reference. + /// - Note: This will crash if the underlying value wasn't injected yet. + public var wrappedValue: Type { + guard let weakReference = storage.reference else { + fatalError("Failed to retrieve `\(Type.self)` object from weak reference is not yet present or not present anymore.") + } + + return weakReference + } + + /// Creates a new and empty instance. + public init() {} + + /// This method injects the weak reference. + /// + /// - Parameter type: A reference to the type that is injected into the property wrapper storage. + public func inject(_ type: Type) { + self.storage.reference = type + } +} + +extension _WeakInjectable: Sendable where Type: Sendable {} + +extension _WeakInjectable.UncheckedWeakBox: @unchecked Sendable where Type: Sendable {} diff --git a/Sources/SpeziAccount/Resources/de.lproj/Localizable.strings b/Sources/SpeziAccount/Resources/de.lproj/Localizable.strings index 512efa7c..ff19a984 100644 --- a/Sources/SpeziAccount/Resources/de.lproj/Localizable.strings +++ b/Sources/SpeziAccount/Resources/de.lproj/Localizable.strings @@ -6,64 +6,107 @@ // SPDX-License-Identifier: MIT // -// MARK: - General Views -"LOGIN" = "Anmelden"; -"SIGN_UP" = "Benutzerkonto Erstellen"; -"MISSING_ACCOUNT_SERVICES" = "Es wurden keine AccountServices konfiguriert.\n Bitte kontaktiere die Dokumentation von SpeziAccount für mehr Informationen zur Konfiguration eines AccountServices!"; +// MARK: - General +"OR" = "oder"; +"YES" = "Ja"; +"NO" = "Nein"; +"MISSING_ACCOUNT_SERVICES" = "**Es wurden keine Account Services konfiguriert.**\n\nBitte kontaktiere die Dokumentation von SpeziAccount für mehr Informationen zur Konfiguration eines AccountServices!"; +"MISSING_ACCOUNT_DETAILS" = "**Kein Benutzerkonto gefunden.**\n\nDiese Oberfläche erfordert ein aktives Benutzerkonto.\nBitte kontaktiere die Dokumentation für die AccountSetup Oberfläche, um ein Benutzerkonto einzurichten."; "OPEN_DOCUMENTATION" = "Dokumentation öffnen"; -// MARK: - Username Password +// MARK: - Account +"ACCOUNT_WELCOME" = "Dein Benutzerkonto"; +"ACCOUNT_WELCOME_SUBTITLE" = "Melde dich mit deinem Benutzerkonto an oder erstelle ein neues, wenn du noch keins hast."; +"ACCOUNT_WELCOME_SIGNED_IN_SUBTITLE" = "Du bist bereits mit dem folgenden Benutzerkonto angemeldet. Du kannst dein Benutzerkonto ändern, indem du dich abmeldest."; -// Login -"UAP_LOGIN_BUTTON_TITLE" = "Benutzername und Passwort"; -"UAP_LOGIN_NAVIGATION_TITLE" = "Anmelden"; -"UAP_LOGIN_USERNAME_TITLE" = "Benutzername"; -"UAP_LOGIN_USERNAME_PLACEHOLDER" = "Benutzername eingeben ..."; -"UAP_LOGIN_PASSWORD_TITLE" = "Passwort"; -"UAP_LOGIN_PASSWORD_PLACEHOLDER" = "Passwort eingeben ..."; -"UAP_LOGIN_ACTION_BUTTON_TITLE" = "Anmelden"; -"UAP_LOGIN_FAILED_DEFAULT_ERROR" = "Anmeldung Fehlgeschlagen"; +// MARK: - AccountValues +"ACCOUNT_VALUES_MISSING_VALUE_DESCRIPTION" = "Fehlende Werte im Benutzerkonto"; +"ACCOUNT_VALUES_MISSING_VALUE_REASON %@" = "Die folgenden Werte wurden nicht im Benutzerkonto gesetzt: %@"; +"ACCOUNT_VALUES_MISSING_VALUE_RECOVERY" = "Melde dieses Problem dem Entwickler des Account Services."; +// MARK: - UserId and Password +"UP_PASSWORD" = "Passwort"; +"NEW_PASSWORD" = "Neues"; +"NEW_PASSWORD_PROMPT" = "Passwort eingeben"; +"REPEAT_PASSWORD" = "Bestätigen"; +"REPEAT_PASSWORD_PROMPT" = "Passwort wiederholen"; +"UP_FORGOT_PASSWORD" = "Password vergessen?"; +"UP_LOGIN" = "Anmelden"; +"UP_SIGNUP" = "Registrieren"; +"UP_LOGOUT" = "Abmelden"; +"UP_NO_ACCOUNT_YET" = "Du hast noch kein Benutzerkonto?"; -// SignUp -"UAP_SIGNUP_BUTTION_TITLE" = "Benutzername und Passwort"; -"UAP_SIGNUP_NAVIGATION_TITLE" = "Benutzerkonto Erstellen"; -"UAP_SIGNUP_USERNAME_TITLE" = "Benutzername"; -"UAP_SIGNUP_USERNAME_PLACEHOLDER" = "Benutzername eingeben ..."; -"UAP_SIGNUP_PASSWORD_TITLE" = "Passwort"; -"UAP_SIGNUP_PASSWORD_PLACEHOLDER" = "Passwort eingeben ..."; -"UAP_SIGNUP_PASSWORD_REPEAT_TITLE" = "Passwort -Wiederholen"; -"UAP_SIGNUP_PASSWORD_REPEAT_PLACEHOLDER" = "Passwort wiederholen ..."; -"UAP_SIGNUP_PASSWORD_NOT_EQUAL_ERROR" = "Die eingegebenen Passwörter sind nicht gleich."; -"UAP_SIGNUP_GIVEN_NAME_TITLE" = "Vorname"; -"UAP_SIGNUP_GIVEN_NAME_PLACEHOLDER" = "Vorname eingeben ..."; -"UAP_SIGNUP_FAMILY_NAME_TITLE" = "Familienname"; -"UAP_SIGNUP_FAMILY_NAME_PLACEHOLDER" = "Familienname eingeben ..."; -"UAP_SIGNUP_GENDER_IDENTITY_TITLE" = "Geschlechtsidentität"; -"UAP_SIGNUP_DATE_OF_BIRTH_TITLE" = "Geburtsdatum"; -"UAP_SIGNUP_ACTION_BUTTON_TITLE" = "Benutzerkonto Erstellen"; -"UAP_SIGNUP_FAILED_DEFAULT_ERROR" = "Benutzerkontoerstellung Fehlgeschlagen"; +// MARK: - UserId and Password (Login) +"UP_LOGIN_FAILED_DEFAULT_ERROR" = "Anmeldung fehlgeschlagen!"; + +// MARK: - UserId and Password (Signup) +"UP_SIGNUP_INSTRUCTIONS" = "Please fill out the details below to create a new account."; +"UP_CREDENTIALS" = "Zugangsdaten"; +"UP_NAME" = "Name"; +"UP_CONTACT_DETAILS" = "Kontaktdaten"; +"UP_PERSONAL_DETAILS" = "Persönliche Details"; +"UP_SIGNUP_FAILED_DEFAULT_ERROR" = "Registrierung fehlgeschlagen!"; + +// MARK: - UserId and Password (Password Reset) +"UP_RESET_PASSWORD" = "Passwort Zurücksetzten"; +"UAP_PASSWORD_RESET_SUBTITLE %@" = "Bitte fülle folgendes Feld aus. Du wirst eine E-Mail erhalten mit der du dein Passwort zurücksetzten kannst."; +"UAP_RESET_PASSWORD_PROCESS_SUCCESSFUL_LABEL" = "Ein Link zum Zurücksetzen das Passworts wurde versandt."; +"UAP_RESET_PASSWORD_FAILED_DEFAULT_ERROR" = "Passwort Zurücksetzen fehlgeschlagen!"; +// MARK: - Account Summary +"UP_LOGOUT_FAILED_DEFAULT_ERROR" = "Abmelden fehlgeschlagen!"; -// Reset -"UAP_RESET_PASSWORD_BUTTON_TITLE" = "Passwort vergessen?"; -"UAP_RESET_PASSWORD_NAVIGATION_TITLE" = "Passwort Vergessen"; -"UAP_RESET_PASSWORD_USERNAME_TITLE" = "Benutzername"; -"UAP_RESET_PASSWORD_USERNAME_PLACEHOLDER" = "Benutzername eingeben ..."; -"UAP_RESET_PASSWORD_ACTION_BUTTON_TITLE" = "Passwort Zurücksetzen"; -"UAP_RESET_PASSWORD_PROCESS_SUCCESSFUL_LABEL" = "Ein Link zum zurücksetzen das Passworts wurde versandt."; -"UAP_RESET_PASSWORD_FAILED_DEFAULT_ERROR" = "Passwort Zurücksetzen Fehlgeschlagen"; +// MARK - Account Overview +"ACCOUNT_OVERVIEW" = "Benutzerkonto"; +"CONFIRMATION_DISCARD_CHANGES_TITLE" = "Willst du alle Änderungen verwerfen?"; +"CONFIRMATION_DISCARD_CHANGES" = "Änderungen Verwerfen"; +"CONFIRMATION_KEEP_EDITING"= "Weiter Bearbeiten"; +"CONFIRMATION_LOGOUT" = "Willst du dich wirklich abmelden?"; +"CONFIRMATION_REMOVAL" = "Willst du wirklich dein Benutzerkonto löschen?"; +"CONFIRMATION_REMOVAL_SUGGESTION" = "Dieser Vorgang is endgültig und du kannst dein Konto Informationen nicht wiederherstellen."; +"DELETE_ACCOUNT" = "Löschen"; +"DONE" = "Fertig"; +"EDIT" = "Bearbeiten"; +"DELETE"= "Löschen"; +"CANCEL" = "Abbrechen"; +"ACCOUNT_OVERVIEW_EDIT_DEFAULT_ERROR" = "Speichern deiner Änderung fehlgeschlagen!"; +"REMOVE_DEFAULT_ERROR"= "Löschen fehlgeschlagen!"; +"SECURITY" = "Sicherheit"; +"VALUE_ADD %@" = "%@ Hinzufügen"; +"CHANGE_PASSWORD" = "Passwort Ändern"; +// MARK: - Validation Rules +"VALIDATION_RULE_NON_EMPTY" = "Diese Feld kann nicht leer sein."; +"VALIDATION_RULE_UNICODE_LETTERS" = "Du kannst nur Zeichen verwenden."; +"VALIDATION_RULE_UNICODE_LETTERS_ASCII" = "You must only use standard English letters."; +"VALIDATION_RULE_MINIMAL_EMAIL" = "Diese E-Mail ist ungültig."; +"VALIDATION_RULE_PASSWORD_LENGTH %lld" = "Dein Passwort muss mindestens %lld Zeichen lang sein."; +"VALIDATION_RULE_PASSWORDS_NOT_MATCHED" = "Die Passwörter stimmen nicht überein."; +"VALIDATION_RULE_GIVEN_NAME_EMPTY" = "Dein Vorname kann nicht leer sein!"; +"VALIDATION_RULE_FAMILY_NAME_EMPTY" = "Dein Nachname kann nicht leer sein!"; -// MARK: - Email and Password -"EAP_BUTTON_TITLE" = "E-Mail and Passwort"; -"EAP_LOGIN_USERNAME_TITLE" = "E-Mail"; -"EAP_LOGIN_USERNAME_PLACEHOLDER" = "E-Mail eingeben ..."; -"EAP_EMAIL_VERIFICATION_ERROR" = "Die eingegebene E-Mail ist nicht korrekt."; +// MARK: - User Id +"USER_ID" = "Benutzerkennung"; +"USER_ID_EMAIL" = "E-Mail Adresse"; +"USER_ID_USERNAME" = "Benutzername"; +// MARK: - General Views +"LOGIN" = "Anmelden"; +"SIGN_UP" = "Benutzerkonto Erstellen"; + +// MARK: - Person Name +"NAME" = "Name"; +"UAP_SIGNUP_GIVEN_NAME_TITLE" = "Vorname"; +"UAP_SIGNUP_GIVEN_NAME_PLACEHOLDER" = "Vornamen eingeben"; +"UAP_SIGNUP_FAMILY_NAME_TITLE" = "Nachname"; +"UAP_SIGNUP_FAMILY_NAME_PLACEHOLDER" = "Nachnamen eingeben"; + +// MARK: - Date Of Birth +"UAP_SIGNUP_DATE_OF_BIRTH_TITLE" = "Geburtsdatum"; +"ADD_DATE" = "Hinzufügen"; // MARK: - Gender Identity +"GENDER_IDENTITY_TITLE" = "Geschlechtsidentität"; "GENDER_IDENTITY_FEMALE" = "Weiblich"; "GENDER_IDENTITY_MALE" = "Männlich"; "GENDER_IDENTITY_TRANSGENDER" = "Transgender"; diff --git a/Sources/SpeziAccount/Resources/en.lproj/Localizable.strings b/Sources/SpeziAccount/Resources/en.lproj/Localizable.strings index bd016a45..9923581f 100644 --- a/Sources/SpeziAccount/Resources/en.lproj/Localizable.strings +++ b/Sources/SpeziAccount/Resources/en.lproj/Localizable.strings @@ -6,64 +6,103 @@ // SPDX-License-Identifier: MIT // -// MARK: - General Views -"LOGIN" = "Login"; -"SIGN_UP" = "Sign Up"; -"MISSING_ACCOUNT_SERVICES" = "No Account Services set up.\n Please refer to the documentation of the SpeziAccount package on how to set up an AccountService!"; +// MARK: - General +"OR" = "or"; +"YES" = "Yes"; +"NO" = "No"; +"MISSING_ACCOUNT_SERVICES" = "**No Account Services set up.**\n\nPlease refer to the documentation of the SpeziAccount package on how to set up an AccountService!"; +"MISSING_ACCOUNT_DETAILS" = "**Couldn't find a user account.**\n\nThis view requires an active user account.\nRefer to the documentation of the AccountSetup view on how to setup a user account!"; "OPEN_DOCUMENTATION" = "Open Documentation"; -// MARK: - Username Password +// MARK: - Account +"ACCOUNT_WELCOME" = "Your Account"; +"ACCOUNT_WELCOME_SUBTITLE" = "Please login to your account. Or create a new one if you don't have one already."; +"ACCOUNT_WELCOME_SIGNED_IN_SUBTITLE" = "You are already logged in with the account shown below. Continue or change your account by logging out."; -// Login -"UAP_LOGIN_BUTTON_TITLE" = "Username and Password"; -"UAP_LOGIN_NAVIGATION_TITLE" = "Login"; -"UAP_LOGIN_USERNAME_TITLE" = "Username"; -"UAP_LOGIN_USERNAME_PLACEHOLDER" = "Enter your username ..."; -"UAP_LOGIN_PASSWORD_TITLE" = "Password"; -"UAP_LOGIN_PASSWORD_PLACEHOLDER" = "Enter your password ..."; -"UAP_LOGIN_ACTION_BUTTON_TITLE" = "Login"; -"UAP_LOGIN_FAILED_DEFAULT_ERROR" = "Could not login"; +// MARK: - AccountValues +"ACCOUNT_VALUES_MISSING_VALUE_DESCRIPTION" = "Missing Account Values"; +"ACCOUNT_VALUES_MISSING_VALUE_REASON %@" = "The following required account values were not supplied: %@"; +"ACCOUNT_VALUES_MISSING_VALUE_RECOVERY" = "Raise an issue with the developer of the Account Service."; -// SignUp -"UAP_SIGNUP_BUTTION_TITLE" = "Username and Password"; -"UAP_SIGNUP_NAVIGATION_TITLE" = "Sign Up"; -"UAP_SIGNUP_USERNAME_TITLE" = "Username"; -"UAP_SIGNUP_USERNAME_PLACEHOLDER" = "Enter your username ..."; -"UAP_SIGNUP_PASSWORD_TITLE" = "Password"; -"UAP_SIGNUP_PASSWORD_PLACEHOLDER" = "Enter your password ..."; -"UAP_SIGNUP_PASSWORD_REPEAT_TITLE" = "Repeat -Password"; -"UAP_SIGNUP_PASSWORD_REPEAT_PLACEHOLDER" = "Repeat your password ..."; -"UAP_SIGNUP_PASSWORD_NOT_EQUAL_ERROR" = "The entered passwords are not equal."; -"UAP_SIGNUP_GIVEN_NAME_TITLE" = "First Name"; -"UAP_SIGNUP_GIVEN_NAME_PLACEHOLDER" = "Enter your first name ..."; -"UAP_SIGNUP_FAMILY_NAME_TITLE" = "Last Name"; -"UAP_SIGNUP_FAMILY_NAME_PLACEHOLDER" = "Enter your last name ..."; -"UAP_SIGNUP_GENDER_IDENTITY_TITLE" = "Gender Identity"; -"UAP_SIGNUP_DATE_OF_BIRTH_TITLE" = "Date of Birth"; -"UAP_SIGNUP_ACTION_BUTTON_TITLE" = "Sign Up"; -"UAP_SIGNUP_FAILED_DEFAULT_ERROR" = "Could not sign up"; +// MARK: - UserId and Password +"UP_PASSWORD" = "Password"; +"NEW_PASSWORD" = "New"; +"NEW_PASSWORD_PROMPT" = "enter password"; +"REPEAT_PASSWORD" = "Verify"; +"REPEAT_PASSWORD_PROMPT" = "re-enter password"; +"UP_FORGOT_PASSWORD" = "Forgot Password?"; +"UP_LOGIN" = "Login"; +"UP_SIGNUP" = "Signup"; +"UP_LOGOUT" = "Logout"; +"UP_NO_ACCOUNT_YET" = "Don't have an Account yet?"; + +// MARK: - UserId and Password (Login) +"UP_LOGIN_FAILED_DEFAULT_ERROR" = "Could not login!"; +// MARK: - UserId and Password (Signup) +"UP_SIGNUP_INSTRUCTIONS" = "Please fill out the details below to create a new account."; +"UP_CREDENTIALS" = "Credentials"; +"UP_NAME" = "Name"; +"UP_CONTACT_DETAILS" = "Contact Details"; +"UP_PERSONAL_DETAILS" = "Personal Details"; +"UP_SIGNUP_FAILED_DEFAULT_ERROR" = "Could not sign up!"; -// Reset -"UAP_RESET_PASSWORD_BUTTON_TITLE" = "Forgot Password?"; -"UAP_RESET_PASSWORD_NAVIGATION_TITLE" = "Forgot Password"; -"UAP_RESET_PASSWORD_USERNAME_TITLE" = "Username"; -"UAP_RESET_PASSWORD_USERNAME_PLACEHOLDER" = "Enter your username ..."; -"UAP_RESET_PASSWORD_ACTION_BUTTON_TITLE" = "Reset Password"; +// MARK: - UserId and Password (Password Reset) +"UP_RESET_PASSWORD" = "Reset Password"; +"UAP_PASSWORD_RESET_SUBTITLE %@" = "Please enter your %@ of your Account. A password reset email will be sent to the linked email address."; "UAP_RESET_PASSWORD_PROCESS_SUCCESSFUL_LABEL" = "Sent out a link to reset the password."; -"UAP_RESET_PASSWORD_FAILED_DEFAULT_ERROR" = "Could not reset the password"; +"UAP_RESET_PASSWORD_FAILED_DEFAULT_ERROR" = "Could not reset the password!"; + +// MARK: - Account Summary +"UP_LOGOUT_FAILED_DEFAULT_ERROR" = "Could not logout!"; +// MARK - Account Overview +"ACCOUNT_OVERVIEW" = "Account Overview"; +"CONFIRMATION_DISCARD_CHANGES_TITLE" = "Are you sure you want to discard your changes?"; +"CONFIRMATION_DISCARD_CHANGES" = "Discard Changes"; +"CONFIRMATION_KEEP_EDITING"= "Keep Editing"; +"CONFIRMATION_LOGOUT" = "Are you sure you want to logout?"; +"CONFIRMATION_REMOVAL" = "Are you sure you want to delete your account?"; +"CONFIRMATION_REMOVAL_SUGGESTION" = "This change is permanent and you won't be able to recover your account information."; +"DELETE_ACCOUNT" = "Delete Account"; +"DONE" = "Done"; +"EDIT" = "Edit"; +"DELETE"= "Delete"; +"CANCEL" = "Cancel"; +"ACCOUNT_OVERVIEW_EDIT_DEFAULT_ERROR" = "Could not save account details!"; +"REMOVE_DEFAULT_ERROR"= "Could not remove account!"; +"SECURITY" = "Security"; +"VALUE_ADD %@" = "Add %@"; +"CHANGE_PASSWORD" = "Change Password"; -// MARK: - Email and Password -"EAP_LOGIN_BUTTON_TITLE" = "Email and Password"; -"EAP_SIGNUP_BUTTON_TITLE" = "Email and Password"; -"EAP_LOGIN_USERNAME_TITLE" = "Email"; -"EAP_LOGIN_USERNAME_PLACEHOLDER" = "Enter your email ..."; -"EAP_EMAIL_VERIFICATION_ERROR" = "The entered email is not correct."; +// MARK: - Validation Rules +"VALIDATION_RULE_NON_EMPTY" = "This field cannot be empty."; +"VALIDATION_RULE_UNICODE_LETTERS" = "You must only use letters."; +"VALIDATION_RULE_UNICODE_LETTERS_ASCII" = "You must only use standard English letters."; +"VALIDATION_RULE_MINIMAL_EMAIL" = "The provided email is invalid."; +"VALIDATION_RULE_PASSWORD_LENGTH %lld" = "Your password must be at least %lld characters long."; +"VALIDATION_RULE_PASSWORDS_NOT_MATCHED" = "Passwords do not match."; +"VALIDATION_RULE_GIVEN_NAME_EMPTY" = "The first name field cannot be empty!"; +"VALIDATION_RULE_FAMILY_NAME_EMPTY" = "The last name field cannot be empty!"; +// MARK: - User Id +"USER_ID" = "User Identifier"; +"USER_ID_EMAIL" = "E-Mail Address"; +"USER_ID_USERNAME" = "Username"; + +// MARK: - Person Name +"NAME" = "Name"; +"UAP_SIGNUP_GIVEN_NAME_TITLE" = "First"; +"UAP_SIGNUP_GIVEN_NAME_PLACEHOLDER" = "enter first name"; +"UAP_SIGNUP_FAMILY_NAME_TITLE" = "Last"; +"UAP_SIGNUP_FAMILY_NAME_PLACEHOLDER" = "enter last name"; + +// MARK: - Date Of Birth +"UAP_SIGNUP_DATE_OF_BIRTH_TITLE" = "Date of Birth"; +"ADD_DATE" = "Add Date"; // MARK: - Gender Identity +"GENDER_IDENTITY_TITLE" = "Gender Identity"; "GENDER_IDENTITY_FEMALE" = "Female"; "GENDER_IDENTITY_MALE" = "Male"; "GENDER_IDENTITY_TRANSGENDER" = "Transgender"; diff --git a/Sources/SpeziAccount/SignUp.swift b/Sources/SpeziAccount/SignUp.swift deleted file mode 100644 index 33e57f93..00000000 --- a/Sources/SpeziAccount/SignUp.swift +++ /dev/null @@ -1,56 +0,0 @@ -// -// This source file is part of the Spezi open-source project -// -// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -import Spezi -import SwiftUI - - -/// Display sign up buttons for all configured ``AccountService``s using the ``Account/Account`` module. -/// -/// The view displays a list of sign up buttons as well as a cusomizable header view that can be defined using the ``Login/init(header:)`` initializer. -public struct SignUp: View { - private var header: Header - - - public var body: some View { - AccountServicesView(header: header) { accountService in - accountService.signUpButton - } - .navigationTitle(String(localized: "SIGN_UP", bundle: .module)) - } - - - public init() where Header == EmptyView { - self.header = EmptyView() - } - - /// - Parameter header: A SwiftUI `View` displayed as a header above all login buttons. - public init(@ViewBuilder header: () -> (Header)) { - self.header = header() - } -} - - -#if DEBUG -struct SignUp_Previews: PreviewProvider { - @StateObject private static var account: Account = { - let accountServices: [any AccountService] = [ - UsernamePasswordAccountService(), - EmailPasswordAccountService() - ] - return Account(accountServices: accountServices) - }() - - static var previews: some View { - NavigationStack { - SignUp() - } - .environmentObject(account) - } -} -#endif diff --git a/Sources/SpeziAccount/SpeziAccount.docc/Account Services/Creating your own Account Service.md b/Sources/SpeziAccount/SpeziAccount.docc/Account Services/Creating your own Account Service.md new file mode 100644 index 00000000..31542915 --- /dev/null +++ b/Sources/SpeziAccount/SpeziAccount.docc/Account Services/Creating your own Account Service.md @@ -0,0 +1,130 @@ +# Creating your own Account Service + +Create your own Account Service implementation to integrate a user account platform. + + + +## Overview + +An `AccountService` is the abstraction layer to create and manage user accounts and can be used to integrate with existing +user account platforms. + +This article guides you through the essential steps to implement your own Account Service. + +### Deciding on the type of Account Service + +`SpeziAccount` provides several different ``AccountService`` protocols (see ``EmbeddableAccountService``, ``UserIdPasswordAccountService`` and ``IdentityProvider``). +Refer to their documentation to evaluate which protocol fits your need best. Typically, if your account credentials are made up from a user identifier +and a password you would want to go for the ``UserIdPasswordAccountService`` and benefit from a lot of the UI components already provided. + +### Account Service Configuration + +Every account service has to provide their ``AccountServiceConfiguration`` through the ``AccountService/configuration`` property. Required information is +the ``AccountServiceName`` and ``SupportedAccountKeys`` configuration. Other configurations can be provided through the optional result builder closure. + +Below is a short code example that demonstrates usage of the ``SupportedAccountKeys/arbitrary``, ``UserIdConfiguration`` and ``RequiredAccountKeys`` configuration. + +```swift +AccountServiceConfiguration(name: "My Account Service", supportedKeys: .arbitrary) { + UserIdConfiguration(type: .emailAddress, keyboardType: .emailAddress) // the default for this configuration + + RequiredAccountKeys { + \.userId + \.password + } +} +``` + +### Implementing your Account Service + +Apart from implementing the ``AccountService`` protocol, an account service is responsible for notifying the global ``Account`` context +of any changes of the user state (e.g., user information updated remotely). + +To do so, you can use the ``AccountService/AccountReference`` property wrapper to get access to the ``Account`` context. +You can then use the ``Account/supplyUserDetails(_:)`` and ``Account/removeUserDetails()`` methods +to update the account state. +Below is a short code example that implements a basic remote session expiration handler. + +> Note: You will always need to call the ``Account/supplyUserDetails(_:)`` and ``Account/removeUserDetails()`` methods manually, +even if the change in user state is caused by a local operation like ``AccountService/signUp(signupDetails:)`` or ``AccountService/logout()``. + +```swift +actor MyAccountService: AccountService { + @AccountReference var account + + func handleRemoteLogout() async { + await account.removeUserDetails() + } +} +``` + +Refer to the respective ``AccountService`` protocol for a list of operations you have to implement. + +> Note: Have a look at the article on how to handle and manipulate account values storage containers. + +The UI components of your ``AccountService`` used within the ``AccountSetup`` view are defined through a ``AccountSetupViewStyle`` (see ``AccountService/viewStyle-swift.property``). +Have a look at the article for more information on this topic. + +### Setting up your Account Service + +There are two basic approaches to set up an account service using the ``AccountServiceConfiguration`` in your `Spezi` app. + +The first approach is to provide your ``AccountService`` as is and let the user create an instance of the account service themselves to +pass it to the result builder closure of the ``AccountServiceConfiguration/init(name:supportedKeys:configuration:)`` initializer. +This is the preferred approach for account services that don't require additional setup or rely on any special infrastructure. + +If your account service requires additional setup or any infrastructure that relies on other `Component`s you can implement +your own `Spezi` [Component](https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/component) that _provides_ your +``AccountService`` directly to the ``AccountServiceConfiguration`` component. To do so, declare the `@Provide` property wrapper with a type of +`any AccountService` and populate it within your initializer. Below is a code example. + +```swift +class MyComponent: Component { + @Provide var accountService: any AccountService // you can also use a type of [any AccountService] to provide multiple with a single @Provide + + init() { + accountService = MyAccountService() + } + + func configure() { + // set up your infrastructure and e.g. register event handlers to the `accountService` + } +} +``` + +> Note: Refer to the [Component Communication](https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/component#Communication) documentation + of the `Spezi` framework for more detailed information. + + +## Topics + +### Account Services + +- ``AccountService`` +- ``EmbeddableAccountService`` +- ``UserIdPasswordAccountService`` +- ``IdentityProvider`` + +### Providing Configuration + +- ``AccountServiceConfiguration`` +- ``AccountServiceName`` +- ``AccountServiceImage`` +- ``SupportedAccountKeys`` +- ``RequiredAccountKeys`` +- ``UserIdConfiguration`` +- ``UserIdType`` +- ``FieldValidationRules`` + +### Managing Account Details + +- ``Account/supplyUserDetails(_:)`` +- ``Account/removeUserDetails()`` diff --git a/Sources/SpeziAccount/SpeziAccount.docc/Account Services/Customize your View Styles.md b/Sources/SpeziAccount/SpeziAccount.docc/Account Services/Customize your View Styles.md new file mode 100644 index 00000000..1a0536db --- /dev/null +++ b/Sources/SpeziAccount/SpeziAccount.docc/Account Services/Customize your View Styles.md @@ -0,0 +1,46 @@ +# Customize your View Styles + +Customize how your Account Service appears in the ``AccountSetup`` view. + + + +## Overview + +``AccountSetupViewStyle``s are used to present your Account Service implementation to the user in the ``AccountSetup`` view. +For some Views default implementations are provided based on the ``AccountServiceConfiguration`` +(e.g., using the ``AccountServiceName`` or ``AccountServiceImage``) or based on the type of Account Service used. + +For more information refer to the documentation of ``AccountSetupViewStyle``, ``EmbeddableAccountSetupViewStyle``, ``UserIdPasswordAccountSetupViewStyle``, +or ``IdentityProviderViewStyle``. + +## Topics + +### Designing Account Setup Views + +- ``AccountSetupViewStyle`` +- ``EmbeddableAccountSetupViewStyle`` +- ``UserIdPasswordAccountSetupViewStyle`` +- ``IdentityProviderViewStyle`` + +### Reusable UI Components + +- ``DefaultAccountSetupHeader`` +- ``AccountSummaryBox`` +- ``SignupForm`` +- ``DateOfBirthPicker`` +- ``GenderIdentityPicker`` +- ``SuccessfulPasswordResetView`` + +### UI Components for the UserIdPasswordAccountService + +- ``UserIdPasswordPrimaryView`` +- ``UserIdPasswordEmbeddedView`` +- ``UserIdPasswordResetView`` diff --git a/Sources/SpeziAccount/SpeziAccount.docc/Account Values/Adding new Account Values.md b/Sources/SpeziAccount/SpeziAccount.docc/Account Values/Adding new Account Values.md new file mode 100644 index 00000000..7cba3cba --- /dev/null +++ b/Sources/SpeziAccount/SpeziAccount.docc/Account Values/Adding new Account Values.md @@ -0,0 +1,173 @@ +# Adding new Account Values + +Support new user account details by defining your own ``AccountKey``. + + + +## Overview + +By defining a custom ``AccountKey`` you can add new data points stored in your user accounts. +An ``AccountKey`` implementation provides all required UI components both for data entry using a ``DataEntryView`` and data display using a +``DataDisplayView``. Consequentially, none of the `SpeziAccount` provided UI components nor existing ``AccountService`` implementations need to be modified. + +This articles guides you through all the necessary steps of defining a new ``AccountKey``. + +### Defining the AccountValue Key + +The first step is to create a new type that adopts the ``AccountKey`` protocol. + +> Note: Refer to the ``RequiredAccountKey`` protocol if you require an account value that is always required to be supplied if configured. + +When adopting the protocol, you have to provide the associated [Value](https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/knowledgesource/value) +type, a ``AccountKey/name``, a ``AccountKey/category`` and an ``AccountKey/initialValue-6h1oo``. +The `Value` defines the type of the account value, the `name` is used to textually refer to the account value and +the `category` is used to group the account values in UI components (see ``AccountKeyCategory`` for more information). +The `initialValue` defines the initial value on signup and how it is used. For some types like String a default ``InitialValue/empty(_:)`` implementation is provided. + +> Note: The associated type for the value is coming from the underlying + [KnowledgeSource](https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/knowledgesource) protocol from the Spezi framework. + Refer to the [Shared Repository](https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/shared-repository) + documentation for more information. + +Below is a code example implementing a simple string-based biography that a user might show on their profile. +```swift +public struct BiographyKey: AccountKey { + public typealias Value = String // as we have declared a String value, we don't need to specify a `initialValue` manually + + public static let name: LocalizedStringResource = "Biography" // make sure to translate your name + public static let signupCategory: SignupCategory = .other +} +``` + +### Value Conformances + +Your `Value` type requires several protocol conformances. + +* The `Value` type must conform to `Sendable` to be safely passed across actor boundaries. +* The `Value` type must conform to `Equatable` to be easily notified about changes at data entry. +* The `Value` type must conform to `Codable` such that ``AccountService``s or a ``AccountStorageStandard`` can easily store and retrieve + arbitrary `Value` types. + +### Accessors + +In order for our new ``AccountKey`` implementation to work seamlessly with the ``SpeziAccount`` infrastructure, +we have to declare several extensions, so we can easily access the ``AccountKey`` meta-type or the value stored for a given user. + +First, an extension to the ``AccountKeys`` type is required, inorder to use a shorthand, `KePath`-based notation to refer to the ``AccountKey`` metatypes. + +```swift +extension AccountKeys { + public var biography: BiographyKey.Type { + BiographyKey.self + } +} +``` + +Secondly, an extension to the ``AccountValues`` protocol is required to retrieve the account value from ``AccountValues`` instances like +``AccountDetails`` or ``SignupDetails``. + +```swift +extension AccountValues { + public var biography: String? { + storage[BiographyKey.self] + } +} +``` + +### UI Components + +Each ``AccountKey`` has to provide a SwiftUI view that is used during signup or when the user wants to view or edit their current account information. +Read through the following sections for more information how to provide UI components for your ``AccountKey`` implementation. + +### Data Display View + +The associated `DataDisplay` type provides the SwiftUI view that handles displaying a value of the ``AccountKey``. +In cases where the `Value` is `String`-based or conforms to the `CustomLocalizedStringResourceConvertible` protocol, +an automatic implementation is provided. +Therefore, you typically do not need to provide a custom view implementation, +or you might consider adding `CustomLocalizedStringResourceConvertible` protocol +conformance to your `Value` type. + +> Note: For more information on how to implement your custom data display view, refer to the ``DataDisplayView`` protocol. + +#### Data Entry View + +The associated ``AccountKey/DataEntry`` type provide the SwiftUI view that handles value entry of the ``AccountKey``. You must always provide a +``DataEntryView`` type. + +This protocol has two requirements: ``DataEntryView/Key`` defines the associated ``AccountKey`` type (what we just implemented) +and provides an ``DataEntryView/init(_:)`` to retrieve a `Binding` to the current (or empty) account value +from the parent view (refer to ``GeneralizedDataEntryView`` for more information). + +Below is a short code example on how one could implement the ``DataEntryView`` for our new biography account value. +```swift +extension BiographyKey { + public struct DataEntry: DataEntryView { + public typealias Key = BiographyKey + + @Binding private var biography: Value + + public init(_ value: Binding) { + self._biography = value + } + + public var body: some View { + VerifiableTextField(Key.name, text: $biography) + .autocorrectionDisabled() + } + } +} +``` + +##### Input Validation + +`SpeziAccount` provides basic validation for most cases where necessary due to ``FieldValidationRules`` or ``AccountKeyRequirement`` configurations. +Still, you are required to evaluate to which extent validation has to be handled in your implementation. + +* For all `String` types a ``ValidationEngine`` is automatically injected into the environment. The ``ValidationEngine`` is either populated by + the rules provided by the account service through ``FieldValidationRules`` or if the user specified a ``AccountKeyRequirement/required`` level. + You must execute the ``ValidationEngine/submit(input:debounce:)`` on input changes and display the ``ValidationEngine/displayedValidationResults`` + or use components like the ``VerifiableTextField`` that automatically do that for you. +* For other types that use ``InitialValue/empty(_:)`` and are specified to be ``AccountKeyRequirement/required``, + validation is automatically set up to check if the user provided a value. For example given a `Date`-based account value, we would require that + the user modifies the Data at least once. +* For other types that use ``InitialValue/default(_:)`` we do not perform any validation automatically. +* If you have diverging needs (e.g., multi field input), you will need to handle validation yourself. + + +## Topics + +### Implementing Account Values + +- ``AccountKey`` +- ``RequiredAccountKey`` +- ``AccountKeyCategory`` +- ``InitialValue`` +- ``AccountKeys`` +- ``AccountValues`` + +### Data Display View + +- ``DataDisplayView`` +- ``StringBasedDisplayView`` +- ``LocalizableStringBasedDisplayView`` + +### Data Entry View + +- ``DataEntryView`` +- ``GeneralizedDataEntryView`` + +### Available Environment Keys + +- ``SwiftUI/EnvironmentValues/accountViewType`` +- ``SwiftUI/EnvironmentValues/accountServiceConfiguration`` +- ``AccountViewType`` +- ``OverviewEntryMode`` diff --git a/Sources/SpeziAccount/SpeziAccount.docc/Account Values/Handling Account Value Storage.md b/Sources/SpeziAccount/SpeziAccount.docc/Account Values/Handling Account Value Storage.md new file mode 100644 index 00000000..0bab3cfe --- /dev/null +++ b/Sources/SpeziAccount/SpeziAccount.docc/Account Values/Handling Account Value Storage.md @@ -0,0 +1,123 @@ +# Handling Account Value Storage + +How to build and modify account value storage. + + + +## Overview + +``AccountValues`` implementations are used to store values for a given ``AccountKey`` definition. + +There exist several different containers types. While they are identical in the underlying storage mechanism with only very +few differences in construction operations, they convey entirely different semantics and are used within their respective context only. + +### Accessing Account Information + +``AccountKey``s define an extension to the ``AccountValues`` so account values can be conveniently accessed. For example, the +``PersonNameKey`` defines the ``AccountValues/name`` property as an extension to access the name of a person if it exists. +Otherwise, you can always access the underlying [Shared Repository](https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/shared-repository) +using the ``AccountValues/storage`` property. + +### Iterating through Account Values + +You can iterate through ``AccountValues`` in a type-safe way using the [Visitor Pattern](https://en.wikipedia.org/wiki/Visitor_pattern). +This is provided through the ``AccountValueVisitor`` protocol and the ``AcceptingAccountValueVisitor/acceptAll(_:)-9hgw5`` method all +``AccountValues`` collections implement. + +Below is a short code example that demonstrates the capabilities of a ``AccountValueVisitor`` to encode all stored values of a ``AccountDetails`` instance. + +```swift +import Foundation + +struct Visitor: AccountValueVisitor { + private let encoder = JSONEncoder() + private var codableStorage: [String: Data] = [:] + + mutating func visit(_ key: Key.Type, _ value: Key.Value) { + // in a real world implementation one would need to collect all thrown errors. We ignore them for the sake of the example. + codableStorage["\(Key.self)"] = try? encoder.encode(value) + } + + func final() -> [String: Data] { // final method provides the return type for `acceptAll` + codableStorage + } +} + +var visitor = Visitor() +let encoded = details.acceptAll(&visitor) +``` + +> Important: ``AccountValues`` implement the `Collection` protocol and therefore support iteration. However, `Element`s of the collection are of type + [AnyRepositoryValue](https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/anyrepositoryvalue) as ``AccountValues`` might store + non-``AccountKey`` conforming knowledge sources like the ``ActiveAccountServiceKey``. + +### Iterating through Account Keys + +You can iterate through a collection of ``AccountKey``s in a type-safe way using the [Visitor Pattern](https://en.wikipedia.org/wiki/Visitor_pattern). +This is provided through the ``AccountKeyVisitor`` protocol and the ``AcceptingAccountKeyVisitor/acceptAll(_:)-1ytax`` method all implemented by +`[any AccountKey.Type]` arrays or ``AccountKeyCollection``s. This is useful when you are accessing the ``AccountValues/keys-572sk`` property or +implement a custom storage provider (see ``AccountStorageStandard/load(_:_:)``). + +An implementation is similarly structured to the code example shown in the previous section. + +> Note: You can also use the ``AccountKey/accept(_:)-8wakg`` method directly for visiting a single element. + +### Managing Account Values + +New ``AccountValues`` instances are created using the ``AccountValuesBuilder`` class. Every container provides access to the respective builder class +using the ``AccountValues/Builder`` typealias. + +Below is a short example on how to create a new ``AccountDetails`` instance using the `Builder` class. For more detailed information refer +to the documentation page of ``AccountValuesBuilder``. + +```swift +let details = AccountDetails.Builder() + .set(\.userId, "my-email@example.com") + .set(\.name, "Hello World") + .build(owner: /* accountService */) +``` + +> Note: Building ``AccountDetails`` is special, as you are required to use the dedicated ``AccountValuesBuilder/build(owner:)`` method + instead of the standard ``AccountValuesBuilder/build()-pqt5`` method. + +## Topics + +### Generalized Containers + +- ``AccountValuesCollection`` +- ``AccountValues`` + +### Account Values + +- ``AccountDetails`` +- ``SignupDetails`` +- ``AccountModifications`` +- ``ModifiedAccountDetails`` +- ``RemovedAccountDetails`` +- ``PartialAccountDetails`` + +### Account Keys + +- ``AccountValues/keys-572sk`` +- ``AccountKeyCollection`` + +### Visitors + +- ``AccountValueVisitor`` +- ``AcceptingAccountValueVisitor`` +- ``AcceptingAccountValueVisitor/acceptAll(_:)-9hgw5`` +- ``AccountKeyVisitor`` +- ``AcceptingAccountKeyVisitor`` +- ``AcceptingAccountKeyVisitor/acceptAll(_:)-1ytax`` + +### Construction + +- ``AccountValuesBuilder`` diff --git a/Sources/SpeziAccount/SpeziAccount.docc/CreateAnAccountService.md b/Sources/SpeziAccount/SpeziAccount.docc/CreateAnAccountService.md deleted file mode 100644 index 8808ea20..00000000 --- a/Sources/SpeziAccount/SpeziAccount.docc/CreateAnAccountService.md +++ /dev/null @@ -1,167 +0,0 @@ -# Create an Account Service - - - -Account services describe the mechanism for account management components to display login, signUp, and account-related UI elements. - -## Create Your Account Service - -You can create new account services by conforming to the ``AccountService`` protocol. -An ``AccountService`` has to provide an ``AccountService/loginButton`` and ``AccountService/signUpButton-8r2hk`` that is used in the -``Login`` and ``SignUp`` views. - -The ``AccountService/inject(account:)`` function provides an ``AccountService`` to get access to the ``Account/Account`` actor. -An ``AccountService`` generally stores the ``Account/Account`` actor using a weak reference to avoid reference cycles. - -The following example demonstrates an example of an ``AccountService``: -```swift -class ExampleAccountService: @unchecked Sendable, AccountService, ObservableObject { - weak var account: Account? - - - var loginButton: AnyView { - AnyView( - NavigationLink { - Text("Your Login View ...") - .environmentObject(self) - } label: { - Text("Login") - } - ) - } - - var signUpButton: AnyView { - AnyView( - NavigationLink { - Text("Your Sign Up View ...") - .environmentObject(self) - } label: { - Text("Sign Up") - } - ) - } - - - public init() { } - - - func inject(account: Account) { - self.account = account - } - - func login(/* ... */) async throws { } - - func signUp(/* ... */) async throws { } -} -``` - -## Username Password-based Account Services - -The ``UsernamePasswordAccountService`` provides a starting point for a username and password-based ``AccountService`` that can be subclassed or extended -to fit the need of the specific application. The ``UsernamePasswordSignUpView``, ``UsernamePasswordLoginView``, and ``UsernamePasswordResetPasswordView`` -all rely on the ``UsernamePasswordAccountService`` to be present in the SwiftUI environment. - -The following example uses the `User` type defined below to login and sign up a user. -```swift -actor User: ObservableObject { - @MainActor @Published var username: String? - @MainActor @Published var name = PersonNameComponents() - @MainActor @Published var gender: GenderIdentity? - @MainActor @Published var dateOfBirth: Date? - - - init( - username: String? = nil, - name: PersonNameComponents = PersonNameComponents(), - gender: GenderIdentity? = nil, - dateOfBirth: Date? = nil - ) { - Task { @MainActor in - self.username = username - self.name = name - self.gender = gender - self.dateOfBirth = dateOfBirth - } - } -} -``` - -Subclassing ``UsernamePasswordAccountService`` enables a built-in functionality to handle username and password-related sign up and login functionality. -The following example demonstrates subclassing the ``UsernamePasswordAccountService`` with custom login and sign up functions. -```swift -class ExampleUsernamePasswordAccountService: UsernamePasswordAccountService { - let user: User - - - init(user: User) { - self.user = user - super.init() - } - - - override func login(username: String, password: String) async throws { - try await Task.sleep(for: .seconds(5)) - - guard username == "lelandstanford", password == "StanfordRocks123!" else { - throw MockAccountServiceError.wrongCredentials - } - - await MainActor.run { - account?.signedIn = true - user.username = username - } - } - - - override func signUp(signUpValues: SignUpValues) async throws { - try await Task.sleep(for: .seconds(5)) - - guard signUpValues.username != "lelandstanford" else { - throw MockAccountServiceError.usernameTaken - } - - await MainActor.run { - account?.signedIn = true - user.username = signUpValues.username - user.name = signUpValues.name - user.dateOfBirth = signUpValues.dateOfBirth - user.gender = signUpValues.genderIdentity - } - } - - override func resetPassword(username: String) async throws { - try await Task.sleep(for: .seconds(5)) - } -} - -``` - -## Build-in Account Services - -### Account Service - -- ``AccountService`` - -### Username And Password Account Service - -The ``UsernamePasswordAccountService`` provides a username and password-account management. - -- ``UsernamePasswordAccountService`` -- ``UsernamePasswordSignUpView`` -- ``UsernamePasswordLoginView`` -- ``UsernamePasswordResetPasswordView`` - -### Email And Password Account Service - -The ``EmailPasswordAccountService`` is a ``UsernamePasswordAccountService`` subclass providing email related validation rules and -customized view buttons enabeling the creation of an email and password-based ``AccountService``. - -- ``EmailPasswordAccountService`` diff --git a/Sources/SpeziAccount/SpeziAccount.docc/Other/Validation.md b/Sources/SpeziAccount/SpeziAccount.docc/Other/Validation.md new file mode 100644 index 00000000..d76f0398 --- /dev/null +++ b/Sources/SpeziAccount/SpeziAccount.docc/Other/Validation.md @@ -0,0 +1,93 @@ +# Validation + +Generalized input validation abstraction. + + + +## Overview + +This article provides an overview of all the components used to perform input validation. +They are used across the `SpeziAccount` framework. + +The validation state is managed through an instance of ``ValidationEngine``. +You provide a set of ``ValidationRule``s against a given input is validated and the ``ValidationEngine`` provides you +with an array of ``FailedValidationResult``s for all ``ValidationRule``s that failed for a given input. + +There are preexisting UI components, like the ``VerifiableTextField``, that perform and display validation results automatically for you. You just have +to manage a ``ValidationEngine`` in the `Environment`. + +Below is a short code example, that uses a ``ValidationEngine`` in combination with a ``VerifiableTextField`` to perform a simple non-empty validation +using the preexisting ``ValidationRule/nonEmpty`` rule. +Note how we use the ``ValidationEngine/inputValid`` property to conditionally disable the button input. Further, we ensure validity of input by calling +``ValidationEngine/runValidation(input:)`` on the button press. For more information refer to the documentation of ``ValidationEngine``. + + +```swift +struct MyView: View { + @StateObject var validation = ValidationEngine(rules: .nonEmpty) + + @State var pet: String = "" + + var body: some View { + VerifiableTextField("Your favorite pet?", text: $pet) + .environment(validation) + + Button(action: savePet) { + Text("Save") + } + .disabled(!validation.inputValid) + } + + func savePet() { + validation.runValidation(input: pet) + guard validation.inputValid else { + return + } + // ... + } +} +``` + +## Topics + +### Validation + +- ``ValidationEngine`` +- ``ValidationRule`` +- ``FailedValidationResult`` + +### Builtin Validation Rules + +- ``ValidationRule/nonEmpty`` +- ``ValidationRule/unicodeLettersOnly`` +- ``ValidationRule/asciiLettersOnly`` +- ``ValidationRule/minimalEmail`` +- ``ValidationRule/minimalPassword`` +- ``ValidationRule/mediumPassword`` +- ``ValidationRule/strongPassword`` + +### Using Validation in your Views + +- ``VerifiableTextField`` +- ``ValidationResultsView`` + +### Collecting Validation Engines + +- ``ValidationEngines`` +- ``SwiftUI/View/register(engine:with:for:input:)`` +- ``SwiftUI/View/register(engine:with:input:)`` + +### Managed Validation + +- ``SwiftUI/View/managedValidation(input:for:rules:)-5gj5g`` +- ``SwiftUI/View/managedValidation(input:for:rules:)-zito`` +- ``SwiftUI/View/managedValidation(input:rules:)-vp6w`` +- ``SwiftUI/View/managedValidation(input:rules:)-8afst`` diff --git a/Sources/SpeziAccount/SpeziAccount.docc/Setup Guides/Custom Storage Provider.md b/Sources/SpeziAccount/SpeziAccount.docc/Setup Guides/Custom Storage Provider.md new file mode 100644 index 00000000..54719703 --- /dev/null +++ b/Sources/SpeziAccount/SpeziAccount.docc/Setup Guides/Custom Storage Provider.md @@ -0,0 +1,58 @@ +# Custom Storage Provider + +Store arbitrary account values by providing a ``AccountStorageStandard`` implementation. + + + +## Overview + +In certain cases, a given ``AccountService`` implementation might be limited to storing only a fixed set of account values. +If you have ``ConfiguredAccountKey``s that are not part of the ``SupportedAccountKeys`` set of a configured ``AccountService`` +you can provide a ``AccountStorageStandard`` conformance to your `Spezi` +[Standard](https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/standard) to handle storage of additional +account values. + +### Define the Conformance + +Refer to the documentation of the ``AccountStorageStandard`` protocol for more information on the required implementation. + +Contact the [Standard Conformance](https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/standard#1-Standard-Conformance) +section of the `Spezi` documentation on how to conform to `Standard` constraints. + +> Note: Have a look at the article on how to handle and manipulate account values storage containers. + +### App Configuration + +Below is a short code example on how to set up your Standard in your App's configuration section. Refer to the +[Standard Definition](https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/standard#2-Standard-Definition) section +of the `Spezi` documentation for more information. + +```swift +var configuration: Configuration { + Configuration(standard: ExampleStorageStandard()) { + // ... `AccountConfiguration` and Account Service configuration + } +} +``` + +> Note: Your ``AccountStorageStandard`` will be used + to handle data flow for all configured ``AccountService``s that do not support at least one + ``ConfiguredAccountKey``. + +## Topics + +### Providing Storage + +- ``AccountStorageStandard`` + +### Identifying Additional Storage Records + +- ``AdditionalRecordId`` diff --git a/Sources/SpeziAccount/SpeziAccount.docc/Setup Guides/Initial Setup.md b/Sources/SpeziAccount/SpeziAccount.docc/Setup Guides/Initial Setup.md new file mode 100644 index 00000000..f74f418a --- /dev/null +++ b/Sources/SpeziAccount/SpeziAccount.docc/Setup Guides/Initial Setup.md @@ -0,0 +1,124 @@ +# Initial Setup + + + +A quick-start guide that shows you how to set up ``SpeziAccount`` in your App. + +## Overview + +This article guides you through the mandatory steps to get `SpeziAccount` up and running. We highlight the necessary +configuration while also showcase the essential `View` components. + +### Account Configuration + +The ``AccountConfiguration`` is the central configuration option to enable ``SpeziAccount`` for your App. Add it +to your `Configuration` closure of your `SpeziAppDelegate`. +Below is an example configuration. + +You must always supply an array of ``ConfiguredAccountKey``s that 1) define the ``AccountKeyRequirement`` level +and 2) the order in which they are displayed (according to their ``AccountKeyCategory``). + +```swift +class YourAppDelegate: SpeziAppDelegate { + override var configuration: Configuration { + AccountConfiguration(configuration: [ + .requires(\.userId), + .requires(\.password), + .requires(\.name), + .collects(\.dateOfBirth), + .collects(\.genderIdentity) + ]) + } +} +``` + +> Note: You may also use the ``ConfiguredAccountKey/supports(_:)-7wwdi`` configuration to mark a ``AccountKey`` as + ``AccountKeyRequirement/supported``. Such account values are not collected during signup but may be added when + editing your account information later on. + +``AccountService``s are the central ``SpeziAccount`` component that is responsible for implementing user account +operations. ``AccountService``s can be manually provided via the ``AccountConfiguration/init(configuration:_:)`` initializer. +Otherwise, ``AccountService``s might be directly provided by other Spezi `Componet`s (like the `FirebaseAccountConfiguration`). + +> Note: A given ``AccountService`` implementation might only support storing a fixed set of account values (see ``SupportedAccountKeys``). + In those cases you may be required to supply your own ``AccountStorageStandard`` implementation + to handle storage of additional account values. Refer to the article for information. + +### Account Setup + +Now that we configured ``SpeziAccount``, let's see how users can set up their account within your app. + +Account setup is done through the ``AccountSetup`` view. It presents all configured ``AccountService``s. A user can choose one +``AccountService`` to setup their account. + +You should make sure to handle the case where there is already an active account setup when showing the ``AccountSetup`` view. +You can use the ``Account/signedIn`` property to conditionally hide or render another view if there is already a signed-in user account +(more information in ). Refer to the example below: + +```swift +struct MyView: View { + @EnvironmentObject var account: Account + + var body: some View { + if !account.signedIn { + AccountSetup() + } + } +} +``` + +Another scenario might be an Onboarding Flow where the user should be able to review the already signed-in user account. +In this case you should provide a `Continue` button using the `ViewBuilder` closure. This is shown in the code example below. + +```swift +struct MyView: View { + var body: some View { + AccountSetup { + NavigationLink { + // ... next view + } label: { + Text("Continue") + } + } + } +} +``` + +> Note: You can also customize the header text using the ``AccountSetup/init(continue:header:)`` initializer. + +### Account Overview + +The ``AccountOverview`` view can be used to view or modify the information of the currently logged-in user account. +You must make sure to display this view only if there is a signed-in user account (see ``Account/signedIn``). + +```swift +struct MyView: View { + var body: some View { + AccountOverview() + } +} +``` + +## Topics + +### Configuration + +- ``AccountConfiguration`` +- ``ConfiguredAccountKey`` +- ``AccountValueConfiguration`` +- ``AccountKeyRequirement`` +- ``AccountKeyConfiguration`` +- ``AccountValueConfigurationError`` + +### Views + +- ``AccountSetup`` +- ``AccountOverview`` diff --git a/Sources/SpeziAccount/SpeziAccount.docc/Setup Guides/Using the Account Object.md b/Sources/SpeziAccount/SpeziAccount.docc/Setup Guides/Using the Account Object.md new file mode 100644 index 00000000..dd2bf1f9 --- /dev/null +++ b/Sources/SpeziAccount/SpeziAccount.docc/Setup Guides/Using the Account Object.md @@ -0,0 +1,60 @@ +# Using the Account Object + +Summary + + + +Use the global `Account` object to access the current account state. + +## Overview + +You can use the ``Account`` object +that is injected into your App's view hierarchy as an environment object to access ``SpeziAccount`` state. +Particularly useful are the published properties ``Account/signedIn`` and ``Account/details`` to access the current account +state. + +Below is a short code example to access the global ``Account`` instance. +```swift +struct MyView: View { + @EnvironmentObject var account: Account + + var body: some View { + // ... use account + } +} +``` + +## Topics + +### Account and Account Details + +- ``Account`` +- ``Account/signedIn`` +- ``Account/details`` +- ``AccountDetails`` + +### Account Details + +Below is a list of built-in account details. Other frameworks might extend this list. + +- ``AccountDetails/userId`` +- ``AccountDetails/email`` +- ``AccountDetails/name`` +- ``AccountDetails/dateOfBrith`` +- ``AccountDetails/genderIdentity`` + + +### Accessing the Account Service +To access the currently active `AccountService` or its configuration you may want to use the following properties: + +- ``AccountDetails/accountService`` +- ``AccountDetails/accountServiceConfiguration`` +- ``AccountDetails/userIdType`` diff --git a/Sources/SpeziAccount/SpeziAccount.docc/SpeziAccount.md b/Sources/SpeziAccount/SpeziAccount.docc/SpeziAccount.md index 39fb9958..e1e76a68 100644 --- a/Sources/SpeziAccount/SpeziAccount.docc/SpeziAccount.md +++ b/Sources/SpeziAccount/SpeziAccount.docc/SpeziAccount.md @@ -1,149 +1,49 @@ # ``SpeziAccount`` +A Spezi framework that provides account-related functionality including login, sign up and password reset. + -Provides account-related functionalty including login, sign up, and reset password functionality. - -## The Account Actor - -The Account module provides several UI components that are powered using the ``Account/Account`` actor that must be injected into the SwiftUI environment. -Views like the ``Login`` and ``SignUp`` views use the ``Account/Account``'s ``AccountService``s provided in the initializer (``Account/Account/init(accountServices:)``) to -populate the views. - -More specialized views like the ``UsernamePasswordLoginView``, ``UsernamePasswordSignUpView``, ``UsernamePasswordResetPasswordView`` which are automatically provided if you use the -``Login`` view. It uses individual ``AccountService``s like the ``UsernamePasswordAccountService`` to populate the view content and react to user interactions. - -You can use the following example using a Spezi Component and the configuration as a mechanism to inject the ``Account/Account`` actor into the SwiftUI -environment. Alternatively, you can use the `environmentObject(_:)` view modifier to manually inject the ``Account/Account`` actor into the SwiftUI environment. - - -The following example shows a Spezi component that creates a `User` class/actor defined within the project to store user-related information and passes it down to an -`ExampleUsernamePasswordAccountService` ``AccountService`` that can then modify the `User` instance based on the login or sign up procedure. -The `Component` injects the `Account` and `User` instances into the SwiftUI environment so they can be used by SwiftUI views: -```swift -final class ExampleAccountConfiguration: Component, ObservableObjectProvider { - private let account: Account - private let user: User - - - var observableObjects: [any ObservableObject] { - [ - account, - user - ] - } - - - init() { - self.user = User() - let accountServices: [any AccountService] = [ - ExampleUsernamePasswordAccountService(user: user) - ] - self.account = Account(accountServices: accountServices) - } -} -``` - -The `ExampleAccountConfiguration` must be added to an instance of `SpeziAppDelegate` to inform Spezi about your configuration and allow Spezi -to take the `observableObjects`, including the ``Account/Account`` actor, into the SwiftUI environment to make it available for your custom views -and, e.g., the ``Login`` and ``SignUp`` views: - -```swift -class TestAppDelegate: SpeziAppDelegate { - override var configuration: Configuration { - Configuration { - ExampleAccountConfiguration() - // ... - } - } -} -``` - -## Account Views - -You can then access the ``Account/Account`` actor in any SwiftUI view. -``AccountService``s must set the ``Account/Account/signedIn`` property to true if the user is signed in, making it possible for your to change your UI based on the -user's sign in status. - -The following example shows an example view that uses the ``Account/Account`` actor to observe the user sign in state and offers the possibility to login or sign up -if the user is not signed in using a SwiftUI sheet to display the ``Login`` and ``SignUp`` views: -```swift -struct AccountExampleView: View { - @EnvironmentObject var account: Account - @State var showLogin = false - @State var showSignUp = false - - - var body: some View { - List { - if account.signedIn { - Text("You are signed in!") - } else { - Button("Login") { - showLogin.toggle() - } - Button("SignUp") { - showSignUp.toggle() - } - } - } - .sheet(isPresented: $showLogin) { - NavigationStack { - Login() - } - } - .sheet(isPresented: $showSignUp) { - NavigationStack { - SignUp() - } - } - .onChange(of: account.signedIn) { signedIn in - if signedIn { - showLogin = false - showSignUp = false - } - } - } -} -``` +## Overview -## Topics +The `SpeziAccount` framework fully abstracts setup and management of user account functionality for the +[Spezi](https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi) framework ecosystem. -### Account Services +> Note: The article provides a quick-start guide to setup `SpeziAccount` in your App. -- -- ``Account/AccountService`` -- ``Account/Account`` +The ``AccountSetup`` and ``AccountOverview`` views are central to `SpeziAccount`. +You use the ``AccountDetails`` abstraction within your views to visualize account information of the associated user account. -### Views +An ``AccountService`` provides an abstraction layer for managing different types of account management services +(e.g. email address and password based service combined with a identity provider like Sign in With Apple). -Views that can be used to provide login, sign up, and reset password flows using the defined ``Account/AccountService``s. +> Note: The [SpeziFirebase](https://swiftpackageindex.com/StanfordSpezi/SpeziFirebase/documentation/spezifirebaseaccount) + framework provides the `FirebaseAccountConfiguration` you can use to configure an Account Service base on the Google Firebase service. -- ``Account/SignUp`` -- ``Account/Login`` -### Username And Password Account Service +## Topics + +### Setup Guides -The ``UsernamePasswordAccountService`` provides a starting point for a username and password-based ``AccountService`` that can be subclassed or extended -to fit the need of the specific application. The ``UsernamePasswordSignUpView``, ``UsernamePasswordLoginView``, and ``UsernamePasswordResetPasswordView`` -all rely on the ``UsernamePasswordAccountService`` to be present in the SwiftUI environment. +- +- +- -- ``UsernamePasswordAccountService`` -- ``UsernamePasswordSignUpView`` -- ``UsernamePasswordLoginView`` -- ``UsernamePasswordResetPasswordView`` +### Account Values -### Email And Password Account Service +- +- +- -The ``EmailPasswordAccountService`` is a ``UsernamePasswordAccountService`` subclass providing email-related validation rules and -customized view buttons enabling the creation of an email and password-based ``AccountService``. +### Account Services -- ``EmailPasswordAccountService`` +- +- diff --git a/Sources/SpeziAccount/Username and Password/Localization/ConfigurableLocalization.swift b/Sources/SpeziAccount/Username and Password/Localization/ConfigurableLocalization.swift deleted file mode 100644 index ff1e35cd..00000000 --- a/Sources/SpeziAccount/Username and Password/Localization/ConfigurableLocalization.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// This source file is part of the Spezi open-source project -// -// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - - -/// Dependency injection mechanism for the localization of views. -/// -/// Configure a view in the ``Account/Account`` module to either use the localization injected in the SwiftUI environment -/// using an ``Account/AccountService`` (``Account/ConfigurableLocalization/environment``) -/// or using a provided value (``Account/ConfigurableLocalization/value(_:)``). -public enum ConfigurableLocalization { - /// Use the localization injected in the SwiftUI environment using an ``Account/AccountService``. - case environment - /// Use a manually specified localization ``Localization`` subtype (e.g. ``Localization/Login-swift.struct``). - case value(T) -} diff --git a/Sources/SpeziAccount/Username and Password/Localization/Localization+Login.swift b/Sources/SpeziAccount/Username and Password/Localization/Localization+Login.swift deleted file mode 100644 index e9c2d607..00000000 --- a/Sources/SpeziAccount/Username and Password/Localization/Localization+Login.swift +++ /dev/null @@ -1,86 +0,0 @@ -// -// This source file is part of the Spezi open-source project -// -// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -import Foundation -import SpeziViews - - -extension Localization { - /// Provides localization information for the login-related views in the Account module. - /// - /// The values passed into the ``Localization`` substructs are automatically interpreted according to the localization key mechanisms defined in the Spezi Views module. - /// - /// You can, e.g., only customize a specific value or all values that are available in the ``Localization/Login-swift.struct/init(buttonTitle:navigationTitle:username:password:loginActionButtonTitle:defaultLoginFailedError:)`` initializer. - /// - /// ```swift - /// Login( - /// navigationTitle: "CUSTOM_NAVIGATION_TITLE", - /// username: FieldLocalization( - /// title: "CUSTOM_USERNAME", - /// placeholder: "CUSTOM_USERNAME_PLACEHOLDER" - /// ) - /// ) - /// ``` - public struct Login: Codable { - /// A default configuration for providing localized text to login views. - public static let `default` = Login( - buttonTitle: LocalizedStringResource("UAP_LOGIN_BUTTON_TITLE", bundle: .atURL(from: .module)), - navigationTitle: LocalizedStringResource("UAP_LOGIN_NAVIGATION_TITLE", bundle: .atURL(from: .module)), - username: FieldLocalizationResource( - title: LocalizedStringResource("UAP_LOGIN_USERNAME_TITLE", bundle: .atURL(from: .module)), - placeholder: LocalizedStringResource("UAP_LOGIN_USERNAME_PLACEHOLDER", bundle: .atURL(from: .module)) - ), - password: FieldLocalizationResource( - title: LocalizedStringResource("UAP_LOGIN_PASSWORD_TITLE", bundle: .atURL(from: .module)), - placeholder: LocalizedStringResource("UAP_LOGIN_PASSWORD_PLACEHOLDER", bundle: .atURL(from: .module)) - ), - loginActionButtonTitle: LocalizedStringResource("UAP_LOGIN_ACTION_BUTTON_TITLE", bundle: .atURL(from: .module)), - defaultLoginFailedError: LocalizedStringResource("UAP_LOGIN_FAILED_DEFAULT_ERROR", bundle: .atURL(from: .module)) - ) - - - /// A localized `LocalizedStringResource` to display on the login button. - public let buttonTitle: LocalizedStringResource - /// A localized `LocalizedStringResource` for login view's navigation title. - public let navigationTitle: LocalizedStringResource - /// A `FieldLocalization` instance containing the localized title and placeholder text for the username field. - public let username: FieldLocalizationResource - /// A `FieldLocalization` instance containing the localized title and placeholder text for the password field. - public let password: FieldLocalizationResource - /// A localized `LocalizedStringResource` to display on the login action button. - public let loginActionButtonTitle: LocalizedStringResource - /// A localized`LocalizedStringResource` error message to be displayed when login fails. - public let defaultLoginFailedError: LocalizedStringResource - - - /// Creates a localization configuration for login views. - /// - /// - Parameters: - /// - buttonTitle: A localized `LocalizedStringResource` to display on the login button. - /// - navigationTitle: A localized `LocalizedStringResource` for the login view's navigation title. - /// - username: A `FieldLocalization` instance containing the localized title and placeholder text for the username field. - /// - password: A `FieldLocalization` instance containing the localized title and placeholder text for the password field. - /// - loginActionButtonTitle: A localized `LocalizedStringResource` to display on the login action button. - /// - defaultLoginFailedError: A localized `LocalizedStringResource` error message to be displayed when login fails. - public init( - buttonTitle: LocalizedStringResource = Login.default.buttonTitle, - navigationTitle: LocalizedStringResource = Login.default.navigationTitle, - username: FieldLocalizationResource = Login.default.username, - password: FieldLocalizationResource = Login.default.password, - loginActionButtonTitle: LocalizedStringResource = Login.default.loginActionButtonTitle, - defaultLoginFailedError: LocalizedStringResource = Login.default.defaultLoginFailedError - ) { - self.buttonTitle = buttonTitle - self.navigationTitle = navigationTitle - self.username = username - self.password = password - self.loginActionButtonTitle = loginActionButtonTitle - self.defaultLoginFailedError = defaultLoginFailedError - } - } -} diff --git a/Sources/SpeziAccount/Username and Password/Localization/Localization+ResetPassword.swift b/Sources/SpeziAccount/Username and Password/Localization/Localization+ResetPassword.swift deleted file mode 100644 index 402713a1..00000000 --- a/Sources/SpeziAccount/Username and Password/Localization/Localization+ResetPassword.swift +++ /dev/null @@ -1,83 +0,0 @@ -// -// This source file is part of the Spezi open-source project -// -// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -import Foundation -import SpeziViews - - -extension Localization { - /// Provides localization information for the reset password-related views in the Accont module. - /// - /// The values passed into the ``Localization`` substructs are automatically interpreted according to the localization key mechanisms defined in the Spezi Views module. - /// - /// You can, e.g., only customize a specific value or all values that are available in the ``Localization/ResetPassword-swift.struct/init(buttonTitle:navigationTitle:username:resetPasswordActionButtonTitle:processSuccessfulLabel:defaultResetPasswordFailedError:)`` initializer. - /// - /// ```swift - /// ResetPassword( - /// navigationTitle: "CUSTOM_NAVIGATION_TITLE", - /// username: FieldLocalizationResource( - /// title: "CUSTOM_USERNAME", - /// placeholder: "CUSTOM_USERNAME_PLACEHOLDER" - /// ) - /// ) - /// ``` - public struct ResetPassword: Codable { - /// A default configuration for providing localized text to reset password views - public static let `default` = ResetPassword( - buttonTitle: LocalizedStringResource("UAP_RESET_PASSWORD_BUTTON_TITLE", bundle: .atURL(from: .module)), - navigationTitle: LocalizedStringResource("UAP_RESET_PASSWORD_NAVIGATION_TITLE", bundle: .atURL(from: .module)), - username: FieldLocalizationResource( - title: LocalizedStringResource("UAP_RESET_PASSWORD_USERNAME_TITLE", bundle: .atURL(from: .module)), - placeholder: LocalizedStringResource("UAP_RESET_PASSWORD_USERNAME_PLACEHOLDER", bundle: .atURL(from: .module)) - ), - resetPasswordActionButtonTitle: LocalizedStringResource("UAP_RESET_PASSWORD_ACTION_BUTTON_TITLE", bundle: .atURL(from: .module)), - processSuccessfulLabel: LocalizedStringResource("UAP_RESET_PASSWORD_PROCESS_SUCCESSFUL_LABEL", bundle: .atURL(from: .module)), - defaultResetPasswordFailedError: LocalizedStringResource("UAP_RESET_PASSWORD_FAILED_DEFAULT_ERROR", bundle: .atURL(from: .module)) - ) - - - /// A localized `LocalizedStringResource` to display on the reset password button. - public let buttonTitle: LocalizedStringResource - /// A localized `LocalizedStringResource` for the reset password view's navigation title. - public let navigationTitle: LocalizedStringResource - /// A `FieldLocalizationResource` instance containing the localized title and placeholder text for the username field. - public let username: FieldLocalizationResource - /// A localized `LocalizedStringResource` to display on the reset password action button. - public let resetPasswordActionButtonTitle: LocalizedStringResource - /// A localized `LocalizedStringResource` to display when the reset password process has been successful. - public let processSuccessfulLabel: LocalizedStringResource - /// A localized `LocalizedStringResource` to display when the reset password process has failed. - public let defaultResetPasswordFailedError: LocalizedStringResource - - - /// Creates a localization configuration for reset password views. - /// - /// - Parameters: - /// - buttonTitle: A localized `LocalizedStringResource` title for the reset password button. - /// - navigationTitle: A localized `LocalizedStringResource` for the reset password view's navigation title. - /// - username: A `FieldLocalizationResource` instance containing the localized title and placeholder text for the username field. - /// - resetPasswordActionbuttonTitle: A localized `LocalizedStringResource` to display on the reset password action button. - /// - processSuccessfulLabel: A localized `LocalizedStringResource` to display when the reset password process has been successful. - /// - defaultResetPasswordFailedError: A localized `LocalizedStringResource` to display when the reset password process has failed. - public init( - buttonTitle: LocalizedStringResource = ResetPassword.default.buttonTitle, - navigationTitle: LocalizedStringResource = ResetPassword.default.navigationTitle, - username: FieldLocalizationResource = ResetPassword.default.username, - resetPasswordActionButtonTitle: LocalizedStringResource = ResetPassword.default.resetPasswordActionButtonTitle, - processSuccessfulLabel: LocalizedStringResource = ResetPassword.default.processSuccessfulLabel, - defaultResetPasswordFailedError: LocalizedStringResource = ResetPassword.default.defaultResetPasswordFailedError - ) { - self.buttonTitle = buttonTitle - self.navigationTitle = navigationTitle - self.username = username - self.resetPasswordActionButtonTitle = resetPasswordActionButtonTitle - self.processSuccessfulLabel = processSuccessfulLabel - self.defaultResetPasswordFailedError = defaultResetPasswordFailedError - } - } -} diff --git a/Sources/SpeziAccount/Username and Password/Localization/Localization+SignUp.swift b/Sources/SpeziAccount/Username and Password/Localization/Localization+SignUp.swift deleted file mode 100644 index a67b135d..00000000 --- a/Sources/SpeziAccount/Username and Password/Localization/Localization+SignUp.swift +++ /dev/null @@ -1,131 +0,0 @@ -// -// This source file is part of the Spezi open-source project -// -// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -import Foundation -import SpeziViews - - -extension Localization { - /// Provides localization information for the sign up-related views in the Accont module. - /// - /// The values passed into the ``Localization`` substructs are automatically interpreted according to the localization key mechanisms defined in the Spezi Views module. - /// - /// You can, e.g., only customize a specific value or all values that are available in the ``Localization/SignUp-swift.struct/init(buttonTitle:navigationTitle:username:password:passwordRepeat:passwordNotEqualError:givenName:familyName:genderIdentityTitle:dateOfBirthTitle:signUpActionButtonTitle:defaultSignUpFailedError:)`` initializer. - /// - /// ```swift - /// SignUp( - /// navigationTitle: "CUSTOM_NAVIGATION_TITLE", - /// username: FieldLocalizationResource( - /// title: "CUSTOM_USERNAME", - /// placeholder: "CUSTOM_USERNAME_PLACEHOLDER" - /// ) - /// ) - /// ``` - public struct SignUp: Codable { - /// A default configuration for providing localized text to sign up views. - public static let `default` = SignUp( - buttonTitle: LocalizedStringResource("UAP_SIGNUP_BUTTION_TITLE", bundle: .atURL(from: .module)), - navigationTitle: LocalizedStringResource("UAP_SIGNUP_NAVIGATION_TITLE", bundle: .atURL(from: .module)), - username: FieldLocalizationResource( - title: LocalizedStringResource("UAP_SIGNUP_USERNAME_TITLE", bundle: .atURL(from: .module)), - placeholder: LocalizedStringResource("UAP_SIGNUP_USERNAME_PLACEHOLDER", bundle: .atURL(from: .module)) - ), - password: FieldLocalizationResource( - title: LocalizedStringResource("UAP_SIGNUP_PASSWORD_TITLE", bundle: .atURL(from: .module)), - placeholder: LocalizedStringResource("UAP_SIGNUP_PASSWORD_PLACEHOLDER", bundle: .atURL(from: .module)) - ), - passwordRepeat: FieldLocalizationResource( - title: LocalizedStringResource("UAP_SIGNUP_PASSWORD_REPEAT_TITLE", bundle: .atURL(from: .module)), - placeholder: LocalizedStringResource("UAP_SIGNUP_PASSWORD_REPEAT_PLACEHOLDER", bundle: .atURL(from: .module)) - ), - passwordNotEqualError: LocalizedStringResource("UAP_SIGNUP_PASSWORD_NOT_EQUAL_ERROR", bundle: .atURL(from: .module)), - givenName: FieldLocalizationResource( - title: LocalizedStringResource("UAP_SIGNUP_GIVEN_NAME_TITLE", bundle: .atURL(from: .module)), - placeholder: LocalizedStringResource("UAP_SIGNUP_GIVEN_NAME_PLACEHOLDER", bundle: .atURL(from: .module)) - ), - familyName: FieldLocalizationResource( - title: LocalizedStringResource("UAP_SIGNUP_FAMILY_NAME_TITLE", bundle: .atURL(from: .module)), - placeholder: LocalizedStringResource("UAP_SIGNUP_FAMILY_NAME_PLACEHOLDER", bundle: .atURL(from: .module)) - ), - genderIdentityTitle: LocalizedStringResource("UAP_SIGNUP_GENDER_IDENTITY_TITLE", bundle: .atURL(from: .module)), - dateOfBirthTitle: LocalizedStringResource("UAP_SIGNUP_DATE_OF_BIRTH_TITLE", bundle: .atURL(from: .module)), - signUpActionButtonTitle: LocalizedStringResource("UAP_SIGNUP_ACTION_BUTTON_TITLE", bundle: .atURL(from: .module)), - defaultSignUpFailedError: LocalizedStringResource("UAP_SIGNUP_FAILED_DEFAULT_ERROR", bundle: .atURL(from: .module)) - ) - - - /// A localized `LocalizedStringResource` to display on the sign up button. - public let buttonTitle: LocalizedStringResource - /// A localized `LocalizedStringResource` for sign up view's localized navigation title. - public let navigationTitle: LocalizedStringResource - /// A `FieldLocalizationResource` instance containing the localized title and placeholder text for the username field. - public let username: FieldLocalizationResource - /// A `FieldLocalizationResource` instance containing the localized title and placeholder text for the password field. - public let password: FieldLocalizationResource - /// A `FieldLocalizationResource` instance containing the localized title and placeholder text for the password repeat field. - public let passwordRepeat: FieldLocalizationResource - /// A localized`LocalizedStringResource` error message to be displayed when the text in the password and password repeat fields are not equal. - public let passwordNotEqualError: LocalizedStringResource - /// A `FieldLocalizationResource` instance containing the localized title and placeholder text for the given name (first name) field. - public let givenName: FieldLocalizationResource - /// A `FieldLocalizationResource` instance containing the localized title and placeholder text for the family name (last name) field. - public let familyName: FieldLocalizationResource - /// A localized `LocalizedStringResource` label for the gender identity field. - public let genderIdentityTitle: LocalizedStringResource - /// A localized `LocalizedStringResource` label for the date of birth field. - public let dateOfBirthTitle: LocalizedStringResource - /// A localized `LocalizedStringResource` title for the sign up action button. - public let signUpActionButtonTitle: LocalizedStringResource - /// A localized `LocalizedStringResource` message to display when sign up fails. - public let defaultSignUpFailedError: LocalizedStringResource - - - /// Creates a localization configuration for signup views. - /// - /// - Parameters: - /// - buttonTitle: A localized `LocalizedStringResource` to display on the sign up button. - /// - navigationTitle: A localized `LocalizedStringResource` for sign up view's localized navigation title. - /// - username: A `FieldLocalizationResource` instance containing the localized title and placeholder text for the username field. - /// - password: A `FieldLocalizationResource` instance containing the localized title and placeholder text for the password field. - /// - passwordRepeat: A `FieldLocalizationResource` instance containing the localized title and placeholder text for the password repeat field. - /// - passwordNotEqualError: A localized`LocalizedStringResource` error message to be displayed when the text in the password and password repeat fields are not equal. - /// - givenName: A `FieldLocalizationResource` instance containing the localized title and placeholder text for the given name (first name) field. - /// - familyName: A `FieldLocalizationResource` instance containing the localized title and placeholder text for the family name (last name) field. - /// - genderIdentityTitle: A localized `LocalizedStringResource` label for the gender identity field. - /// - dateOfBirthTitle: A localized `LocalizedStringResource` label for the date of birth field. - /// - signUpActionButtonTitle: A localized `LocalizedStringResource` title for the sign up action button. - /// - defaultSignUpFailedError: A localized `LocalizedStringResource` message to display when sign up fails. - public init( - buttonTitle: LocalizedStringResource = SignUp.default.buttonTitle, - navigationTitle: LocalizedStringResource = SignUp.default.navigationTitle, - username: FieldLocalizationResource = SignUp.default.username, - password: FieldLocalizationResource = SignUp.default.password, - passwordRepeat: FieldLocalizationResource = SignUp.default.passwordRepeat, - passwordNotEqualError: LocalizedStringResource = SignUp.default.passwordNotEqualError, - givenName: FieldLocalizationResource = SignUp.default.givenName, - familyName: FieldLocalizationResource = SignUp.default.familyName, - genderIdentityTitle: LocalizedStringResource = SignUp.default.genderIdentityTitle, - dateOfBirthTitle: LocalizedStringResource = SignUp.default.dateOfBirthTitle, - signUpActionButtonTitle: LocalizedStringResource = SignUp.default.signUpActionButtonTitle, - defaultSignUpFailedError: LocalizedStringResource = SignUp.default.defaultSignUpFailedError - ) { - self.buttonTitle = buttonTitle - self.navigationTitle = navigationTitle - self.username = username - self.password = password - self.passwordRepeat = passwordRepeat - self.passwordNotEqualError = passwordNotEqualError - self.givenName = givenName - self.familyName = familyName - self.genderIdentityTitle = genderIdentityTitle - self.dateOfBirthTitle = dateOfBirthTitle - self.signUpActionButtonTitle = signUpActionButtonTitle - self.defaultSignUpFailedError = defaultSignUpFailedError - } - } -} diff --git a/Sources/SpeziAccount/Username and Password/Localization/Localization.swift b/Sources/SpeziAccount/Username and Password/Localization/Localization.swift deleted file mode 100644 index bfcff1a7..00000000 --- a/Sources/SpeziAccount/Username and Password/Localization/Localization.swift +++ /dev/null @@ -1,59 +0,0 @@ -// -// This source file is part of the Spezi open-source project -// -// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - - -import SpeziViews - - -/// Defines the localization for the ``Account/Account`` module that can be used to customize the ``Account/Account`` module-related views. -/// -/// The values passed into the ``Localization`` substructs are automatically interpreted according to the localization key mechanisms defined in the Spezi Views module. -/// -/// The ``Localization`` initializer provides default values in localizations provided out-of-the-box by the ``Account/Account`` module. -/// Each substruct (``Localization/Login-swift.struct``, ``Localization/SignUp-swift.struct``, ``Localization/ResetPassword-swift.struct``) uses -/// these default values and can be customized by, e.g., only modifying a specific property: -/// ```swift -/// let localization = Localization( -/// login: Localization.Login( -/// navigationTitle: "CUSTOM_NAVIGATION_TITLE" -/// ) -/// ) -/// ``` -public struct Localization: Codable { - /// Defines the default localization. - public static let `default` = Localization( - login: Login.default, - signUp: SignUp.default, - resetPassword: ResetPassword.default - ) - - - /// Localization for login views. - public let login: Login - /// Localization for sign up views. - public let signUp: SignUp - /// Localization for reset password views. - public let resetPassword: ResetPassword - - - /// Creates a new localization with configurations for login, sign up, and reset password views. - /// - /// - Parameters: - /// - login: An instance of ``Localization/Login-swift.struct``, a configuration for localizing login views. - /// - signUp: An instance of ``Localization/SignUp-swift.struct``, a configuration for localizing signup views. - /// - resetPassword: An instance of ``Localization/ResetPassword-swift.struct``, a configuration for localizing reset password views. - public init( - login: Login = Localization.default.login, - signUp: SignUp = Localization.default.signUp, - resetPassword: ResetPassword = Localization.default.resetPassword - ) { - self.login = login - self.signUp = signUp - self.resetPassword = resetPassword - } -} diff --git a/Sources/SpeziAccount/Username and Password/Login/UsernamePasswordLoginView.swift b/Sources/SpeziAccount/Username and Password/Login/UsernamePasswordLoginView.swift deleted file mode 100644 index 96f5766e..00000000 --- a/Sources/SpeziAccount/Username and Password/Login/UsernamePasswordLoginView.swift +++ /dev/null @@ -1,174 +0,0 @@ -// -// This source file is part of the Spezi open-source project -// -// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -import SwiftUI - - -/// Displays a login view allowing a user to enter a username and password. -/// -/// Enables ``AccountService``s such as the ``UsernamePasswordAccountService`` to -/// display a user interface allowing users to login with a username and password. -/// -/// The ``Login`` view automatically displays login buttons of all configured ``AccountService``s and is the recommended way to automatically constuct a login flow for different ``AccountService``s. -/// -/// Nevertheless, the ``UsernamePasswordLoginView`` can also be used to display the login view in a custom login flow. -/// Applications must ensure that an ``UsernamePasswordAccountService`` instance is injected in the SwiftUI environment by, e.g., using the `.environmentObject(_:)` view modifier. -/// -/// The view can automatically validate input using passed in ``ValidationRule``s and can be customized using header or footer views: -/// ```swift -/// UsernamePasswordLoginView( -/// passwordValidationRules: [ -/// /* ... */ -/// ], -/// header: { -/// Text("A Header View ...") -/// }, -/// footer: { -/// Text("A Footer View ...") -/// } -/// ) -/// .environmentObject(UsernamePasswordAccountService()) -/// ``` -public struct UsernamePasswordLoginView: View { - private let usernameValidationRules: [ValidationRule] - private let passwordValidationRules: [ValidationRule] - private let header: AnyView - private let footer: AnyView - - @EnvironmentObject private var usernamePasswordAccountService: UsernamePasswordAccountService - - @State private var username: String = "" - @State private var password: String = "" - @State private var valid = false - @FocusState private var focusedField: AccountInputFields? - - private let localization: ConfigurableLocalization - - - public var body: some View { - ScrollView { - DataEntryAccountView( - buttonTitle: loginButtonTitleLocalization, - defaultError: defaultLoginFailedError, - focusState: _focusedField, - valid: $valid, - buttonPressed: { - try await usernamePasswordAccountService.login(username: username, password: password) - }, - content: { - header - Divider() - usernamePasswordSection - Divider() - usernamePasswordAccountService.resetPasswordButton - }, - footer: { - footer - } - ) - footer - } - .navigationTitle(navigationTitle.localizedString()) - } - - private var usernamePasswordSection: some View { - Grid(horizontalSpacing: 16, verticalSpacing: 16) { - switch localization { - case .environment: - UsernamePasswordFields( - username: $username, - password: $password, - valid: $valid, - focusState: _focusedField, - usernameValidationRules: usernameValidationRules, - passwordValidationRules: passwordValidationRules, - presentationType: .login(.environment) - ) - case let .value(login): - UsernamePasswordFields( - username: $username, - password: $password, - valid: $valid, - focusState: _focusedField, - usernameValidationRules: usernameValidationRules, - passwordValidationRules: passwordValidationRules, - presentationType: .login( - .value( - ( - login.username, - login.password - ) - ) - ) - ) - } - } - .padding(.leading, 16) - .padding(.vertical, 12) - } - - private var loginButtonTitleLocalization: LocalizedStringResource { - switch localization { - case .environment: - return usernamePasswordAccountService.localization.login.loginActionButtonTitle - case let .value(login): - return login.loginActionButtonTitle - } - } - - private var navigationTitle: LocalizedStringResource { - switch localization { - case .environment: - return usernamePasswordAccountService.localization.login.navigationTitle - case let .value(login): - return login.navigationTitle - } - } - - private var defaultLoginFailedError: LocalizedStringResource { - switch localization { - case .environment: - return usernamePasswordAccountService.localization.login.defaultLoginFailedError - case let .value(resetPassword): - return resetPassword.defaultLoginFailedError - } - } - - - /// - Parameters: - /// - usernameValidationRules: An collection of ``ValidationRule``s to validate to the entered username. - /// - passwordValidationRules: An collection of ``ValidationRule``s to validate to the entered password. - /// - header: A SwiftUI `View` to display as a header. - /// - footer: A SwiftUI `View` to display as a footer. - /// - localization: A ``ConfigurableLocalization`` to define the localization of the ``UsernamePasswordLoginView``. The default value uses the localization provided by the ``UsernamePasswordAccountService`` provided in the SwiftUI environment. - public init( - usernameValidationRules: [ValidationRule] = [], - passwordValidationRules: [ValidationRule] = [], - @ViewBuilder header: () -> Header = { EmptyView() }, - @ViewBuilder footer: () -> Footer = { EmptyView() }, - localization: ConfigurableLocalization = .environment - ) { - self.usernameValidationRules = usernameValidationRules - self.passwordValidationRules = passwordValidationRules - self.header = AnyView(header()) - self.footer = AnyView(footer()) - self.localization = localization - } -} - - -#if DEBUG -struct UsernamePasswordLoginView_Previews: PreviewProvider { - static var previews: some View { - NavigationStack { - UsernamePasswordLoginView() - .environmentObject(UsernamePasswordAccountService()) - } - } -} -#endif diff --git a/Sources/SpeziAccount/Username and Password/ResetPassword/UsernamePasswordResetPasswordView.swift b/Sources/SpeziAccount/Username and Password/ResetPassword/UsernamePasswordResetPasswordView.swift deleted file mode 100644 index bc220189..00000000 --- a/Sources/SpeziAccount/Username and Password/ResetPassword/UsernamePasswordResetPasswordView.swift +++ /dev/null @@ -1,181 +0,0 @@ -// -// This source file is part of the Spezi open-source project -// -// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -import SpeziViews -import SwiftUI - -/// Displays a reset password view allowing a user to enter a username. -/// -/// Enables ``AccountService``s such as the ``UsernamePasswordAccountService`` to -/// display a user interface allowing users to start the reset password workflow. -/// -/// If the password is successfully reset, the view passed as the `processSuccessfulView` into the ``UsernamePasswordResetPasswordView/init(usernameValidationRules:header:footer:processSuccessfulView:localization:)`` initializer is displayed. -/// -/// Applications must ensure that an ``UsernamePasswordAccountService`` instance is injected in the SwiftUI environment by, e.g., using the `.environmentObject(_:)` view modifier. -/// -/// The view can automatically validate input using passed in ``ValidationRule``s and can be customized using header or footer views: -/// ```swift -/// UsernamePasswordResetPasswordView( -/// usernameValidationRules: [ -/// /* ... */ -/// ], -/// header: { -/// Text("A Header View ...") -/// }, -/// footer: { -/// Text("A Header View ...") -/// }, -/// processSuccessfulView: { -/// Text("The an email to reset the password has been sent out.") -/// } -/// ) -/// .environmentObject(UsernamePasswordAccountService()) -/// ``` -public struct UsernamePasswordResetPasswordView: View { - private let usernameValidationRules: [ValidationRule] - private let header: AnyView - private let footer: AnyView - private let processSuccessfulView: AnyView - - @EnvironmentObject private var usernamePasswordAccountService: UsernamePasswordAccountService - - @State private var username: String = "" - @State private var valid = false - @State private var processSuccess = false - @FocusState private var focusedField: AccountInputFields? - - private let localization: ConfigurableLocalization - - - public var body: some View { - ScrollView { - if processSuccess { - processSuccessfulView - } else { - DataEntryAccountView( - buttonTitle: resetPasswordButtonTitleLocalization, - defaultError: defaultResetPasswordFailedError, - focusState: _focusedField, - valid: $valid, - buttonPressed: { - try await usernamePasswordAccountService.resetPassword(username: username) - withAnimation(.easeOut(duration: 0.5)) { - processSuccess = true - } - try await Task.sleep(for: .seconds(0.6)) - }, - content: { - header - Divider() - usernameTextField - Divider() - }, - footer: { - footer - } - ) - } - } - .navigationTitle(navigationTitle.localizedString()) - } - - private var usernameTextField: some View { - let usernameLocalization: FieldLocalizationResource - switch localization { - case .environment: - usernameLocalization = usernamePasswordAccountService.localization.resetPassword.username - case let .value(resetPassword): - usernameLocalization = resetPassword.username - } - - return Grid(horizontalSpacing: 16, verticalSpacing: 16) { - VerifiableTextFieldGridRow( - text: $username, - valid: $valid, - validationRules: usernameValidationRules, - description: { - Text(usernameLocalization.title) - }, - textField: { binding in - TextField(text: binding) { - Text(usernameLocalization.placeholder) - } - .autocorrectionDisabled(true) - .textInputAutocapitalization(.never) - .keyboardType(.emailAddress) - .textContentType(.username) - } - ) - .onTapFocus(focusedField: _focusedField, fieldIdentifier: .username) - } - .padding(.leading, 16) - .padding(.vertical, 12) - } - - private var resetPasswordButtonTitleLocalization: LocalizedStringResource { - switch localization { - case .environment: - return usernamePasswordAccountService.localization.resetPassword.resetPasswordActionButtonTitle - case let .value(resetPassword): - return resetPassword.resetPasswordActionButtonTitle - } - } - - private var navigationTitle: LocalizedStringResource { - switch localization { - case .environment: - return usernamePasswordAccountService.localization.resetPassword.navigationTitle - case let .value(resetPassword): - return resetPassword.navigationTitle - } - } - - private var defaultResetPasswordFailedError: LocalizedStringResource { - switch localization { - case .environment: - return usernamePasswordAccountService.localization.resetPassword.defaultResetPasswordFailedError - case let .value(resetPassword): - return resetPassword.defaultResetPasswordFailedError - } - } - - - /// - Parameters: - /// - usernameValidationRules: An collection of ``ValidationRule``s to validete the entered username. - /// - header: A SwiftUI `View` to display as a header. - /// - footer: A SwiftUI `View` to display as a footer. - /// - processSuccessfulView: A SwiftUI `View` to display if the password has been successfully reset. - /// - localization: A ``ConfigurableLocalization`` to define the localization of the ``UsernamePasswordResetPasswordView``. The default value uses the localization provided by the ``UsernamePasswordAccountService`` provided in the SwiftUI environment. - public init( - usernameValidationRules: [ValidationRule] = [], - @ViewBuilder header: () -> Header = { EmptyView() }, - @ViewBuilder footer: () -> Footer = { EmptyView() }, - @ViewBuilder processSuccessfulView: () -> ProcessSuccessful, - localization: ConfigurableLocalization = .environment - ) { - self.usernameValidationRules = usernameValidationRules - self.header = AnyView(header()) - self.footer = AnyView(footer()) - self.processSuccessfulView = AnyView(processSuccessfulView()) - self.localization = localization - } -} - - -#if DEBUG -struct UsernamePasswordResetPasswordView_Previews: PreviewProvider { - static var previews: some View { - NavigationStack { - UsernamePasswordResetPasswordView { - Text("Sucessfully sent a link to reset the password ...") - } - .environmentObject(UsernamePasswordAccountService()) - } - } -} -#endif diff --git a/Sources/SpeziAccount/Username and Password/Shared/AccountInputFields.swift b/Sources/SpeziAccount/Username and Password/Shared/AccountInputFields.swift deleted file mode 100644 index a9851806..00000000 --- a/Sources/SpeziAccount/Username and Password/Shared/AccountInputFields.swift +++ /dev/null @@ -1,19 +0,0 @@ -// -// This source file is part of the Spezi open-source project -// -// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - - -enum AccountInputFields: Hashable { - case username - case password - case passwordRepeat - case givenName - case familyName - case genderIdentity - case dateOfBirth - case phoneNumber -} diff --git a/Sources/SpeziAccount/Username and Password/Shared/DataEntryAccountView.swift b/Sources/SpeziAccount/Username and Password/Shared/DataEntryAccountView.swift deleted file mode 100644 index 49f43305..00000000 --- a/Sources/SpeziAccount/Username and Password/Shared/DataEntryAccountView.swift +++ /dev/null @@ -1,125 +0,0 @@ -// -// This source file is part of the Spezi open-source project -// -// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -import SpeziViews -import SwiftUI - - -struct DataEntryAccountView: View { - private let content: AnyView - private let buttonTitle: LocalizedStringResource - private let buttonPressed: () async throws -> Void - private let footer: AnyView - private let defaultError: LocalizedStringResource - - @Binding private var valid: Bool - @FocusState private var focusedField: AccountInputFields? - @State private var state: ViewState = .idle - - - var body: some View { - ScrollView { - content - resetPasswordButton - footer - } - .navigationBarBackButtonHidden(state == .processing) - .onTapGesture { - focusedField = nil - } - .viewStateAlert(state: $state) - } - - - private var resetPasswordButton: some View { - let resetPasswordButtonDisabled = state == .processing || !valid - - return Button(action: resetPasswordButtonPressed) { - Text(buttonTitle) - .padding(6) - .frame(maxWidth: .infinity) - .opacity(state == .processing ? 0.0 : 1.0) - .overlay { - if state == .processing { - ProgressView() - } - } - } - .buttonStyle(.borderedProminent) - .disabled(resetPasswordButtonDisabled) - .padding() - } - - - init( - buttonTitle: LocalizedStringResource, - defaultError: LocalizedStringResource, - focusState: FocusState = FocusState(), - valid: Binding = .constant(true), - buttonPressed: @escaping () async throws -> Void, - @ViewBuilder content: () -> Content, - @ViewBuilder footer: () -> Footer = { EmptyView() } - ) { - self.buttonTitle = buttonTitle - self._focusedField = focusState - self._valid = valid - self.buttonPressed = buttonPressed - self.defaultError = defaultError - self.content = AnyView(content()) - self.footer = AnyView(footer()) - } - - - private func resetPasswordButtonPressed() { - guard !(state == .processing) else { - return - } - - withAnimation(.easeOut(duration: 0.2)) { - focusedField = .none - state = .processing - } - - Task { - do { - try await buttonPressed() - withAnimation(.easeIn(duration: 0.2)) { - state = .idle - } - } catch { - state = .error( - AnyLocalizedError( - error: error, - defaultErrorDescription: defaultError - ) - ) - } - } - } -} - - -#if DEBUG -struct DataEntryView_Previews: PreviewProvider { - static var previews: some View { - NavigationStack { - DataEntryAccountView( - buttonTitle: "Test", - defaultError: "Error", - buttonPressed: { - try await Task.sleep(for: .seconds(2)) - print("Pressed!") - } - ) { - Text("Content ...") - } - .environmentObject(UsernamePasswordAccountService()) - } - } -} -#endif diff --git a/Sources/SpeziAccount/Username and Password/Shared/UsernamePasswordFields.swift b/Sources/SpeziAccount/Username and Password/Shared/UsernamePasswordFields.swift deleted file mode 100644 index c29cbc54..00000000 --- a/Sources/SpeziAccount/Username and Password/Shared/UsernamePasswordFields.swift +++ /dev/null @@ -1,334 +0,0 @@ -// -// This source file is part of the Spezi open-source project -// -// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -import SpeziViews -import SwiftUI - - -struct UsernamePasswordFields: View { - enum PresentationType { - case login(ConfigurableLocalization<( - username: FieldLocalizationResource, - password: FieldLocalizationResource - )>) - // We do not introduce an explicit type for the temporary usage of the localization fields. - // swiftlint:disable:next large_tuple - case signUp(ConfigurableLocalization<( - username: FieldLocalizationResource, - password: FieldLocalizationResource, - passwordRepeat: FieldLocalizationResource, - passwordNotEqualError: LocalizedStringResource - )>) - - - var login: Bool { - if case .login = self { - return true - } else { - return false - } - } - - var signUp: Bool { - if case .signUp = self { - return true - } else { - return false - } - } - - - var username: FieldLocalizationResource? { - switch self { - case let .login(.value((username, _))), let .signUp(.value((username, _, _, _))): - return username - default: - return nil - } - } - - var password: FieldLocalizationResource? { - switch self { - case let .login(.value((_, password))), let .signUp(.value((_, password, _, _))): - return password - default: - return nil - } - } - - var passwordRepeat: FieldLocalizationResource? { - switch self { - case let .signUp(.value((_, _, passwordRepeat, _))): - return passwordRepeat - default: - return nil - } - } - - var passwordNotEqualError: LocalizedStringResource? { - switch self { - case let .signUp(.value((_, _, _, passwordNotEqualError))): - return passwordNotEqualError - default: - return nil - } - } - } - - - private let usernameValidationRules: [ValidationRule] - private let passwordValidationRules: [ValidationRule] - private let presentationType: PresentationType - - @FocusState var focusedField: AccountInputFields? - - @EnvironmentObject private var usernamePasswordLoginService: UsernamePasswordAccountService - - @Binding private var valid: Bool - @Binding private var username: String - @Binding private var password: String - - @State private var passwordRepeat: String = "" - - @State private var usernameValid = false - @State private var passwordValid = false - @State private var passwordRepeatValid = false - - - var body: some View { - Group { - usernameTextField - Divider() - passwordSecureField - if presentationType.signUp { - Divider() - passwordRepeatSecureField - } - } - .onChange(of: usernameValid) { _ in - updateValid() - } - .onChange(of: passwordValid) { _ in - updateValid() - } - .onChange(of: passwordRepeatValid) { _ in - updateValid() - } - .onChange(of: password) { _ in - updateValid() - } - .onChange(of: passwordRepeat) { _ in - updateValid() - } - } - - private var usernameTextField: some View { - let usernameLocalization: FieldLocalizationResource - if let username = presentationType.username { - usernameLocalization = username - } else { - switch presentationType { - case .login: - usernameLocalization = usernamePasswordLoginService.localization.login.username - case .signUp: - usernameLocalization = usernamePasswordLoginService.localization.signUp.username - } - } - - return VerifiableTextFieldGridRow( - text: $username, - valid: $usernameValid, - validationRules: usernameValidationRules, - description: { - Text(usernameLocalization.title) - }, - textField: { binding in - TextField(text: binding) { - Text(usernameLocalization.placeholder) - } - .autocorrectionDisabled(true) - .textInputAutocapitalization(.never) - .keyboardType(.emailAddress) - .textContentType(.username) - } - ) - .onTapFocus(focusedField: _focusedField, fieldIdentifier: .username) - } - - private var passwordSecureField: some View { - let passwordLocalization: FieldLocalizationResource - if let password = presentationType.password { - passwordLocalization = password - } else { - switch presentationType { - case .login: - passwordLocalization = usernamePasswordLoginService.localization.login.password - case .signUp: - passwordLocalization = usernamePasswordLoginService.localization.signUp.password - } - } - - return VerifiableTextFieldGridRow( - text: $password, - valid: $passwordValid, - validationRules: passwordValidationRules, - description: { - Text(passwordLocalization.title) - }, - textField: { binding in - SecureField(text: binding) { - Text(passwordLocalization.placeholder) - } - .autocorrectionDisabled(true) - .textInputAutocapitalization(.never) - .textContentType(presentationType.login ? .password : .newPassword) - } - ) - .onTapFocus(focusedField: _focusedField, fieldIdentifier: .password) - } - - private var passwordRepeatSecureField: some View { - let passwordRepeatLocalization: FieldLocalizationResource - if let passwordRepeat = presentationType.passwordRepeat { - passwordRepeatLocalization = passwordRepeat - } else { - switch presentationType { - case .login: - preconditionFailure("The password repeat field should never be shown in the login presentation type.") - case .signUp: - passwordRepeatLocalization = usernamePasswordLoginService.localization.signUp.passwordRepeat - } - } - - let passwordNotEqualErrorLocalization: LocalizedStringResource - if let passwordNotEqualError = presentationType.passwordNotEqualError { - passwordNotEqualErrorLocalization = passwordNotEqualError - } else { - switch presentationType { - case .login: - preconditionFailure("The password not equal error should never be shown in the login presentation type.") - case .signUp: - passwordNotEqualErrorLocalization = usernamePasswordLoginService.localization.signUp.passwordNotEqualError - } - } - - return VerifiableTextFieldGridRow( - text: $passwordRepeat, - valid: $passwordRepeatValid, - validationRules: passwordValidationRules, - description: { - Text(passwordRepeatLocalization.title) - }, - textField: { binding in - VStack { - SecureField(text: binding) { - Text(passwordRepeatLocalization.placeholder) - } - .autocorrectionDisabled(true) - .textInputAutocapitalization(.never) - .textContentType(.newPassword) - if password != passwordRepeat && !passwordRepeat.isEmpty { - HStack { - Text(passwordNotEqualErrorLocalization) - .fixedSize(horizontal: false, vertical: true) - .gridColumnAlignment(.leading) - .font(.footnote) - .foregroundColor(.red) - Spacer(minLength: 0) - } - } - } - } - ) - .onTapFocus(focusedField: _focusedField, fieldIdentifier: .passwordRepeat) - } - - - init( - username: Binding, - password: Binding, - valid: Binding, - focusState: FocusState = FocusState(), - usernameValidationRules: [ValidationRule] = [], - passwordValidationRules: [ValidationRule] = [], - presentationType: PresentationType = .login(.environment) - ) { - self._username = username - self._password = password - self._valid = valid - self._focusedField = focusState - self.usernameValidationRules = usernameValidationRules - self.passwordValidationRules = passwordValidationRules - self.presentationType = presentationType - } - - - private func updateValid() { - switch presentationType { - case .login: - valid = usernameValid && passwordValid - case .signUp: - valid = usernameValid - && passwordValid - && passwordRepeatValid - && password == passwordRepeat - } - } -} - - -#if DEBUG -struct UsernamePasswordFields_Previews: PreviewProvider { - @State private static var username: String = "" - @State private static var password: String = "" - @State private static var valid = false - - - private static var validationRules: [ValidationRule] { - guard let regex = try? Regex("[a-zA-Z]") else { - return [] - } - - return [ - ValidationRule( - regex: regex, - message: "Validation failed: Required only letters." - ) - ] - } - - static var previews: some View { - Form { - Section { - Grid(horizontalSpacing: 8, verticalSpacing: 8) { - UsernamePasswordFields( - username: $username, - password: $password, - valid: $valid, - usernameValidationRules: validationRules, - passwordValidationRules: validationRules - ) - } - } - Section { - Grid(horizontalSpacing: 8, verticalSpacing: 8) { - UsernamePasswordFields( - username: $username, - password: $password, - valid: $valid, - usernameValidationRules: validationRules, - passwordValidationRules: validationRules, - presentationType: .signUp(.environment) - ) - } - } - } - .environmentObject(UsernamePasswordAccountService()) - } -} -#endif diff --git a/Sources/SpeziAccount/Username and Password/Shared/ValidationRule.swift b/Sources/SpeziAccount/Username and Password/Shared/ValidationRule.swift deleted file mode 100644 index c7daa7ea..00000000 --- a/Sources/SpeziAccount/Username and Password/Shared/ValidationRule.swift +++ /dev/null @@ -1,71 +0,0 @@ -// -// This source file is part of the Spezi open-source project -// -// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - - -/// A rule used for validating text along with a message to display if the validation fails. -/// -/// The following example demonstrates a ``ValidationRule`` using a regex expression for an email. -/// ```swift -/// ValidationRule( -/// regex: try? Regex("[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}"), -/// message: "The entered email is not correct." -/// ) -/// ``` -public struct ValidationRule: Decodable { - enum CodingKeys: String, CodingKey { - case rule - case message - } - - - private let rule: (String) -> Bool - private let message: String - - - /// Creates a validation rule from an escaping closure - /// - /// - Parameters: - /// - rule: An escaping closure that validates a `String` and returns a boolean result - /// - message: A `String` message to display if validation fails - public init(rule: @escaping (String) -> Bool, message: String) { - self.rule = rule - self.message = message - } - - /// Creates a validation rule from a regular expression - /// - /// - Parameters: - /// - regex: A `Regex` regular expression to match for validating text - /// - message: A `String` message to display if validation fails - public init(regex: Regex, message: String) { - self.rule = { input in - (try? regex.wholeMatch(in: input) != nil) ?? false - } - self.message = message - } - - public init(from decoder: Decoder) throws { - let values = try decoder.container(keyedBy: CodingKeys.self) - - let regexString = try values.decode(String.self, forKey: .rule) - let regex = try Regex(regexString) - - let message = try values.decode(String.self, forKey: .message) - - self.init(regex: regex, message: message) - } - - /// Validates the contents of a `String` and returns a `String` error message if validation fails - public func validate(_ input: String) -> String? { - guard !rule(input) else { - return nil - } - - return message - } -} diff --git a/Sources/SpeziAccount/Username and Password/Sign Up/DateOfBirthPicker.swift b/Sources/SpeziAccount/Username and Password/Sign Up/DateOfBirthPicker.swift deleted file mode 100644 index 7ed2a257..00000000 --- a/Sources/SpeziAccount/Username and Password/Sign Up/DateOfBirthPicker.swift +++ /dev/null @@ -1,85 +0,0 @@ -// -// This source file is part of the Spezi open-source project -// -// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -import Foundation -import SwiftUI - - -struct DateOfBirthPicker: View { - @Binding private var date: Date - @EnvironmentObject private var localizationEnvironmentObject: UsernamePasswordAccountService - private let localization: ConfigurableLocalization - - - private var dateOfBirthTitle: LocalizedStringResource { - switch localization { - case .environment: - return localizationEnvironmentObject.localization.signUp.dateOfBirthTitle - case let .value(dateOfBirthTitle): - return dateOfBirthTitle - } - } - - private var dateRange: ClosedRange { - let calendar = Calendar.current - let startDateComponents = DateComponents(year: 1900, month: 1, day: 1) - let endDate = Date.now - - guard let startDate = calendar.date(from: startDateComponents) else { - fatalError("Could not translate \(startDateComponents) to a valid date.") - } - - return startDate...endDate - } - - var body: some View { - DatePicker( - selection: $date, - in: dateRange, - displayedComponents: [ - .date - ] - ) { - Text(dateOfBirthTitle) - .fontWeight(.semibold) - } - } - - - init(date: Binding, title: LocalizedStringResource) { - self._date = date - self.localization = .value(title) - } - - - init(date: Binding) { - self._date = date - self.localization = .environment - } -} - - -#if DEBUG -struct DateOfBirthPicker_Previews: PreviewProvider { - @State private static var date = Date.now - - - static var previews: some View { - VStack { - Form { - DateOfBirthPicker(date: $date) - } - .frame(height: 200) - DateOfBirthPicker(date: $date) - .padding(32) - } - .environmentObject(UsernamePasswordAccountService()) - .background(Color(.systemGroupedBackground)) - } -} -#endif diff --git a/Sources/SpeziAccount/Username and Password/Sign Up/GenderIdentity.swift b/Sources/SpeziAccount/Username and Password/Sign Up/GenderIdentity.swift deleted file mode 100644 index 7a8c4b19..00000000 --- a/Sources/SpeziAccount/Username and Password/Sign Up/GenderIdentity.swift +++ /dev/null @@ -1,44 +0,0 @@ -// -// This source file is part of the Spezi open-source project -// -// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -import Foundation - - -/// Describes the self-identified gender identity -public enum GenderIdentity: Int, Sendable, CaseIterable, Identifiable, Hashable, CustomLocalizedStringResourceConvertible { - /// Self-identify as female - case female - /// Self-identify as male - case male - /// Self-identify as transgender - case transgender - /// Self-identify as non-binary - case nonBinary - /// Prefer not to state the self-identified gender - case preferNotToState - - - public var id: RawValue { - rawValue - } - - public var localizedStringResource: LocalizedStringResource { - switch self { - case .female: - return LocalizedStringResource("GENDER_IDENTITY_FEMALE", bundle: .atURL(Bundle.module.bundleURL)) - case .male: - return LocalizedStringResource("GENDER_IDENTITY_MALE", bundle: .atURL(Bundle.module.bundleURL)) - case .transgender: - return LocalizedStringResource("GENDER_IDENTITY_TRANSGENDER", bundle: .atURL(Bundle.module.bundleURL)) - case .nonBinary: - return LocalizedStringResource("GENDER_IDENTITY_NON_BINARY", bundle: .atURL(Bundle.module.bundleURL)) - case .preferNotToState: - return LocalizedStringResource("GENDER_IDENTITY_PREFER_NOT_TO_STATE", bundle: .atURL(Bundle.module.bundleURL)) - } - } -} diff --git a/Sources/SpeziAccount/Username and Password/Sign Up/NameTextFields.swift b/Sources/SpeziAccount/Username and Password/Sign Up/NameTextFields.swift deleted file mode 100644 index daec8b24..00000000 --- a/Sources/SpeziAccount/Username and Password/Sign Up/NameTextFields.swift +++ /dev/null @@ -1,95 +0,0 @@ -// -// This source file is part of the Spezi open-source project -// -// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -import SpeziViews -import SwiftUI - - -struct NameTextFields: View { - @Binding private var name: PersonNameComponents - @FocusState private var focusedField: AccountInputFields? - @EnvironmentObject var localizationEnvironmentObject: UsernamePasswordAccountService - private let localization: ConfigurableLocalization<( - givenName: FieldLocalizationResource, - familyName: FieldLocalizationResource - )> - - - private var givenName: FieldLocalizationResource { - switch localization { - case .environment: - return localizationEnvironmentObject.localization.signUp.givenName - case let .value((givenName, _)): - return givenName - } - } - - private var familyName: FieldLocalizationResource { - switch localization { - case .environment: - return localizationEnvironmentObject.localization.signUp.familyName - case let .value((_, familyName)): - return familyName - } - } - - - var body: some View { - SpeziViews.NameFields( - name: $name, - givenNameField: givenName, - givenNameFieldIdentifier: AccountInputFields.givenName, - familyNameField: familyName, - familyNameFieldIdentifier: AccountInputFields.familyName, - focusedState: _focusedField - ) - } - - - init( - name: Binding, - givenName: FieldLocalizationResource, - familyName: FieldLocalizationResource, - focusState: FocusState = FocusState() - ) { - self._name = name - self.localization = .value((givenName, familyName)) - self._focusedField = focusState - } - - - init( - name: Binding, - focusState: FocusState = FocusState() - ) { - self._name = name - self._focusedField = focusState - self.localization = .environment - } -} - - -#if DEBUG -struct NameTextFields_Previews: PreviewProvider { - @State private static var name = PersonNameComponents() - - - static var previews: some View { - VStack { - Form { - NameTextFields(name: $name) - } - .frame(height: 300) - NameTextFields(name: $name) - .padding(32) - } - .environmentObject(UsernamePasswordAccountService()) - .background(Color(.systemGroupedBackground)) - } -} -#endif diff --git a/Sources/SpeziAccount/Username and Password/Sign Up/SignUpOptions.swift b/Sources/SpeziAccount/Username and Password/Sign Up/SignUpOptions.swift deleted file mode 100644 index e5bb0093..00000000 --- a/Sources/SpeziAccount/Username and Password/Sign Up/SignUpOptions.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// This source file is part of the Spezi open-source project -// -// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -import Foundation - - -/// Represents a set of options for data to collect from the user on the sign up form. -public struct SignUpOptions: OptionSet { - /// Option to collect a username and password. - public static let usernameAndPassword = SignUpOptions(rawValue: 1 << 0) - /// Option to collect a name. - public static let name = SignUpOptions(rawValue: 1 << 1) - /// Option to collect a gender identity. - public static let genderIdentity = SignUpOptions(rawValue: 1 << 2) - /// Option to collect a date of birth. - public static let dateOfBirth = SignUpOptions(rawValue: 1 << 3) - - /// A default set of signup options, including username and password, name, gender identity, and date of birth. - public static let `default`: SignUpOptions = [.usernameAndPassword, .name, .genderIdentity, .dateOfBirth] - - - public let rawValue: Int - - - public init(rawValue: Int) { - self.rawValue = rawValue - } -} diff --git a/Sources/SpeziAccount/Username and Password/Sign Up/SignUpValues.swift b/Sources/SpeziAccount/Username and Password/Sign Up/SignUpValues.swift deleted file mode 100644 index 3c2755d1..00000000 --- a/Sources/SpeziAccount/Username and Password/Sign Up/SignUpValues.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// This source file is part of the Spezi open-source project -// -// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -import Foundation - - -/// The collected date instantiated by the sign up views. -public struct SignUpValues: Sendable { - /// The username as inputted in the sign up user interface. - public let username: String - /// The password as inputted in the sign up user interface. - public let password: String - /// The name as inputted in the sign up user interface. - public let name: PersonNameComponents - /// The self-identified gender as inputted in the sign up user interface. - public let genderIdentity: GenderIdentity? - /// The date of birth as inputted in the sign up user interface. - public let dateOfBirth: Date? - - - /// - Parameters: - /// - username: The username as inputted in the sign-up user interface. - /// - password: The password as inputted in the sign-up user interface. - /// - name: The name as inputted in the sign-up user interface. - /// - genderIdentity: The self-identified gender as inputted in the sign-up user interface. - /// - dateOfBirth: The date of birth as inputted in the sign-up user interface. - public init(username: String, password: String, name: PersonNameComponents, genderIdentity: GenderIdentity?, dateOfBirth: Date?) { - self.username = username - self.password = password - self.name = name - self.genderIdentity = genderIdentity - self.dateOfBirth = dateOfBirth - } -} diff --git a/Sources/SpeziAccount/Username and Password/Sign Up/UsernamePasswordSignUpView.swift b/Sources/SpeziAccount/Username and Password/Sign Up/UsernamePasswordSignUpView.swift deleted file mode 100644 index 2d99515f..00000000 --- a/Sources/SpeziAccount/Username and Password/Sign Up/UsernamePasswordSignUpView.swift +++ /dev/null @@ -1,269 +0,0 @@ -// -// This source file is part of the Spezi open-source project -// -// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -import SpeziViews -import SwiftUI - - -/// Displays a sign up view allowing a user to sign up using a username, password, and additional context. -/// -/// Enables ``AccountService``s such as the ``UsernamePasswordAccountService`` to -/// display a user interface allowing users to sign up with a username and password. -/// -/// The ``SignUp`` view automatically displays sign up buttons of all configured ``AccountService``s and is the recommended way to automatically constuct a sign up flow for different ``AccountService``s. -/// -/// Nevertheless, the ``UsernamePasswordSignUpView`` can also be used to display the sign up view in a custom sign up flow. -/// Applications must ensure that an ``UsernamePasswordAccountService`` instance is injected in the SwiftUI environment by, e.g., using the `.environmentObject(_:)` view modifier. -/// -/// The view can automatically validate input using passed in ``ValidationRule``s and can be customized using header or footer views: -/// ```swift -/// UsernamePasswordSignUpView( -/// signUpOptions: [.usernameAndPassword, .name, .genderIdentity, .dateOfBirth], -/// passwordValidationRules: [ -/// /* ... */ -/// ], -/// header: { -/// Text("A Header View ...") -/// }, -/// footer: { -/// Text("A Footer View ...") -/// } -/// ) -/// .environmentObject(UsernamePasswordAccountService()) -/// ``` -public struct UsernamePasswordSignUpView: View { - enum Constants { - static let formVerticalPadding: CGFloat = 8 - } - - - private let usernameValidationRules: [ValidationRule] - private let passwordValidationRules: [ValidationRule] - private let header: AnyView - private let footer: AnyView - private let signUpOptions: SignUpOptions - - @EnvironmentObject private var usernamePasswordAccountService: UsernamePasswordAccountService - - @State private var username = "" - @State private var password = "" - @State private var name = PersonNameComponents() - @State private var dateOfBirth = Date() - @State private var genderIdentity: GenderIdentity = .preferNotToState - @State private var state: ViewState = .idle - - @State private var usernamePasswordValid = false - @FocusState private var focusedField: AccountInputFields? - - private let localization: ConfigurableLocalization - - - public var body: some View { - Form { - header - if signUpOptions.contains(.usernameAndPassword) { - usernamePasswordSection - } - if signUpOptions.contains(.name) { - Section { - NameTextFields(name: $name, focusState: _focusedField) - } - } - if signUpOptions.contains(.dateOfBirth) { - Section { - DateOfBirthPicker(date: $dateOfBirth) - } - } - if signUpOptions.contains(.genderIdentity) { - Section { - GenderIdentityPicker(genderIdentity: $genderIdentity) - } - } - signUpButton - footer - } - .navigationTitle(navigationTitle.localizedString()) - .navigationBarBackButtonHidden(state == .processing) - .viewStateAlert(state: $state) - } - - private var usernamePasswordSection: some View { - Section { - Grid(alignment: .leading) { - switch localization { - case .environment: - UsernamePasswordFields( - username: $username, - password: $password, - valid: $usernamePasswordValid, - focusState: _focusedField, - usernameValidationRules: usernameValidationRules, - passwordValidationRules: passwordValidationRules, - presentationType: .signUp(.environment) - ) - case let .value(signUp): - UsernamePasswordFields( - username: $username, - password: $password, - valid: $usernamePasswordValid, - focusState: _focusedField, - usernameValidationRules: usernameValidationRules, - passwordValidationRules: passwordValidationRules, - presentationType: .signUp( - .value( - ( - signUp.username, - signUp.password, - signUp.passwordRepeat, - signUp.passwordNotEqualError - ) - ) - ) - ) - } - } - } - } - - private var signUpButton: some View { - let signUpButtonLocalization: LocalizedStringResource - switch localization { - case .environment: - signUpButtonLocalization = usernamePasswordAccountService.localization.signUp.signUpActionButtonTitle - case .value(let signUp): - signUpButtonLocalization = signUp.signUpActionButtonTitle - } - - return Button(action: signUpButtonPressed) { - Text(signUpButtonLocalization) - .padding(6) - .frame(maxWidth: .infinity) - .opacity(state == .processing ? 0.0 : 1.0) - .overlay { - if state == .processing { - ProgressView() - .progressViewStyle(CircularProgressViewStyle(tint: .white)) - } - } - } - .buttonStyle(.borderedProminent) - .disabled(signUpButtonDisabled) - .padding() - .padding(-34) - .listRowBackground(Color.clear) - } - - private var navigationTitle: LocalizedStringResource { - switch localization { - case .environment: - return usernamePasswordAccountService.localization.signUp.navigationTitle - case .value(let signUp): - return signUp.navigationTitle - } - } - - private var defaultSignUpFailedError: LocalizedStringResource { - switch localization { - case .environment: - return usernamePasswordAccountService.localization.signUp.defaultSignUpFailedError - case let .value(resetPassword): - return resetPassword.defaultSignUpFailedError - } - } - - private var signUpButtonDisabled: Bool { - let namesInvalid: Bool - if signUpOptions.contains(.name) { - if let familyName = name.familyName, - let givenName = name.givenName { - namesInvalid = familyName.isEmpty || givenName.isEmpty - } else { - namesInvalid = true - } - } else { - namesInvalid = false - } - - return state == .processing || !usernamePasswordValid || namesInvalid - } - - - /// Creates a `UsernamePasswordSignUpView` for users to sign up with a username, password, - /// and other personal information. - /// - /// - Parameters: - /// - signUpOptions: A set of options for data to collect from the user - /// - usernameValidationRules: An array of ``ValidationRule``s to apply to the entered username - /// - passwordValidationRules: An array of ``ValidationRule``s to apply to the entered password - /// - header: A SwiftUI `View` to display as a header - /// - footer: A SwiftUI `View` to display as a footer - /// - localization: A localization configuration to apply to this view - public init( - signUpOptions: SignUpOptions = .default, - usernameValidationRules: [ValidationRule] = [], - passwordValidationRules: [ValidationRule] = [], - @ViewBuilder header: () -> Header = { EmptyView() }, - @ViewBuilder footer: () -> Footer = { EmptyView() }, - localization: ConfigurableLocalization = .environment - ) { - self.signUpOptions = signUpOptions - self.usernameValidationRules = usernameValidationRules - self.passwordValidationRules = passwordValidationRules - self.header = AnyView(header()) - self.footer = AnyView(footer()) - self.localization = localization - } - - - private func signUpButtonPressed() { - guard !(state == .processing) else { - return - } - - withAnimation(.easeOut(duration: 0.2)) { - focusedField = .none - state = .processing - } - - Task { - do { - try await usernamePasswordAccountService.signUp( - signUpValues: SignUpValues( - username: username, - password: password, - name: name, - genderIdentity: genderIdentity, - dateOfBirth: dateOfBirth - ) - ) - withAnimation(.easeIn(duration: 0.2)) { - state = .idle - } - } catch { - state = .error( - AnyLocalizedError( - error: error, - defaultErrorDescription: defaultSignUpFailedError - ) - ) - } - } - } -} - - -#if DEBUG -struct UsernamePasswordSignUpView_Previews: PreviewProvider { - static var previews: some View { - NavigationStack { - UsernamePasswordSignUpView() - .environmentObject(UsernamePasswordAccountService()) - } - } -} -#endif diff --git a/Sources/SpeziAccount/Username and Password/UsernamePasswordAccountService.swift b/Sources/SpeziAccount/Username and Password/UsernamePasswordAccountService.swift deleted file mode 100644 index 39a4e260..00000000 --- a/Sources/SpeziAccount/Username and Password/UsernamePasswordAccountService.swift +++ /dev/null @@ -1,129 +0,0 @@ -// -// This source file is part of the Spezi open-source project -// -// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -import Spezi -import SwiftUI - - -/// Account service that enables a username and password based management -/// -/// The ``UsernamePasswordAccountService`` enables a username and password based account management. -/// -/// Other ``AccountService``s can be created by subclassing the ``UsernamePasswordAccountService`` and overriding the ``UsernamePasswordAccountService/localization``, -/// buttons like the ``UsernamePasswordAccountService/loginButton``, or overriding the ``UsernamePasswordAccountService/login(username:password:)`` -/// and ``UsernamePasswordAccountService/button(_:destination:)`` functions. -open class UsernamePasswordAccountService: @unchecked Sendable, AccountService, ObservableObject { - /// The ``Account/Account`` instance that can be used to e.g., interact with the ``Account/Account/signedIn``. - public weak var account: Account? - - - /// The ``Localization`` used by the views presented in the ``UsernamePasswordAccountService`` or its subclasses. - open var localization: Localization { - Localization.default - } - - /// The button that should be displayed in login-related views to represent the ``UsernamePasswordAccountService`` or its subclasses. - open var loginButton: AnyView { - button( - localization.signUp.buttonTitle, - destination: UsernamePasswordLoginView() - ) - } - - /// The button that should be displayed in sign up-related views to represent the ``UsernamePasswordAccountService`` or its subclasses. - open var signUpButton: AnyView { - button( - localization.signUp.buttonTitle, - destination: UsernamePasswordSignUpView() - ) - } - - /// The button that should be displayed in password-reset-related views to represent the ``UsernamePasswordAccountService`` or its subclasses. - open var resetPasswordButton: AnyView { - AnyView( - NavigationLink { - UsernamePasswordResetPasswordView { - processSuccessfulResetPasswordView - } - .environmentObject(self as UsernamePasswordAccountService) - } label: { - Text(localization.resetPassword.buttonTitle) - } - ) - } - - open var processSuccessfulResetPasswordView: AnyView { - AnyView( - VStack(spacing: 32) { - Image(systemName: "checkmark.circle.fill") - .resizable() - .foregroundColor(.green) - .frame(width: 100, height: 100) - Text(localization.resetPassword.processSuccessfulLabel) - .multilineTextAlignment(.center) - .lineLimit(nil) - } - .padding(32) - ) - } - - - /// Creates a new instance of a ``UsernamePasswordAccountService`` - public init() { } - - - public func inject(account: Account) { - self.account = account - } - - /// The function is called when a user is logged in. - /// - /// You can use views like the ``UsernamePasswordLoginView`` to display user interfaces for logging in users or create your own views and call ``UsernamePasswordAccountService/login(username:password:)`` - /// Throw an `Error` type conforming to `LocalizedError` if the sign up has not been successful to present a localized description to the user on a failed sign up. - /// - Parameters: - /// - username: The username that should be used in the login process - /// - password: The password that should be used in the login process - open func login(username: String, password: String) async throws { } - - - /// The ``signUp(signUpValues:)`` method is called by UI elements when a user wants to sign up. - /// - /// You can use views like the ``UsernamePasswordSignUpView`` to display user interfaces for signing up users or create your own views and call ``UsernamePasswordAccountService/signUp(signUpValues:)`` - /// Throw an `Error` type conforming to `LocalizedError` if the sign up has not been successful to present a localized description to the user on a failed sign up. - /// - Parameter signUpValues: The context collected in the sign in process. Refer to ``SignUpValues`` for more information about the possible context. - open func signUp(signUpValues: SignUpValues) async throws { } - - - /// The ``login(username:password:)`` method is called by UI elements when a user wants to reset their password. - /// - /// You can use views like the ``UsernamePasswordResetPasswordView`` to display user interfaces for resetting their password or create your own views and call ``UsernamePasswordAccountService/resetPassword(username:)`` - /// Throw an `Error` type conforming to `LocalizedError` if the reset password action has not been successful to present a localized description to the user on a failed reset password attempt. - /// - Parameter username: The username that the password should be reset for. - open func resetPassword(username: String) async throws { } - - - /// Creates a resuable button styled in accordance to the ``UsernamePasswordAccountService`` or its subclasses. - /// - Parameters: - /// - title: The title of the button. - /// - destination: The destination of the button. - /// - Returns: Returns the styled button in accordance to the ``UsernamePasswordAccountService`` or its subclasses. - open func button(_ title: LocalizedStringResource, destination: V) -> AnyView { - AnyView( - NavigationLink { - destination - .environmentObject(self as UsernamePasswordAccountService) - } label: { - AccountServiceButton { - Image(systemName: "ellipsis.rectangle") - .font(.title2) - Text(title) - } - } - ) - } -} diff --git a/Sources/SpeziAccount/ViewModel/AccountDisplayModel.swift b/Sources/SpeziAccount/ViewModel/AccountDisplayModel.swift new file mode 100644 index 00000000..9eb7e665 --- /dev/null +++ b/Sources/SpeziAccount/ViewModel/AccountDisplayModel.swift @@ -0,0 +1,46 @@ +// +// 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 +// + +import Foundation + + +struct AccountDisplayModel { + let accountDetails: AccountDetails + + var profileViewName: PersonNameComponents? { + accountDetails.name + } + + var accountHeadline: String { + // we gracefully check if the account details have a name, bypassing the subscript overloads + if let name = accountDetails.name { + return name.formatted(.name(style: .long)) + } else { + // otherwise we display the userId + return accountDetails.userId + } + } + + var accountSubheadline: String? { + if accountDetails.name != nil { + // If the accountHeadline uses the name, we display the userId as the subheadline + return accountDetails.userId + } else if accountDetails.userIdType != .emailAddress, + let email = accountDetails.email { + // Otherwise, headline will be the userId. Therefore, we check if the userId is not already + // displaying the email address. In this case the subheadline will be the email if available. + return email + } + + return nil + } + + init(details: AccountDetails) { + self.accountDetails = details + } +} diff --git a/Sources/SpeziAccount/ViewModel/AccountOverviewFormViewModel.swift b/Sources/SpeziAccount/ViewModel/AccountOverviewFormViewModel.swift new file mode 100644 index 00000000..5e032c10 --- /dev/null +++ b/Sources/SpeziAccount/ViewModel/AccountOverviewFormViewModel.swift @@ -0,0 +1,196 @@ +// +// 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 +// + +import Combine +import OrderedCollections +import os +import SpeziViews +import SwiftUI + + +@MainActor +class AccountOverviewFormViewModel: ObservableObject { + private static var logger: Logger { + LoggerKey.defaultValue + } + + /// We categorize ``AccountKey`` by ``AccountKeyCategory``. This is completely static and precomputed. + /// + /// Instead of iterating over the ``AccountDetails`` and show whatever values are present, we rely on the statically + /// defined ``AccountKeyRequirement``s defined by the user. Using those classifications we can easily allow to model + /// "shadow" account keys that are present but never shown to the user to allow to manage additional state. + private let categorizedAccountKeys: OrderedDictionary + + + let modifiedDetailsBuilder = ModifiedAccountDetails.Builder() // nested ObservableObject, see init + let validationEngines = ValidationEngines() + + @Published var presentingCancellationDialog = false + @Published var presentingLogoutAlert = false + @Published var presentingRemovalAlert = false + + @Published var addedAccountKeys = CategorizedAccountKeys() + @Published var removedAccountKeys = CategorizedAccountKeys() + + var hasUnsavedChanges: Bool { + !modifiedDetailsBuilder.isEmpty + } + + var defaultErrorDescription: LocalizedStringResource { + .init("ACCOUNT_OVERVIEW_EDIT_DEFAULT_ERROR", bundle: .atURL(from: .module)) + } + + private var anyCancellable: [AnyCancellable] = [] + + + init(account: Account) { + self.categorizedAccountKeys = account.configuration.reduce(into: [:]) { result, configuration in + result[configuration.key.category, default: []] += [configuration.key] + } + + // We forward the objectWillChange publisher. Our `hasUnsavedChanges` is affected by changes to the builder. + // Otherwise, changes to the object wouldn't be important. + anyCancellable.append(modifiedDetailsBuilder.objectWillChange.sink { [weak self] _ in + self?.objectWillChange.send() + }) + anyCancellable.append(validationEngines.objectWillChange.sink { [weak self] _ in + self?.objectWillChange.send() + }) + } + + + func accountKeys(by category: AccountKeyCategory, using details: AccountDetails) -> [any AccountKey.Type] { + categorizedAccountKeys[category, default: []] + .sorted(using: AccountOverviewValuesComparator(details: details, added: addedAccountKeys, removed: removedAccountKeys)) + } + + func editableAccountKeys(details accountDetails: AccountDetails) -> OrderedDictionary { + let results = categorizedAccountKeys.filter { category, _ in + category != .credentials && category != .name + } + + // We want to establish the following order: + // - account keys where the user has supplied a value + // - account keys the user just added a filed for input in the current edit session + // - account keys for which the user doesn't have a value (to display a add button at the bottom of a section) + return results.mapValues { value in + // sort is stable: see https://github.com/apple/swift-evolution/blob/main/proposals/0372-document-sorting-as-stable.md + value.sorted(using: AccountOverviewValuesComparator(details: accountDetails, added: addedAccountKeys, removed: removedAccountKeys)) + } + } + + func addAccountDetail(for value: any AccountKey.Type) { + guard !addedAccountKeys.contains(value) else { + return + } + + Self.logger.debug("Adding new account value \(value) to the edit view!") + + if let index = removedAccountKeys.index(of: value) { + // This is a account value for which the user has a value set, but which he marked as removed in this session + // and is now adding back a value for. + removedAccountKeys.remove(at: index, for: value.category) + } else { + addedAccountKeys.append(value) + } + } + + func deleteAccountKeys(at indexSet: IndexSet, in accountKeys: [any AccountKey.Type]) { + for index in indexSet { + let value = accountKeys[index] + + if let addedValueIndex = addedAccountKeys.index(of: value) { + // remove an account value which was just added in the current edit session + addedAccountKeys.remove(at: addedValueIndex, for: value.category) + + // make sure we discard potential changes + modifiedDetailsBuilder.remove(any: value) + } else { + // a removed account key that is still present in the current account details + removedAccountKeys.append(value) + + // set a empty value to: + // - have an empty value if it gets added again + // - the discard button will ask for confirmation + modifiedDetailsBuilder.setEmptyValue(for: value) + } + } + } + + func cancelEditAction(editMode: Binding?) { + Self.logger.debug("Pressed the cancel button!") + if !hasUnsavedChanges { + discardChangesAction(editMode: editMode) + return + } + + presentingCancellationDialog = true + + Self.logger.debug("Found \(self.modifiedDetailsBuilder.count) modified elements. Asking to discard.") + } + + func discardChangesAction(editMode: Binding?) { + Self.logger.debug("Exiting edit mode and discarding changes.") + + resetModelState(editMode: editMode) + } + + func updateAccountDetails(details: AccountDetails, editMode: Binding? = nil) async throws { + let removedDetailsBuilder = RemovedAccountDetails.Builder() + removedDetailsBuilder.merging(with: removedAccountKeys.keys, from: details) + + let modifications = AccountModifications( + modifiedDetails: modifiedDetailsBuilder.build(), + removedAccountDetails: removedDetailsBuilder.build() + ) + + try await details.accountService.updateAccountDetails(modifications) + Self.logger.debug("\(self.modifiedDetailsBuilder.count) items saved successfully.") + + resetModelState(editMode: editMode) // this reset the edit mode as well + } + + func resetModelState(editMode: Binding? = nil) { + addedAccountKeys = CategorizedAccountKeys() + removedAccountKeys = CategorizedAccountKeys() + + // clearing the builder before switching the edit mode + modifiedDetailsBuilder.clear() // it's okay that this doesn't trigger a UI update + + editMode?.wrappedValue = .inactive + } + + func accountIdentifierLabel(configuration: AccountValueConfiguration, userIdType: UserIdType) -> Text { + let userId = Text(userIdType.localizedStringResource) + + if configuration[PersonNameKey.self] != nil { + return Text(PersonNameKey.name) + + Text(", ") + + userId + } + + return userId + } + + func accountSecurityLabel(_ configuration: AccountValueConfiguration) -> Text { + let security = Text("SECURITY", bundle: .module) + + if configuration[PasswordKey.self] != nil { + return Text("UP_PASSWORD", bundle: .module) + + Text(" & ") + + security + } + + return security + } + + + deinit { + anyCancellable.forEach { $0.cancel() } + } +} diff --git a/Sources/SpeziAccount/ViewModel/AccountOverviewValuesComparator.swift b/Sources/SpeziAccount/ViewModel/AccountOverviewValuesComparator.swift new file mode 100644 index 00000000..043e962b --- /dev/null +++ b/Sources/SpeziAccount/ViewModel/AccountOverviewValuesComparator.swift @@ -0,0 +1,71 @@ +// +// 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 +// + +import Foundation + + +struct AccountOverviewValuesComparator: SortComparator { + var order: SortOrder = .forward + + private let id = UUID() + private let accountDetails: AccountDetails + private let addedAccountKeys: CategorizedAccountKeys + private let removedAccountKeys: CategorizedAccountKeys + + init(details: AccountDetails, added: CategorizedAccountKeys, removed: CategorizedAccountKeys) { + self.accountDetails = details + self.addedAccountKeys = added + self.removedAccountKeys = removed + } + + func compare(_ lhs: any AccountKey.Type, _ rhs: any AccountKey.Type) -> ComparisonResult { + let lhsContained = accountDetails.contains(lhs) && !removedAccountKeys.contains(lhs) + let rhsContained = accountDetails.contains(rhs) && !removedAccountKeys.contains(rhs) + + guard !lhsContained && !rhsContained else { + if lhsContained == rhsContained { + return .orderedSame + } else if !rhsContained { + return .orderedAscending + } else { + return .orderedDescending + } + } + + // this is basically also the "contains" check + let lhsIndex = addedAccountKeys.index(of: lhs) + let rhsIndex = addedAccountKeys.index(of: rhs) + + if let lhsIndex, let rhsIndex { + if lhsIndex < rhsIndex { + return .orderedAscending + } else if lhsIndex > rhsIndex { + return .orderedDescending + } else { + return .orderedSame + } + } else if lhsIndex != nil && rhsIndex == nil { + return .orderedAscending + } else if lhsIndex == nil && rhsIndex != nil { + return .orderedDescending + } + + return .orderedSame + } +} + + +extension AccountOverviewValuesComparator { + static func == (lhs: AccountOverviewValuesComparator, rhs: AccountOverviewValuesComparator) -> Bool { + lhs.id == rhs.id + } + + func hash(into hasher: inout Hasher) { + id.hash(into: &hasher) + } +} diff --git a/Sources/SpeziAccount/ViewModel/CategorizedAccountKeys.swift b/Sources/SpeziAccount/ViewModel/CategorizedAccountKeys.swift new file mode 100644 index 00000000..e48aff4e --- /dev/null +++ b/Sources/SpeziAccount/ViewModel/CategorizedAccountKeys.swift @@ -0,0 +1,47 @@ +// +// 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 +// + +import OrderedCollections + + +struct CategorizedAccountKeys { + private var accountKeys: OrderedDictionary + + var keys: [any AccountKey.Type] { + accountKeys.values.reduce(into: []) { result, keys in + result.append(contentsOf: keys) + } + } + + init() { + accountKeys = [:] + } + + mutating func append(_ value: any AccountKey.Type) { + accountKeys[value.category, default: []] + .append(value) + } + + func contains(_ value: any AccountKey.Type) -> Bool { + accountKeys[value.category, default: []] + .contains(where: { $0.id == value.id }) + } + + func index(of value: any AccountKey.Type) -> Int? { + accountKeys[value.category, default: []] + .firstIndex(where: { $0.id == value.id }) + } + + @discardableResult + mutating func remove(at index: Int, for category: AccountKeyCategory) -> any AccountKey.Type { + let result = accountKeys[category, default: []] + .remove(at: index) + + return result + } +} diff --git a/Sources/SpeziAccount/ViewModel/FocusStateObject.swift b/Sources/SpeziAccount/ViewModel/FocusStateObject.swift new file mode 100644 index 00000000..d2788309 --- /dev/null +++ b/Sources/SpeziAccount/ViewModel/FocusStateObject.swift @@ -0,0 +1,46 @@ +// +// 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 +// + +import SpeziViews +import SwiftUI + + +/// An `ObservableObject` that wraps a `FocusState` instance. +/// +/// This is currently necessary as there is no viable mechanism to pass FocusState around and preexisting components +/// like the Spezi `NameFields` expect a `FocusState` instance. +/// +/// - Note: This is only available in ``DataEntryView`` and ``DataDisplayView`` views. +@dynamicMemberLookup +class FocusStateObject: ObservableObject { + /// The `FocusState` of the parent view. + /// Focus state is typically handled automatically using the ``AccountKey/focusState`` property. + /// Access to this property is useful when defining a ``DataEntryView`` that exposes more than one field. + let focusedField: FocusState.Binding // see `AccountKey.Type/focusState` + + + /// Initializes a new FocusStateObject object. + /// - Parameters: + /// - focusedField: The `FocusState` of the data entry view. + init(focusedField: FocusState.Binding) { + self.focusedField = focusedField + } + + subscript(dynamicMember keyPath: KeyPath.Binding, Member>) -> Member { + focusedField[keyPath: keyPath] + } + + subscript(dynamicMember keyPath: ReferenceWritableKeyPath.Binding, Member>) -> Member { + get { + self[dynamicMember: keyPath as KeyPath.Binding, Member>] + } + set { + focusedField[keyPath: keyPath] = newValue + } + } +} diff --git a/Sources/SpeziAccount/ViewModel/ForEachAccountKeyWrapper.swift b/Sources/SpeziAccount/ViewModel/ForEachAccountKeyWrapper.swift new file mode 100644 index 00000000..820d79a1 --- /dev/null +++ b/Sources/SpeziAccount/ViewModel/ForEachAccountKeyWrapper.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 +// + + +/// Helper type that wraps ``AccountKey`` metatypes to be identifiable for usage within `ForEach` views. +struct ForEachAccountKeyWrapper: Identifiable { + var id: ObjectIdentifier { + accountKey.id + } + + var accountKey: any AccountKey.Type + + init(_ accountKey: any AccountKey.Type) { + self.accountKey = accountKey + } +} diff --git a/Sources/SpeziAccount/ViewModel/ViewSizing.swift b/Sources/SpeziAccount/ViewModel/ViewSizing.swift new file mode 100644 index 00000000..44691a84 --- /dev/null +++ b/Sources/SpeziAccount/ViewModel/ViewSizing.swift @@ -0,0 +1,18 @@ +// +// 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 +// + +import Foundation + + +struct ViewSizing { + static let outerHorizontalPadding: CGFloat = 16 + static let innerHorizontalPadding: CGFloat = 16 + static let maxFrameWidth: CGFloat = 450 + + private init() {} +} diff --git a/Sources/SpeziAccount/Views/AccountServiceButton.swift b/Sources/SpeziAccount/ViewModifier/AccountServiceButtonModifier.swift similarity index 57% rename from Sources/SpeziAccount/Views/AccountServiceButton.swift rename to Sources/SpeziAccount/ViewModifier/AccountServiceButtonModifier.swift index 9fb0f8ae..bbc805c2 100644 --- a/Sources/SpeziAccount/Views/AccountServiceButton.swift +++ b/Sources/SpeziAccount/ViewModifier/AccountServiceButtonModifier.swift @@ -1,7 +1,7 @@ // // This source file is part of the Spezi open-source project // -// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) // // SPDX-License-Identifier: MIT // @@ -9,11 +9,8 @@ import SwiftUI -struct AccountServiceButton: View { - private let content: Content - - - var body: some View { +struct AccountServiceButtonModifier: ViewModifier { + func body(content: Content) -> some View { HStack { content } @@ -27,10 +24,13 @@ struct AccountServiceButton: View { ) .cornerRadius(8) } - - - init(@ViewBuilder _ content: () -> Content) { - self.content = content() +} + + +extension View { + /// Draw the standard background of a ``AccountService`` button (see ``AccountSetupViewStyle/makeServiceButtonLabel()-6ihdh``. + public func accountServiceButtonBackground() -> some View { + modifier(AccountServiceButtonModifier()) } } @@ -38,11 +38,13 @@ struct AccountServiceButton: View { #if DEBUG struct UsernamePasswordLoginServiceButton_Previews: PreviewProvider { static var previews: some View { - AccountServiceButton { + Group { Image(systemName: "ellipsis.rectangle") .font(.title2) - Text("UAP_LOGIN_BUTTON_TITLE", bundle: .module) + .accessibilityHidden(true) + Text("USER_ID_EMAIL", bundle: .module) } + .accountServiceButtonBackground() } } #endif diff --git a/Sources/SpeziAccount/ViewModifier/DisableFieldAssitantsModifier.swift b/Sources/SpeziAccount/ViewModifier/DisableFieldAssitantsModifier.swift new file mode 100644 index 00000000..eba10283 --- /dev/null +++ b/Sources/SpeziAccount/ViewModifier/DisableFieldAssitantsModifier.swift @@ -0,0 +1,18 @@ +// +// This source file is part of the Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SwiftUI + +extension View { + /// Disable any assistants on a field like autocorrect or input autocapitalization. + public func disableFieldAssistants() -> some View { + self + .autocorrectionDisabled(true) + .textInputAutocapitalization(.never) + } +} diff --git a/Sources/SpeziAccount/ViewModifier/DismissiveActions.swift b/Sources/SpeziAccount/ViewModifier/DismissiveActions.swift new file mode 100644 index 00000000..ee357309 --- /dev/null +++ b/Sources/SpeziAccount/ViewModifier/DismissiveActions.swift @@ -0,0 +1,20 @@ +// +// This source file is part of the Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Foundation +import SpeziViews +import SwiftUI + +extension View { + /// Disable any dismissive actions if the current `ViewState` is `processing`. + public func disableDismissiveActions(isProcessing state: ViewState) -> some View { + self + .navigationBarBackButtonHidden(state == .processing) + .interactiveDismissDisabled(state == .processing) + } +} diff --git a/Sources/SpeziAccount/ViewModifier/RequiredValidationModifier.swift b/Sources/SpeziAccount/ViewModifier/RequiredValidationModifier.swift new file mode 100644 index 00000000..3eac197c --- /dev/null +++ b/Sources/SpeziAccount/ViewModifier/RequiredValidationModifier.swift @@ -0,0 +1,68 @@ +// +// 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 +// + +import SwiftUI + + +private struct RequiredValidationModifier: ViewModifier { + @EnvironmentObject private var engines: ValidationEngines + @EnvironmentObject private var detailsBuilder: AccountValuesBuilder + + @Binding private var value: Key.Value + + @StateObject private var validation = ValidationEngine(rules: .nonEmpty) // mock validation engine + + private var mockText: String { + detailsBuilder.contains(Key.self) ? "CONTAINED" : "" + } + + init(_ binding: Binding) { + self._value = binding + } + + func body(content: Content) -> some View { + VStack { + content // the wrapped data entry view + .onChange(of: value) { _ in + // only if we are still registered + if engines.contains(validation) { + // as soon as the user changed the input once, we will always have a value. + validation.submit(input: mockText, debounce: true) + } + } + + HStack { + ValidationResultsView(results: validation.displayedValidationResults) + Spacer() + } + } + .register(engine: validation, with: engines, input: mockText) + } +} + + +extension View { + @ViewBuilder + func requiredValidation( + for key: Key.Type, + storage values: Values.Type, + _ value: Binding + ) -> some View { + // this is a workaround to allow subviews to define their own ValidationEngine + let containsValidation = Mirror(reflecting: self).children.contains { _, value in + value is StateObject + } + + if containsValidation { + self + } else { + self + .modifier(RequiredValidationModifier(value)) + } + } +} diff --git a/Sources/SpeziAccount/Views/AccountOverview/AccountKeyEditRow.swift b/Sources/SpeziAccount/Views/AccountOverview/AccountKeyEditRow.swift new file mode 100644 index 00000000..3c001f74 --- /dev/null +++ b/Sources/SpeziAccount/Views/AccountOverview/AccountKeyEditRow.swift @@ -0,0 +1,78 @@ +// +// 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 +// + +import SwiftUI + + +struct AccountKeyEditRow: View { + private let accountDetails: AccountDetails + private let accountKey: any AccountKey.Type + + @EnvironmentObject private var account: Account + @Environment(\.editMode) private var editMode + + @ObservedObject private var model: AccountOverviewFormViewModel + + var body: some View { + if editMode?.wrappedValue.isEditing == true { + // we place everything in the same HStack, such that animations are smooth + let hStack = VStack { + if accountDetails.contains(accountKey) && !model.removedAccountKeys.contains(accountKey) { + Group { + if let view = accountKey.dataEntryViewFromBuilder(builder: model.modifiedDetailsBuilder, for: ModifiedAccountDetails.self) { + view + } else if let view = accountKey.dataEntryViewWithStoredValue(details: accountDetails, for: ModifiedAccountDetails.self) { + view + } + } + .environment(\.accountViewType, .overview(mode: .existing)) + } else if model.addedAccountKeys.contains(accountKey) { // no need to repeat the removedAccountKeys condition + accountKey.emptyDataEntryView(for: ModifiedAccountDetails.self) + .deleteDisabled(false) + .environment(\.accountViewType, .overview(mode: .new)) + } else { + Button(action: { + model.addAccountDetail(for: accountKey) + }) { + Text("VALUE_ADD \(accountKey.name)", bundle: .module) + } + } + } + + // for some reason, SwiftUI doesn't update the view when the `deleteDisabled` changes in our scenario + if isDeleteDisabled(for: accountKey) { + hStack + .deleteDisabled(true) + } else { + hStack + } + } else { + if let view = accountKey.dataDisplayViewWithCurrentStoredValue(from: accountDetails) { + view + .environment(\.accountViewType, .overview(mode: .display)) + } + } + } + + + init(details accountDetails: AccountDetails, for accountKey: any AccountKey.Type, model: AccountOverviewFormViewModel) { + self.accountDetails = accountDetails + self.accountKey = accountKey + self.model = model + } + + + func isDeleteDisabled(for key: any AccountKey.Type) -> Bool { + if accountDetails.contains(key) && !model.removedAccountKeys.contains(key) { + return account.configuration[key]?.requirement == .required + } + + // if not in the addedAccountKeys, it's a "add" button + return !model.addedAccountKeys.contains(key) + } +} diff --git a/Sources/SpeziAccount/Views/AccountOverview/AccountOverviewHeader.swift b/Sources/SpeziAccount/Views/AccountOverview/AccountOverviewHeader.swift new file mode 100644 index 00000000..af57398c --- /dev/null +++ b/Sources/SpeziAccount/Views/AccountOverview/AccountOverviewHeader.swift @@ -0,0 +1,68 @@ +// +// 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 +// + +import SpeziViews +import SwiftUI + + +struct AccountOverviewHeader: View { + private let model: AccountDisplayModel + + + var body: some View { + VStack { + Group { + if let profileViewName = model.profileViewName { + UserProfileView(name: profileViewName) + .frame(height: 90) + } else { + Image(systemName: "person.circle.fill") + .resizable() + .frame(width: 40, height: 40) + .symbolRenderingMode(.hierarchical) + .foregroundColor(Color(.systemGray)) + .accessibilityHidden(true) + } + } + .accessibilityHidden(true) + + Text(model.accountHeadline) + .font(.title2) + .fontWeight(.semibold) + + if let accountSubheadline = model.accountSubheadline { + Text(accountSubheadline) + .font(.subheadline) + .foregroundColor(.secondary) + } + } + .accessibilityElement(children: .combine) + .frame(maxWidth: .infinity, alignment: .center) + .listRowBackground(Color.clear) + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + } + + + init(details: AccountDetails) { + self.model = AccountDisplayModel(details: details) + } +} + + +#if DEBUG +struct AccountOverviewHeader_Previews: PreviewProvider { + static let details = AccountDetails.Builder() + .set(\.userId, value: "andi.bauer@tum.de") + .set(\.name, value: PersonNameComponents(givenName: "Andreas", familyName: "Bauer")) + .build(owner: MockUserIdPasswordAccountService()) + + static var previews: some View { + AccountOverviewHeader(details: details) + } +} +#endif diff --git a/Sources/SpeziAccount/Views/AccountOverview/AccountOverviewSections.swift b/Sources/SpeziAccount/Views/AccountOverview/AccountOverviewSections.swift new file mode 100644 index 00000000..1b9eda7e --- /dev/null +++ b/Sources/SpeziAccount/Views/AccountOverview/AccountOverviewSections.swift @@ -0,0 +1,252 @@ +// +// 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 +// + +import OrderedCollections +import SpeziViews +import SwiftUI + + +/// A internal subview of ``AccountOverview`` that expects to be embedded into a `Form`. +struct AccountOverviewSections: View { + private let accountDetails: AccountDetails + + private var service: any AccountService { + accountDetails.accountService + } + + @EnvironmentObject private var account: Account + + @Environment(\.logger) private var logger + @Environment(\.editMode) private var editMode + @Environment(\.dismiss) private var dismiss + + @StateObject private var model: AccountOverviewFormViewModel + @Binding private var isEditing: Bool + + @State private var viewState: ViewState = .idle + // separate view state for any destructive actions like logout or account removal + @State private var destructiveViewState: ViewState = .idle + @FocusState private var focusedDataEntry: String? // see `AccountKey.Type/focusState` + + + var isProcessing: Bool { + viewState == .processing || destructiveViewState == .processing + } + + + var body: some View { + AccountOverviewHeader(details: accountDetails) + // Every `Section` is basically a `Group` view. So we have to be careful where to place modifiers + // as they might otherwise be rendered for every element in the Section/Group, e.g., placing multiple buttons. + .interactiveDismissDisabled(model.hasUnsavedChanges || isProcessing) + .navigationBarBackButtonHidden(editMode?.wrappedValue.isEditing ?? false || isProcessing) + .viewStateAlert(state: $viewState) + .viewStateAlert(state: $destructiveViewState) + .toolbar { + if editMode?.wrappedValue.isEditing == true && !isProcessing { + ToolbarItemGroup(placement: .cancellationAction) { + Button(action: { + model.cancelEditAction(editMode: editMode) + }) { + Text("CANCEL", bundle: .module) + } + } + } + if destructiveViewState == .idle { + ToolbarItemGroup(placement: .primaryAction) { + AsyncButton(state: $viewState, action: editButtonAction) { + if editMode?.wrappedValue.isEditing == true { + Text("DONE", bundle: .module) + } else { + Text("EDIT", bundle: .module) + } + } + .disabled(editMode?.wrappedValue.isEditing == true && model.validationEngines.isDisplayingValidationErrors) + .environment(\.defaultErrorDescription, model.defaultErrorDescription) + } + } + } + .confirmationDialog( + Text("CONFIRMATION_DISCARD_CHANGES_TITLE", bundle: .module), + isPresented: $model.presentingCancellationDialog, + titleVisibility: .visible + ) { + Button(role: .destructive, action: { + model.discardChangesAction(editMode: editMode) + }) { + Text("CONFIRMATION_DISCARD_CHANGES", bundle: .module) + } + Button(role: .cancel, action: {}) { + Text("CONFIRMATION_KEEP_EDITING", bundle: .module) + } + } + .alert(Text("CONFIRMATION_LOGOUT", bundle: .module), isPresented: $model.presentingLogoutAlert) { + // Note how the below AsyncButton (in the HStack) uses the same `destructiveViewState`. + // Due to SwiftUI behavior, the alert will be dismissed immediately. We use the AsyncButton here still + // to manage our async task and setting the ViewState. + AsyncButton(role: .destructive, state: $destructiveViewState, action: { + try await service.logout() + dismiss() + }) { + Text("UP_LOGOUT", bundle: .module) + } + .environment(\.defaultErrorDescription, .init("UP_LOGOUT_FAILED_DEFAULT_ERROR", bundle: .atURL(from: .module))) + + Button(role: .cancel, action: {}) { + Text("CANCEL", bundle: .module) + } + } + .alert(Text("CONFIRMATION_REMOVAL", bundle: .module), isPresented: $model.presentingRemovalAlert) { + // see the discussion of the AsyncButton in the above alert closure + AsyncButton(role: .destructive, state: $destructiveViewState, action: { + try await service.delete() + dismiss() + }) { + Text("DELETE", bundle: .module) + } + .environment(\.defaultErrorDescription, .init("REMOVE_DEFAULT_ERROR", bundle: .atURL(from: .module))) + + Button(role: .cancel, action: {}) { + Text("CANCEL", bundle: .module) + } + } message: { + Text("CONFIRMATION_REMOVAL_SUGGESTION", bundle: .module) + } + .onChange(of: editMode?.wrappedValue.isEditing ?? false) { newValue in + // sync the edit mode with the outer view + isEditing = newValue + } + + Section { + NavigationLink { + NameOverview(model: model, details: accountDetails) + } label: { + model.accountIdentifierLabel(configuration: account.configuration, userIdType: accountDetails.userIdType) + } + NavigationLink { + SecurityOverview(model: model, details: accountDetails) + } label: { + model.accountSecurityLabel(account.configuration) + } + } + + sectionsView + .injectEnvironmentObjects(service: service, model: model, focusState: $focusedDataEntry) + .animation(nil, value: editMode?.wrappedValue) + + HStack { + if editMode?.wrappedValue.isEditing == true { + AsyncButton(role: .destructive, state: $destructiveViewState, action: { + // While the action closure itself is not async, we rely on ability to render loading indicator + // of the AsyncButton which based on the externally supplied viewState. + model.presentingRemovalAlert = true + }) { + Text("DELETE_ACCOUNT", bundle: .module) + } + } else { + AsyncButton(role: .destructive, state: $destructiveViewState, action: { + model.presentingLogoutAlert = true + }) { + Text("UP_LOGOUT", bundle: .module) + } + } + } + .frame(maxWidth: .infinity, alignment: .center) + } + + @ViewBuilder private var sectionsView: some View { + ForEach(model.editableAccountKeys(details: accountDetails).elements, id: \.key) { category, accountKeys in + if !sectionIsEmpty(accountKeys) { + Section { + // the id property of AccountKey.Type is static, so we can't reference it by a KeyPath, therefore the wrapper + let forEachWrappers = accountKeys.map { key in + ForEachAccountKeyWrapper(key) + } + + ForEach(forEachWrappers, id: \.id) { wrapper in + AccountKeyEditRow(details: accountDetails, for: wrapper.accountKey, model: model) + } + .onDelete { indexSet in + model.deleteAccountKeys(at: indexSet, in: accountKeys) + } + } header: { + if let title = category.categoryTitle { + Text(title) + } + } + } + } + } + + + init( + account: Account, + details accountDetails: AccountDetails, + isEditing: Binding + ) { + self.accountDetails = accountDetails + self._model = StateObject(wrappedValue: AccountOverviewFormViewModel(account: account)) + self._isEditing = isEditing + } + + + private func editButtonAction() async throws { + if editMode?.wrappedValue.isEditing == false { + editMode?.wrappedValue = .active + return + } + + guard !model.modifiedDetailsBuilder.isEmpty else { + logger.debug("Not saving anything, as there were no changes!") + model.discardChangesAction(editMode: editMode) + return + } + + guard model.validationEngines.validateSubviews(focusState: $focusedDataEntry) else { + logger.debug("Some input validation failed. Staying in edit mode!") + return + } + + focusedDataEntry = nil + + logger.debug("Exiting edit mode and saving \(model.modifiedDetailsBuilder.count) changes to AccountService!") + + try await model.updateAccountDetails(details: accountDetails, editMode: editMode) + } + + /// Computes if a given `Section` is empty. This is the case if we are **not** currently editing + /// and the accountDetails don't have values stored for any of the provided ``AccountKey``. + private func sectionIsEmpty(_ accountKeys: [any AccountKey.Type]) -> Bool { + guard editMode?.wrappedValue.isEditing == false else { + // there is always UI presented in EDIT mode + return false + } + + // we don't have to check for `addedAccountKeys` as these are only relevant in edit mode + return accountKeys.allSatisfy { element in + !accountDetails.contains(element) + } + } +} + + +#if DEBUG +struct AccountOverviewSections_Previews: PreviewProvider { + static let details = AccountDetails.Builder() + .set(\.userId, value: "andi.bauer@tum.de") + .set(\.name, value: PersonNameComponents(givenName: "Andreas", familyName: "Bauer")) + .set(\.genderIdentity, value: .male) + + static var previews: some View { + NavigationStack { + AccountOverview() + } + .environmentObject(Account(building: details, active: MockUserIdPasswordAccountService())) + } +} +#endif diff --git a/Sources/SpeziAccount/Views/AccountOverview/MissingAccountDetailsWarning.swift b/Sources/SpeziAccount/Views/AccountOverview/MissingAccountDetailsWarning.swift new file mode 100644 index 00000000..838babff --- /dev/null +++ b/Sources/SpeziAccount/Views/AccountOverview/MissingAccountDetailsWarning.swift @@ -0,0 +1,45 @@ +// +// 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 +// + +import SwiftUI + + +struct MissingAccountDetailsWarning: View { + private var documentationUrl: URL { + // we may move to a #URL macro once Swift 5.9 is shipping + guard let docsUrl = URL(string: "https://swiftpackageindex.com/stanfordspezi/speziaccount/documentation/speziaccount/initial-setup") else { + fatalError("Failed to construct SpeziAccount Documentation URL. Please review URL syntax!") + } + + return docsUrl + } + + var body: some View { + VStack { + Text("MISSING_ACCOUNT_DETAILS", bundle: .module) + .multilineTextAlignment(.center) + .foregroundColor(.secondary) + + Button(action: { + UIApplication.shared.open(documentationUrl) + }) { + Text("OPEN_DOCUMENTATION", bundle: .module) + } + .padding() + } + } +} + + +#if DEBUG +struct MissingAccountDetailsWarning_Previews: PreviewProvider { + static var previews: some View { + MissingAccountDetailsWarning() + } +} +#endif diff --git a/Sources/SpeziAccount/Views/AccountOverview/NameOverview.swift b/Sources/SpeziAccount/Views/AccountOverview/NameOverview.swift new file mode 100644 index 00000000..1f40a760 --- /dev/null +++ b/Sources/SpeziAccount/Views/AccountOverview/NameOverview.swift @@ -0,0 +1,98 @@ +// +// 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 +// + +import SpeziViews +import SwiftUI + + +struct NameOverview: View { + private let accountDetails: AccountDetails + + @EnvironmentObject private var account: Account + + @ObservedObject private var model: AccountOverviewFormViewModel + + var body: some View { + Form { + Section { + NavigationLink { + SingleEditView(model: model, details: accountDetails) + } label: { + UserIdKey.dataDisplayViewWithCurrentStoredValue(from: accountDetails) + } + } + + Section { + NavigationLink { + SingleEditView(model: model, details: accountDetails) + } label: { + if let name = accountDetails.name { + PersonNameKey.DataDisplay(name) + } else { + HStack { + Text(PersonNameKey.name) + .accessibilityHidden(true) + Spacer() + Text("VALUE_ADD \(PersonNameKey.name)", bundle: .module) + .foregroundColor(.secondary) + } + .accessibilityElement(children: .combine) + } + } + } header: { + if let title = PersonNameKey.category.categoryTitle { + Text(title) + } + } + } + .navigationTitle(model.accountIdentifierLabel(configuration: account.configuration, userIdType: accountDetails.userIdType)) + .navigationBarTitleDisplayMode(.inline) + .injectEnvironmentObjects(service: accountDetails.accountService, model: model) + .environment(\.accountViewType, .overview(mode: .display)) + } + + + init(model: AccountOverviewFormViewModel, details accountDetails: AccountDetails) { + self.model = model + self.accountDetails = accountDetails + } +} + + +#if DEBUG +struct NameOverview_Previews: PreviewProvider { + static let details = AccountDetails.Builder() + .set(\.userId, value: "andi.bauer@tum.de") + .set(\.name, value: PersonNameComponents(givenName: "Andreas", familyName: "Bauer")) + + static let detailsWithoutName = AccountDetails.Builder() + .set(\.userId, value: "andi.bauer@tum.de") + + static let account = Account(building: details, active: MockUserIdPasswordAccountService()) + static let accountWithoutName = Account(building: detailsWithoutName, active: MockUserIdPasswordAccountService()) + + // be aware, modifications won't be displayed due to declaration in PreviewProvider that do not trigger an UI update + @StateObject static var model = AccountOverviewFormViewModel(account: account) + + static var previews: some View { + NavigationStack { + if let details = account.details { + NameOverview(model: model, details: details) + } + } + .environmentObject(account) + + NavigationStack { + if let details = account.details { + NameOverview(model: model, details: details) + } + } + .environmentObject(accountWithoutName) + } +} +#endif diff --git a/Sources/SpeziAccount/Views/AccountOverview/PasswordChangeSheet.swift b/Sources/SpeziAccount/Views/AccountOverview/PasswordChangeSheet.swift new file mode 100644 index 00000000..faec6610 --- /dev/null +++ b/Sources/SpeziAccount/Views/AccountOverview/PasswordChangeSheet.swift @@ -0,0 +1,151 @@ +// +// 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 +// + +import SpeziViews +import SwiftUI + + +struct PasswordChangeSheet: View { + private let accountDetails: AccountDetails + + private var service: any AccountService { + accountDetails.accountService + } + + + @Environment(\.logger) private var logger + @Environment(\.dismiss) private var dismiss + + @ObservedObject private var model: AccountOverviewFormViewModel + + @State private var viewState: ViewState = .idle + @FocusState private var focusedDataEntry: String? + + @State private var newPassword: String = "" + @State private var repeatPassword: String = "" + + private var passwordValidations: [ValidationRule] { + accountDetails.accountServiceConfiguration.fieldValidationRules(for: \.password) ?? [] + } + + var body: some View { + NavigationStack { + Form { + passwordFieldsSection + .injectEnvironmentObjects(service: service, model: model, focusState: $focusedDataEntry) + .environment(\.accountViewType, .overview(mode: .new)) + } + .viewStateAlert(state: $viewState) + .environment(\.defaultErrorDescription, model.defaultErrorDescription) + .navigationTitle(Text("CHANGE_PASSWORD", bundle: .module)) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItemGroup(placement: .primaryAction) { + AsyncButton(state: $viewState, action: submitPasswordChange) { + Text("DONE", bundle: .module) + } + } + ToolbarItemGroup(placement: .cancellationAction) { + Button(action: { + dismiss() + }) { + Text("CANCEL", bundle: .module) + } + } + } + .onDisappear { + model.resetModelState() // clears modified details + } + } + } + + @ViewBuilder private var passwordFieldsSection: some View { + Section { + Grid { + PasswordKey.DataEntry($newPassword) + .environment(\.passwordFieldType, .new) + .focused($focusedDataEntry, equals: PasswordKey.focusState) + .managedValidation(input: newPassword, for: PasswordKey.focusState, rules: passwordValidations) + .onChange(of: newPassword) { newValue in + // A workaround to execute the validation engine of the repeat field if it contains content. + // It works, as we only have two validation engines in this view. + if !newValue.isEmpty && !repeatPassword.isEmpty { + model.validationEngines.validateSubviews() // don't supply focus state. Must not switch focus here! + } + + model.modifiedDetailsBuilder.set(\.password, value: newPassword) + } + + Divider() + .gridCellUnsizedAxes(.horizontal) + + + PasswordKey.DataEntry($repeatPassword) + .environment(\.passwordFieldType, .repeat) + .focused($focusedDataEntry, equals: "$-newPassword") + .managedValidation(input: repeatPassword, for: "$-newPassword", rules: passwordEqualityValidation(new: $newPassword)) + .environment(\.validationEngineConfiguration, .hideFailedValidationOnEmptySubmit) + } + } footer: { + PasswordValidationRuleFooter(configuration: service.configuration) + } + } + + + init(model: AccountOverviewFormViewModel, details accountDetails: AccountDetails) { + self.model = model + self.accountDetails = accountDetails + } + + func submitPasswordChange() async throws { + guard model.validationEngines.validateSubviews(focusState: $focusedDataEntry) else { + return + } + + focusedDataEntry = nil + + logger.debug("Saving updated password to AccountService!") + + try await model.updateAccountDetails(details: accountDetails) + dismiss() + } + + func passwordEqualityValidation(new newPassword: Binding) -> ValidationRule { + ValidationRule( + rule: { repeatPassword in + repeatPassword == newPassword.wrappedValue + }, + message: "VALIDATION_RULE_PASSWORDS_NOT_MATCHED", + bundle: .module + ) + } +} + + +#if DEBUG +struct PasswordChangeSheet_Previews: PreviewProvider { + static let details = AccountDetails.Builder() + .set(\.userId, value: "andi.bauer@tum.de") + .set(\.name, value: PersonNameComponents(givenName: "Andreas", familyName: "Bauer")) + .set(\.genderIdentity, value: .male) + + static let account = Account(building: details, active: MockUserIdPasswordAccountService()) + + // be aware, modifications won't be displayed due to declaration in PreviewProvider that do not trigger an UI update + @StateObject static var model = AccountOverviewFormViewModel(account: account) + + static var previews: some View { + NavigationStack { + if let details = account.details { + PasswordChangeSheet(model: model, details: details) + } + } + .environmentObject(account) + } +} +#endif diff --git a/Sources/SpeziAccount/Views/AccountOverview/PasswordValidationRuleFooter.swift b/Sources/SpeziAccount/Views/AccountOverview/PasswordValidationRuleFooter.swift new file mode 100644 index 00000000..84b6e073 --- /dev/null +++ b/Sources/SpeziAccount/Views/AccountOverview/PasswordValidationRuleFooter.swift @@ -0,0 +1,44 @@ +// +// 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 +// + +import SwiftUI + + +struct PasswordValidationRuleFooter: View { + private let configuration: AccountServiceConfiguration + + var body: some View { + let rules = (configuration.fieldValidationRules(for: PasswordKey.self) ?? []) + .filter { $0.id != ValidationRule.nonEmpty.id } + + VStack { + ForEach(rules) { rules in + HStack { + Text(rules.message) + Spacer() + } + } + .multilineTextAlignment(.leading) + } + } + + init(configuration: AccountServiceConfiguration) { + self.configuration = configuration + } +} + + +#if DEBUG +struct PasswordValidationRuleFooter_Previews: PreviewProvider { + static var previews: some View { + PasswordValidationRuleFooter(configuration: AccountServiceConfiguration(name: "Preview Service", supportedKeys: .arbitrary) { + FieldValidationRules(for: \.password, rules: .minimalPassword, .strongPassword) // doesn't make sense, but useful for preview + }) + } +} +#endif diff --git a/Sources/SpeziAccount/Views/AccountOverview/SecurityOverview.swift b/Sources/SpeziAccount/Views/AccountOverview/SecurityOverview.swift new file mode 100644 index 00000000..060996c8 --- /dev/null +++ b/Sources/SpeziAccount/Views/AccountOverview/SecurityOverview.swift @@ -0,0 +1,93 @@ +// +// 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 +// + +import SpeziViews +import SwiftUI + + +struct SecurityOverview: View { + private let accountDetails: AccountDetails + + private var service: any AccountService { + accountDetails.accountService + } + + + @EnvironmentObject private var account: Account + @ObservedObject private var model: AccountOverviewFormViewModel + + @State private var viewState: ViewState = .idle + @FocusState private var focusedDataEntry: String? + + @State private var presentingPasswordChangeSheet = false + + + var body: some View { + Form { + Button("Change Password", action: { + presentingPasswordChangeSheet = true + }) + .sheet(isPresented: $presentingPasswordChangeSheet) { + PasswordChangeSheet(model: model, details: accountDetails) + } + + // we place every account key of the `.credentials` section except the userId and password below + let forEachWrappers = model.accountKeys(by: .credentials, using: accountDetails) + .filter { $0 != UserIdKey.self && $0 != PasswordKey.self } + .map { ForEachAccountKeyWrapper($0) } + + + ForEach(forEachWrappers, id: \.id) { wrapper in + Section { + // 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. + AccountKeyEditRow(details: accountDetails, for: wrapper.accountKey, model: model) + } + } + .injectEnvironmentObjects(service: service, model: model, focusState: $focusedDataEntry) + .environment(\.defaultErrorDescription, model.defaultErrorDescription) + } + .viewStateAlert(state: $viewState) + .navigationTitle(model.accountSecurityLabel(account.configuration)) + .navigationBarTitleDisplayMode(.inline) + .onDisappear { + model.resetModelState() + } + } + + + init(model: AccountOverviewFormViewModel, details accountDetails: AccountDetails) { + self.model = model + self.accountDetails = accountDetails + } +} + + +#if DEBUG +struct SecurityOverview_Previews: PreviewProvider { + static let details = AccountDetails.Builder() + .set(\.userId, value: "andi.bauer@tum.de") + .set(\.name, value: PersonNameComponents(givenName: "Andreas", familyName: "Bauer")) + .set(\.genderIdentity, value: .male) + + static let account = Account(building: details, active: MockUserIdPasswordAccountService()) + + // be aware, modifications won't be displayed due to declaration in PreviewProvider that do not trigger an UI update + @StateObject static var model = AccountOverviewFormViewModel(account: account) + + static var previews: some View { + NavigationStack { + if let details = account.details { + SecurityOverview(model: model, details: details) + } + } + .environmentObject(account) + } +} +#endif diff --git a/Sources/SpeziAccount/Views/AccountOverview/SingleEditView.swift b/Sources/SpeziAccount/Views/AccountOverview/SingleEditView.swift new file mode 100644 index 00000000..092d3cfc --- /dev/null +++ b/Sources/SpeziAccount/Views/AccountOverview/SingleEditView.swift @@ -0,0 +1,98 @@ +// +// 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 +// + +import SpeziViews +import SwiftUI + + +struct SingleEditView: View { + private let accountDetails: AccountDetails + + private var service: any AccountService { + accountDetails.accountService + } + + + @Environment(\.logger) private var logger + @Environment(\.dismiss) private var dismiss + + @ObservedObject private var model: AccountOverviewFormViewModel + + @State private var viewState: ViewState = .idle + @FocusState private var focusedDataEntry: String? + + private var disabledDone: Bool { + !model.hasUnsavedChanges // we don't have any changes + || accountDetails.storage.get(Key.self) == model.modifiedDetailsBuilder.get(Key.self) // it's the same value + || !model.validationEngines.allInputValid // or the input isn't valid + } + + var body: some View { + Form { + VStack { + Key.dataEntryViewWithStoredValue(details: accountDetails, for: ModifiedAccountDetails.self) + } + } + .navigationTitle(Text(Key.self == UserIdKey.self ? accountDetails.userIdType.localizedStringResource : Key.name)) + .viewStateAlert(state: $viewState) + .injectEnvironmentObjects(service: service, model: model, focusState: $focusedDataEntry) + .environment(\.accountViewType, .overview(mode: .existing)) + .toolbar { + AsyncButton(state: $viewState, action: submitChange) { + Text("DONE", bundle: .module) + } + .disabled(disabledDone) + .environment(\.defaultErrorDescription, model.defaultErrorDescription) + } + .onDisappear { + model.resetModelState() + } + } + + + init(model: AccountOverviewFormViewModel, details accountDetails: AccountDetails) { + self.model = model + self.accountDetails = accountDetails + } + + + private func submitChange() async throws { + guard model.validationEngines.validateSubviews(focusState: $focusedDataEntry) else { + return + } + + focusedDataEntry = nil + + logger.debug("Saving updated \(Key.self) value!") + + try await model.updateAccountDetails(details: accountDetails) + dismiss() + } +} + +#if DEBUG +struct SingleEditView_Previews: PreviewProvider { + static let details = AccountDetails.Builder() + .set(\.userId, value: "andi.bauer@tum.de") + .set(\.name, value: PersonNameComponents(givenName: "Andreas", familyName: "Bauer")) + + static let account = Account(building: details, active: MockUserIdPasswordAccountService()) + + // be aware, modifications won't be displayed due to declaration in PreviewProvider that do not trigger an UI update + @StateObject static var model = AccountOverviewFormViewModel(account: account) + + static var previews: some View { + NavigationStack { + if let details = account.details { + SingleEditView(model: model, details: details) + } + } + .environmentObject(account) + } +} +#endif diff --git a/Sources/SpeziAccount/Views/AccountOverview/View+AccountOverviewObjects.swift b/Sources/SpeziAccount/Views/AccountOverview/View+AccountOverviewObjects.swift new file mode 100644 index 00000000..b9a1f498 --- /dev/null +++ b/Sources/SpeziAccount/Views/AccountOverview/View+AccountOverviewObjects.swift @@ -0,0 +1,30 @@ +// +// 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 +// + +import SwiftUI + + +extension View { + /// Shorthand modifier for all ``AccountOverview`` related views to inject all the required values and objects into the environment. + func injectEnvironmentObjects( + service: any AccountService, + model: AccountOverviewFormViewModel, + focusState: FocusState.Binding + ) -> some View { + self + .injectEnvironmentObjects(service: service, model: model) + .environmentObject(FocusStateObject(focusedField: focusState)) + } + + func injectEnvironmentObjects(service: any AccountService, model: AccountOverviewFormViewModel) -> some View { + self + .environment(\.accountServiceConfiguration, service.configuration) + .environmentObject(model.modifiedDetailsBuilder) + .environmentObject(model.validationEngines) + } +} diff --git a/Sources/SpeziAccount/Views/AccountSetup/AccountServiceButtonList.swift b/Sources/SpeziAccount/Views/AccountSetup/AccountServiceButtonList.swift new file mode 100644 index 00000000..5aa5bf8b --- /dev/null +++ b/Sources/SpeziAccount/Views/AccountSetup/AccountServiceButtonList.swift @@ -0,0 +1,56 @@ +// +// 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 +// + +import SwiftUI + + +struct AccountServiceButtonList: View { + private let services: [any AccountService] + + var body: some View { + // We use indices here as the preview provider has some issues with ForEach and a `any` existential. + // As the array doesn't change this is completely fine and the index is a stable identifier. + ForEach(services.indices, id: \.self) { index in + let service = services[index] + let style = service.viewStyle + + NavigationLink { + AnyView(style.makePrimaryView()) + } label: { + style.makeAnyAccountServiceButtonLabel() + } + } + } + + init(services: [any AccountService]) { + self.services = services + } +} + + +extension AccountSetupViewStyle { + fileprivate func makeAnyAccountServiceButtonLabel() -> AnyView { + // as the `AccountSetup` only has a type-erased view on the `AccountSetupViewStyle` + // we can't, because of the default implementation, create the AnyView inline. + AnyView(self.makeServiceButtonLabel()) + } +} + + +#if DEBUG +struct AccountServiceButtonList_Previews: PreviewProvider { + static var previews: some View { + NavigationStack { + AccountServiceButtonList(services: [ + MockUserIdPasswordAccountService(), + MockSimpleAccountService() + ]) + } + } +} +#endif diff --git a/Sources/SpeziAccount/Views/AccountSetup/AccountServicesSection.swift b/Sources/SpeziAccount/Views/AccountSetup/AccountServicesSection.swift new file mode 100644 index 00000000..42d9da81 --- /dev/null +++ b/Sources/SpeziAccount/Views/AccountSetup/AccountServicesSection.swift @@ -0,0 +1,78 @@ +// +// 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 +// + +import SwiftUI + + +struct AccountServicesSection: View { + private let services: [any AccountService] + + private var embeddableAccountService: (any EmbeddableAccountService)? { + let embeddableServices = services + .filter { $0 is any EmbeddableAccountService } + + if embeddableServices.count == 1 { + return embeddableServices.first as? any EmbeddableAccountService + } + + return nil + } + + private var nonEmbeddableAccountServices: [any AccountService] { + services + .filter { !($0 is any EmbeddableAccountService) } + } + + var body: some View { + if let embeddableService = embeddableAccountService { + let embeddableViewStyle = embeddableService.viewStyle + AnyView(embeddableViewStyle.makeEmbeddedAccountView()) + + if !nonEmbeddableAccountServices.isEmpty { + ServicesDivider() + + AccountServiceButtonList(services: nonEmbeddableAccountServices) + } else { + EmptyView() + } + } else { + // there is no primary embeddable account service, list all as buttons + AccountServiceButtonList(services: services) + } + } + + init(services: [any AccountService]) { + self.services = services + } +} + + +#if DEBUG +struct AccountServicesSection_Previews: PreviewProvider { + static var accountServicePermutations: [[any AccountService]] = { + [ + [MockUserIdPasswordAccountService()], + [MockSimpleAccountService()], + [MockUserIdPasswordAccountService(), MockSimpleAccountService()], + [ + MockUserIdPasswordAccountService(), + MockSimpleAccountService(), + MockUserIdPasswordAccountService() + ] + ] + }() + + static var previews: some View { + ForEach(accountServicePermutations.indices, id: \.self) { index in + NavigationStack { + AccountServicesSection(services: accountServicePermutations[index]) + } + } + } +} +#endif diff --git a/Sources/SpeziAccount/Views/AccountSetup/DefaultAccountSetupHeader.swift b/Sources/SpeziAccount/Views/AccountSetup/DefaultAccountSetupHeader.swift new file mode 100644 index 00000000..bd294f22 --- /dev/null +++ b/Sources/SpeziAccount/Views/AccountSetup/DefaultAccountSetupHeader.swift @@ -0,0 +1,54 @@ +// +// 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 +// + +import SwiftUI + + +/// A view which provides the default title and subtitle text. +/// +/// This view expects a ``Account`` object to be in the environment to dynamically +/// present the appropriate subtitle. +public struct DefaultAccountSetupHeader: View { + @EnvironmentObject private var account: Account + + public var body: some View { + VStack { + Text("ACCOUNT_WELCOME", bundle: .module) + .font(.largeTitle) + .bold() + .multilineTextAlignment(.center) + .padding(.bottom) + .padding(.top, 30) + + Group { + if !account.signedIn { + Text("ACCOUNT_WELCOME_SUBTITLE", bundle: .module) + } else { + Text("ACCOUNT_WELCOME_SIGNED_IN_SUBTITLE", bundle: .module) + } + } + .multilineTextAlignment(.center) + } + } + + /// Initialize a new account header. + public init() {} +} + + +#if DEBUG +struct DefaultAccountSetupHeader_Previews: PreviewProvider { + static var previews: some View { + DefaultAccountSetupHeader() + .environmentObject(Account()) + + DefaultAccountSetupHeader() + .environmentObject(Account(building: .init().set(\.userId, value: "myUser"), active: MockUserIdPasswordAccountService())) + } +} +#endif diff --git a/Sources/SpeziAccount/Views/AccountSetup/EmptyServicesWarning.swift b/Sources/SpeziAccount/Views/AccountSetup/EmptyServicesWarning.swift new file mode 100644 index 00000000..0766bf69 --- /dev/null +++ b/Sources/SpeziAccount/Views/AccountSetup/EmptyServicesWarning.swift @@ -0,0 +1,45 @@ +// +// 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 +// + +import SwiftUI + + +struct EmptyServicesWarning: View { + private var documentationUrl: URL { + // we may move to a #URL macro once Swift 5.9 is shipping + guard let docsUrl = URL(string: "https://swiftpackageindex.com/stanfordspezi/speziaccount/documentation/speziaccount/initial-setup") else { + fatalError("Failed to construct SpeziAccount Documentation URL. Please review URL syntax!") + } + + return docsUrl + } + + var body: some View { + VStack { + Text("MISSING_ACCOUNT_SERVICES", bundle: .module) + .multilineTextAlignment(.center) + .foregroundColor(.secondary) + + Button(action: { + UIApplication.shared.open(documentationUrl) + }) { + Text("OPEN_DOCUMENTATION", bundle: .module) + } + .padding() + } + } +} + + +#if DEBUG +struct EmptyServicesWarning_Previews: PreviewProvider { + static var previews: some View { + EmptyServicesWarning() + } +} +#endif diff --git a/Sources/SpeziAccount/Views/AccountSetup/ExistingAccountView.swift b/Sources/SpeziAccount/Views/AccountSetup/ExistingAccountView.swift new file mode 100644 index 00000000..0b6fd284 --- /dev/null +++ b/Sources/SpeziAccount/Views/AccountSetup/ExistingAccountView.swift @@ -0,0 +1,99 @@ +// +// 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 +// + +import SpeziViews +import SwiftUI + + +struct ExistingAccountView: View { + private let accountDetails: AccountDetails + + private var service: any AccountService { + accountDetails.accountService + } + + private let continueButton: Continue + + @State private var viewState: ViewState = .idle + + var body: some View { + VStack { + VStack { + AnyView(service.viewStyle.makeAnyAccountSummary(details: accountDetails)) + + AsyncButton(.init("UP_LOGOUT", bundle: .atURL(from: .module)), role: .destructive, state: $viewState) { + try await service.logout() + } + .environment(\.defaultErrorDescription, .init("UP_LOGOUT_FAILED_DEFAULT_ERROR", bundle: .atURL(from: .module))) + .padding() + } + } + .viewStateAlert(state: $viewState) + .toolbar { + ToolbarItemGroup(placement: .bottomBar) { + if Continue.self != EmptyView.self { + VStack { + continueButton + .padding() + Spacer() + .frame(height: 30) + } + } else { + EmptyView() + } + } + } + } + + /// Creates a new `ExistingAccountView` to render an already signed in user. + /// + /// - Note: When using a non empty `Continue` button, this view must be placed within a `NavigationStack` + /// in order to render the toolbar. + /// - Parameters: + /// - details: The ``AccountDetails`` to render. + /// - continue: An optional `Continue` button. + init(details: AccountDetails, @ViewBuilder `continue`: () -> Continue = { EmptyView() }) { + self.accountDetails = details + self.continueButton = `continue`() + } +} + + +extension AccountSetupViewStyle { + fileprivate func makeAnyAccountSummary(details: AccountDetails) -> AnyView { + AnyView(self.makeAccountSummary(details: details)) + } +} + + +#if DEBUG +struct ExistingAccountView_Previews: PreviewProvider { + static let details = AccountDetails.Builder() + .set(\.userId, value: "andi.bauer@tum.de") + .set(\.name, value: PersonNameComponents(givenName: "Andreas", familyName: "Bauer")) + .build(owner: MockUserIdPasswordAccountService()) + + static var previews: some View { + ExistingAccountView(details: details) + + NavigationStack { + ExistingAccountView(details: details) + } + + NavigationStack { + ExistingAccountView(details: details) { + Button(action: {}, label: { + Text("Continue") + .frame(maxWidth: .infinity, minHeight: 38) + }) + .buttonStyle(.borderedProminent) + } + } + } +} +#endif diff --git a/Sources/SpeziAccount/Views/AccountSetup/IdentityProviderSection.swift b/Sources/SpeziAccount/Views/AccountSetup/IdentityProviderSection.swift new file mode 100644 index 00000000..5a4b0e88 --- /dev/null +++ b/Sources/SpeziAccount/Views/AccountSetup/IdentityProviderSection.swift @@ -0,0 +1,42 @@ +// +// 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 +// + +import SwiftUI + + +struct IdentityProviderSection: View { + private let providers: [any IdentityProvider] + + var body: some View { + VStack { + ForEach(providers.indices, id: \.self) { index in + providers[index].viewStyle.makeAnySignInButton() + } + } + } + + init(providers: [any IdentityProvider]) { + self.providers = providers + } +} + + +extension IdentityProviderViewStyle { + func makeAnySignInButton() -> AnyView { + AnyView(self.makeSignInButton()) + } +} + + +#if DEBUG +struct IdentityProviderSection_Previews: PreviewProvider { + static var previews: some View { + IdentityProviderSection(providers: [MockSignInWithAppleProvider()]) + } +} +#endif diff --git a/Sources/SpeziAccount/Views/AccountSetup/ServicesDivider.swift b/Sources/SpeziAccount/Views/AccountSetup/ServicesDivider.swift new file mode 100644 index 00000000..b3a5b3ee --- /dev/null +++ b/Sources/SpeziAccount/Views/AccountSetup/ServicesDivider.swift @@ -0,0 +1,41 @@ +// +// 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 +// + +import SwiftUI + + +/// Simple `Divider` that reads "or" in the middle to divide sections that provide non-overlapping options of choice to the user. +struct ServicesDivider: View { + var body: some View { + HStack { + VStack { + Divider() + } + Text("OR", bundle: .module) + .padding(.horizontal, 8) + .font(.subheadline) + .foregroundColor(.secondary) + VStack { + Divider() + } + } + .padding(.horizontal, 36) + .padding(.vertical, 16) + } + + init() {} +} + + +#if DEBUG +struct ServicesDivider_Previews: PreviewProvider { + static var previews: some View { + ServicesDivider() + } +} +#endif diff --git a/Sources/SpeziAccount/Views/AccountSummaryBox.swift b/Sources/SpeziAccount/Views/AccountSummaryBox.swift new file mode 100644 index 00000000..464454f2 --- /dev/null +++ b/Sources/SpeziAccount/Views/AccountSummaryBox.swift @@ -0,0 +1,88 @@ +// +// This source file is part of the Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SpeziViews +import SwiftUI + + +/// A simple account summary displayed in the ``AccountSetup`` view when there is already a signed in user account. +public struct AccountSummaryBox: View { + private let model: AccountDisplayModel + + public var body: some View { + HStack(spacing: 16) { + Group { + if let profileViewName = model.profileViewName { + UserProfileView(name: profileViewName) + .frame(height: 40) + } else { + Image(systemName: "person.circle.fill") + .resizable() + .frame(width: 40, height: 40) + .symbolRenderingMode(.hierarchical) + .foregroundColor(Color(.systemGray)) + .accessibilityHidden(true) + } + } + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: 4) { + Text(model.accountHeadline) + if let subheadline = model.accountSubheadline { + Text(subheadline) + .font(.subheadline) + .foregroundColor(.secondary) + } + } + Spacer() + } + .padding() + .background( + RoundedRectangle(cornerRadius: 8) + .fill(.background) + .shadow(color: .gray, radius: 2) + ) + .frame(maxWidth: ViewSizing.maxFrameWidth) + .accessibilityElement(children: .combine) + } + + /// Create a new `AccountSummaryBox` + /// - Parameter details: The ``AccountDetails`` to render. + public init(details: AccountDetails) { + self.model = AccountDisplayModel(details: details) + } +} + +#if DEBUG +struct AccountSummary_Previews: PreviewProvider { + static let emailDetails = AccountDetails.Builder() + .set(\.userId, value: "andi.bauer@tum.de") + .set(\.name, value: PersonNameComponents(givenName: "Andreas", familyName: "Bauer")) + .build(owner: MockUserIdPasswordAccountService()) + + static let usernameDetails = AccountDetails.Builder() + .set(\.userId, value: "andreas.bauer") + .set(\.name, value: PersonNameComponents(givenName: "Andreas", familyName: "Bauer")) + .build(owner: MockUserIdPasswordAccountService(.username)) + + static let usernameWithoutNameDetails = AccountDetails.Builder() + .set(\.userId, value: "andreas.bauer") + .build(owner: MockUserIdPasswordAccountService(.username)) + + static let emailOnlyDetails = AccountDetails.Builder() + .set(\.userId, value: "andi.bauer@tum.de") + .build(owner: MockUserIdPasswordAccountService()) + + static var previews: some View { + AccountSummaryBox(details: emailDetails) + AccountSummaryBox(details: usernameDetails) + AccountSummaryBox(details: usernameWithoutNameDetails) + AccountSummaryBox(details: emailOnlyDetails) + } +} +#endif diff --git a/Sources/SpeziAccount/Views/DataDisplay/DataDisplayView.swift b/Sources/SpeziAccount/Views/DataDisplay/DataDisplayView.swift new file mode 100644 index 00000000..8f5f77f0 --- /dev/null +++ b/Sources/SpeziAccount/Views/DataDisplay/DataDisplayView.swift @@ -0,0 +1,27 @@ +// +// 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 +// + +import Spezi +import SwiftUI + + +/// A view that displays the current value of a given ``AccountKey`` implementation. +/// +/// This view is default implemented for all ``AccountKey``s which value type is `String`-based or conforms +/// to `CustomLocalizedStringResourceConvertible`. So before implementing one yourself verify if you might be able +/// to rely on the default implementation. +/// +/// This view is typically placed as a row in the ``AccountOverview`` view. +public protocol DataDisplayView: View { + /// The ``AccountKey`` this view displays the value for. + associatedtype Key: AccountKey + + /// Create a new display view. + /// - Parameter value: The current account value. + init(_ value: Key.Value) +} diff --git a/Sources/SpeziAccount/Views/DataDisplay/LocalizableStringBasedDisplayView.swift b/Sources/SpeziAccount/Views/DataDisplay/LocalizableStringBasedDisplayView.swift new file mode 100644 index 00000000..8f16ef07 --- /dev/null +++ b/Sources/SpeziAccount/Views/DataDisplay/LocalizableStringBasedDisplayView.swift @@ -0,0 +1,44 @@ +// +// 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 +// + +import Spezi +import SwiftUI + + +/// A ``DataDisplayView`` implementation for all ``AccountKey`` `Value` types that conform to `CustomLocalizedStringResourceConvertible`. +public struct LocalizableStringBasedDisplayView: DataDisplayView + where Key.Value: CustomLocalizedStringResourceConvertible { + private let value: Key.Value + + public var body: some View { + SimpleTextRow(name: Key.name) { + Text(value.localizedStringResource) + } + } + + + public init(_ value: Key.Value) { + self.value = value + } +} + + +extension Bool: CustomLocalizedStringResourceConvertible { + public var localizedStringResource: LocalizedStringResource { + .init(self ? "YES" : "NO", bundle: .atURL(from: .module)) + } +} + + +#if DEBUG +struct LocalizableStringBasedDisplayView_Previews: PreviewProvider { + static var previews: some View { + LocalizableStringBasedDisplayView(.preferNotToState) + } +} +#endif diff --git a/Sources/SpeziAccount/Views/DataDisplay/SimpleTextRow.swift b/Sources/SpeziAccount/Views/DataDisplay/SimpleTextRow.swift new file mode 100644 index 00000000..8c92d0da --- /dev/null +++ b/Sources/SpeziAccount/Views/DataDisplay/SimpleTextRow.swift @@ -0,0 +1,41 @@ +// +// 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 +// + +import SwiftUI + + +struct SimpleTextRow: View { + private let name: LocalizedStringResource + private let value: Value + + var body: some View { + HStack { + Text(name) + Spacer() + value + .foregroundColor(.secondary) + } + .accessibilityElement(children: .combine) + } + + init(name: LocalizedStringResource, @ViewBuilder value: () -> Value) { + self.name = name + self.value = value() + } +} + + +#if DEBUG +struct SimpleTextRow_Previews: PreviewProvider { + static var previews: some View { + SimpleTextRow(name: "Hello") { + Text("World") + } + } +} +#endif diff --git a/Sources/SpeziAccount/Views/DataDisplay/StringBasedDisplayView.swift b/Sources/SpeziAccount/Views/DataDisplay/StringBasedDisplayView.swift new file mode 100644 index 00000000..d8d8c317 --- /dev/null +++ b/Sources/SpeziAccount/Views/DataDisplay/StringBasedDisplayView.swift @@ -0,0 +1,35 @@ +// +// 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 +// + +import Spezi +import SwiftUI + + +/// A ``DataDisplayView`` implementation for all ``AccountKey`` `Value` types that conform to `StringProtocol`. +public struct StringBasedDisplayView: DataDisplayView where Key.Value: StringProtocol { + private let value: Key.Value + + public var body: some View { + SimpleTextRow(name: Key.name) { + Text(value) + } + } + + public init(_ value: Key.Value) { + self.value = value + } +} + + +#if DEBUG +struct StringDataDisplayView_Previews: PreviewProvider { + static var previews: some View { + StringBasedDisplayView("andreas.bauer") + } +} +#endif diff --git a/Sources/SpeziAccount/Views/DataEntry/DataEntryView.swift b/Sources/SpeziAccount/Views/DataEntry/DataEntryView.swift new file mode 100644 index 00000000..9ae26da3 --- /dev/null +++ b/Sources/SpeziAccount/Views/DataEntry/DataEntryView.swift @@ -0,0 +1,25 @@ +// +// 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 +// + +import Spezi +import SwiftUI + + +/// A view that handles entry of a new value of a given ``AccountKey`` implementation. +/// +/// This view is used in views like the ``SignupForm`` or ``AccountOverview`` to enter or modify a account value. +/// +/// - Note: The ``AccountKey/initialValue-6h1oo`` is used in views like the ``SignupForm`` as the initial value for this view. +public protocol DataEntryView: View { + /// The ``AccountKey`` this view receives a value for. + associatedtype Key: AccountKey + + /// Creates a new data entry view. + /// - Parameter value: A binding to store the current and change value. + init(_ value: Binding) +} diff --git a/Sources/SpeziAccount/Views/DataEntry/GeneralizedDataEntryView.swift b/Sources/SpeziAccount/Views/DataEntry/GeneralizedDataEntryView.swift new file mode 100644 index 00000000..a1940705 --- /dev/null +++ b/Sources/SpeziAccount/Views/DataEntry/GeneralizedDataEntryView.swift @@ -0,0 +1,103 @@ +// +// 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 +// + +import Spezi +import SwiftUI + + +/// Helper protocol to easily retrieve Wrapped.Key types with String value +private protocol GeneralizedStringEntryView { + func validationRules() -> [ValidationRule] +} + + +/// A View to manage the state of a ``DataEntryView``. +/// +/// Every ``DataEntryView`` is wrapped into a `GeneralizedDataEntryView` which is responsible to manage state of its child-view. +/// Particularly, the following things are taken care of: +/// - Declare and manage the state of the value and post any changes back up to the parent view. +/// - Declare a default `focused(_:equals:)` modifier to String-based fields to automatically manage focus state based on ``AccountKey/focusState``. +/// - If the value is of type `String` and the ``AccountService`` has a ``FieldValidationRules`` configuration for the given +/// ``DataEntryView/Key``, a ``SwiftUI/View/managedValidation(input:for:rules:)-5gj5g`` modifier is automatically injected. One can easily override +/// the modified by declaring a custom one in the subview. +public struct GeneralizedDataEntryView: View { + @EnvironmentObject private var account: Account + + @EnvironmentObject private var focusState: FocusStateObject + @EnvironmentObject private var detailsBuilder: AccountValuesBuilder + + @Environment(\.accountServiceConfiguration) private var configuration + @Environment(\.accountViewType) private var viewType + + @State private var value: Wrapped.Key.Value + + + public var body: some View { + Group { + if let stringValue = value as? String, + let stringEntryView = self as? GeneralizedStringEntryView { + // if we have a string value, we have to check if FieldValidationRules is configured and + // inject a ValidationEngine into the environment + Wrapped($value) + .managedValidation(input: stringValue, for: Wrapped.Key.focusState, rules: stringEntryView.validationRules()) + } else if case .empty = Wrapped.Key.initialValue, + account.configuration[Wrapped.Key.self]?.requirement == .required { + // If the field provides an empty value and is required, we inject a `nonEmpty` validation rule + // if there isn't a validation engine already in the subview! + Wrapped($value) + // checks that non-string values are supplied if configured to be `.required` + .requiredValidation(for: Wrapped.Key.self, storage: Values.self, $value) + } else { + Wrapped($value) + } + } + .focused(focusState.projectedValue, equals: Wrapped.Key.focusState) + .onAppear { + // values like `GenderIdentity` provide a default value a user might not want to change + if viewType?.enteringNewData == true, + case let .default(value) = Wrapped.Key.initialValue { + detailsBuilder.set(Wrapped.Key.self, value: value) + } + } + .onChange(of: value) { newValue in + // ensure parent view has access to the latest value + if viewType?.enteringNewData == true, + case let .empty(emptyValue) = Wrapped.Key.initialValue, + newValue == emptyValue { + // e.g. make sure we don't save an empty value (e.g. an empty PersonNameComponents) + detailsBuilder.remove(Wrapped.Key.self) + } else { + detailsBuilder.set(Wrapped.Key.self, value: newValue) + } + } + } + + + /// Initialize a new GeneralizedDataEntryView given a `Wrapped` view. + /// - Parameter signupValue: The initial value to use. Typically you want to pass some "empty" value. + init(initialValue signupValue: Wrapped.Key.Value) { + self._value = State(wrappedValue: signupValue) + } +} + + +extension GeneralizedDataEntryView: GeneralizedStringEntryView where Wrapped.Key.Value == String { + func validationRules() -> [ValidationRule] { + if let rules = configuration.fieldValidationRules(for: Wrapped.Key.self) { + return rules + } + + if account.configuration[Wrapped.Key.self]?.requirement == .required { + return [.nonEmpty] + } + + // we always want to inject a validation engine. Otherwise, account key would need to check the existence + // of an environment object. + return [] + } +} diff --git a/Sources/SpeziAccount/Views/Fields/DateOfBirthPicker.swift b/Sources/SpeziAccount/Views/Fields/DateOfBirthPicker.swift new file mode 100644 index 00000000..b2db7123 --- /dev/null +++ b/Sources/SpeziAccount/Views/Fields/DateOfBirthPicker.swift @@ -0,0 +1,150 @@ +// +// This source file is part of the Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SwiftUI + + +/// A simple `DatePicker` implementation tailored towards entry of a date of birth. +public struct DateOfBirthPicker: DataEntryView { + public typealias Key = DateOfBirthKey + + private let titleLocalization: LocalizedStringResource + + @EnvironmentObject private var account: Account + @Environment(\.accountViewType) private var viewType + + @Binding private var date: Date + @State private var dateAdded = false + + private var dateRange: ClosedRange { + let calendar = Calendar.current + let startDateComponents = DateComponents(year: 1800, month: 1, day: 1) + let endDate = Date.now + + guard let startDate = calendar.date(from: startDateComponents) else { + fatalError("Could not translate \(startDateComponents) to a valid date.") + } + + return startDate...endDate + } + + + /// We want to show the picker if + /// - The date is configured to be required. + /// - We are NOT entering new date. Showing existing data the user might want to change. + /// - If we are entering new data and the user pressed the add button. + private var showPicker: Bool { + account.configuration[Key.self]?.requirement == .required + || viewType?.enteringNewData == false + || dateAdded + } + + + public var body: some View { + HStack { + Text(titleLocalization) + .multilineTextAlignment(.leading) + Spacer() + + if showPicker { + DatePicker( + selection: $date, + in: dateRange, + displayedComponents: .date + ) { + Text(titleLocalization) + } + .labelsHidden() + } else { + Button(action: addDateAction) { + Text("ADD_DATE", bundle: .module) + .multilineTextAlignment(.center) + .foregroundColor(.primary) + .frame(width: 110, height: 34) + } + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color(uiColor: .tertiarySystemFill)) + ) + } + } + .accessibilityRepresentation { + // makes sure the accessibility view spans the whole width, including the label. + if showPicker { + DatePicker(selection: $date, in: dateRange, displayedComponents: .date) { + Text(titleLocalization) + } + } else { + Button(action: addDateAction) { + Text("VALUE_ADD \(titleLocalization)", bundle: .module) + .frame(maxWidth: .infinity) + } + } + } + } + + + /// Initialize a new `DateOfBirthPicker`. + /// - Parameters: + /// - date: A binding to the `Date` state. + /// - customTitle: Optionally provide a custom label text. + public init( + date: Binding, + title customTitle: LocalizedStringResource? = nil + ) { + self._date = date + self.titleLocalization = customTitle ?? DateOfBirthKey.name + } + + public init(_ value: Binding) { + self.init(date: value) + } + + + private func addDateAction() { + dateAdded = true + } +} + + +#if DEBUG +struct DateOfBirthPicker_Previews: PreviewProvider { + struct Preview: View { + @State private var date = Date.now + + var body: some View { + VStack { + Form { + DateOfBirthPicker(date: $date) + } + .frame(height: 200) + DateOfBirthPicker(date: $date) + .padding(32) + } + .background(Color(.systemGroupedBackground)) + } + } + + static var previews: some View { + // preview entering new data. + Preview() + .environmentObject(Account(MockUserIdPasswordAccountService())) + .environment(\.accountViewType, .signup) + + // preview entering new data but displaying existing data. + Preview() + .environmentObject(Account(MockUserIdPasswordAccountService())) + .environment(\.accountViewType, .overview(mode: .existing)) + + // preview entering new data but required. + Preview() + .environment(\.accountViewType, .signup) + .environmentObject(Account(MockUserIdPasswordAccountService(), configuration: [.requires(\.dateOfBirth)])) + } +} +#endif diff --git a/Sources/SpeziAccount/Username and Password/Sign Up/GenderIdentityPicker.swift b/Sources/SpeziAccount/Views/Fields/GenderIdentityPicker.swift similarity index 56% rename from Sources/SpeziAccount/Username and Password/Sign Up/GenderIdentityPicker.swift rename to Sources/SpeziAccount/Views/Fields/GenderIdentityPicker.swift index d9b341db..64518db0 100644 --- a/Sources/SpeziAccount/Username and Password/Sign Up/GenderIdentityPicker.swift +++ b/Sources/SpeziAccount/Views/Fields/GenderIdentityPicker.swift @@ -9,22 +9,13 @@ import SwiftUI -struct GenderIdentityPicker: View { +/// A simple `Picker` implementation for ``GenderIdentity`` entry. +public struct GenderIdentityPicker: View { + private let titleLocalization: LocalizedStringResource + @Binding private var genderIdentity: GenderIdentity - @EnvironmentObject private var localizationEnvironmentObject: UsernamePasswordAccountService - private let localization: ConfigurableLocalization - - - private var genderIdentityTitle: LocalizedStringResource { - switch localization { - case .environment: - return localizationEnvironmentObject.localization.signUp.genderIdentityTitle - case let .value(genderIdentityTitle): - return genderIdentityTitle - } - } - var body: some View { + public var body: some View { Picker( selection: $genderIdentity, content: { @@ -33,22 +24,21 @@ struct GenderIdentityPicker: View { .tag(genderIdentity) } }, label: { - Text(genderIdentityTitle) - .fontWeight(.semibold) + Text(titleLocalization) } ) } - - - init(genderIdentity: Binding, title: LocalizedStringResource) { - self._genderIdentity = genderIdentity - self.localization = .value(title) - } - - - init(genderIdentity: Binding) { + + /// Initialize a new `GenderIdentityPicker`. + /// - Parameters: + /// - genderIdentity: A binding to the ``GenderIdentity`` state. + /// - customTitle: Optionally provide a custom label text. + public init( + genderIdentity: Binding, + title customTitle: LocalizedStringResource? = nil + ) { self._genderIdentity = genderIdentity - self.localization = .environment + self.titleLocalization = customTitle ?? GenderIdentityKey.name } } @@ -71,7 +61,6 @@ struct GenderIdentityPicker_Previews: PreviewProvider { } .padding(32) } - .environmentObject(UsernamePasswordAccountService()) .background(Color(.systemGroupedBackground)) } } diff --git a/Sources/SpeziAccount/Views/Fields/ValidationResultsView.swift b/Sources/SpeziAccount/Views/Fields/ValidationResultsView.swift new file mode 100644 index 00000000..4cefff64 --- /dev/null +++ b/Sources/SpeziAccount/Views/Fields/ValidationResultsView.swift @@ -0,0 +1,43 @@ +// +// 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 +// + +import SwiftUI + +/// A view that displays the results of a ``ValidationEngine`` (see ``FailedValidationResult``). +public struct ValidationResultsView: View { + private let results: [FailedValidationResult] + + public var body: some View { + VStack(alignment: .leading) { + ForEach(results) { result in + Text(result.message) + .fixedSize(horizontal: false, vertical: true) + } + } + .font(.footnote) + .foregroundColor(.red) + } + + /// Create a new view. + /// - Parameter results: The array of ``FailedValidationResult`` coming from the ``ValidationEngine``. + public init(results: [FailedValidationResult]) { + self.results = results + } +} + + +#if DEBUG +struct ValidationResultsView_Previews: PreviewProvider { + static var previews: some View { + ValidationResultsView(results: [ + .init(from: .nonEmpty), + .init(from: .mediumPassword) + ]) + } +} +#endif diff --git a/Sources/SpeziAccount/Views/Fields/VerifiableTextField.swift b/Sources/SpeziAccount/Views/Fields/VerifiableTextField.swift new file mode 100644 index 00000000..f08c1fed --- /dev/null +++ b/Sources/SpeziAccount/Views/Fields/VerifiableTextField.swift @@ -0,0 +1,121 @@ +// +// This source file is part of the Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SwiftUI + + +/// A `TextField` that automatically handles validation of input. +/// +/// This text field expects a ``ValidationEngine`` object in the environment. The engine is used +/// to validate the text field input. A ``ValidationResultsView`` is used to automatically display +/// recovery suggestions for failed ``ValidationRule`` below the text field. +public struct VerifiableTextField: View { + /// The type of text field. + public enum TextFieldType { + /// A standard `TextField`. + case text + /// A `SecureField`. + case secure + } + + private let label: FieldLabel + private let textFieldFooter: FieldFooter + private let fieldType: TextFieldType + + @Binding private var text: String + + @EnvironmentObject var validationEngine: ValidationEngine + + public var body: some View { + VStack { + Group { + switch fieldType { + case .text: + TextField(text: $text, label: { label }) + case .secure: + SecureField(text: $text, label: { label }) + } + } + .onSubmit { + validationEngine.submit(input: text, debounce: false) + } + + HStack { + ValidationResultsView(results: validationEngine.displayedValidationResults) + + Spacer() + + textFieldFooter + } + } + .onChange(of: text) { _ in + validationEngine.submit(input: text, debounce: true) + } + } + + + /// Create a new verifiable text field. + /// - Parameters: + /// - label: The localized text label for the text field. + /// - text: The binding to the stored value. + /// - type: An optional ``TextFieldType``. + /// - footer: An optional footer displayed below the text field next to the ``ValidationResultsView``. + public init( + _ label: LocalizedStringResource, + text: Binding, + type: TextFieldType = .text, + @ViewBuilder footer: () -> FieldFooter = { EmptyView() } + ) where FieldLabel == Text { + self.init(text: text, type: type, label: { Text(label) }, footer: footer) + } + + /// Create a new verifiable text field. + /// - Parameters: + /// - text: The binding to the stored value. + /// - type: An optional ``TextFieldType``. + /// - label: An arbitrary label for the text field. + /// - footer: An optional footer displayed below the text field next to the ``ValidationResultsView`` + public init( + text: Binding, + type: TextFieldType = .text, + @ViewBuilder label: () -> FieldLabel, + @ViewBuilder footer: () -> FieldFooter = { EmptyView() } + ) { + self._text = text + self.fieldType = type + self.label = label() + self.textFieldFooter = footer() + } +} + + +#if DEBUG +struct VerifiableTextField_Previews: PreviewProvider { + private struct PreviewView: View { + @State var text = "" + @StateObject var engine = ValidationEngine(rules: .nonEmpty) + + var body: some View { + VerifiableTextField(text: $text) { + Text("Password Text") + } footer: { + Text("Some Hint") + .font(.footnote) + } + .environmentObject(engine) + } + } + static var previews: some View { + Form { + PreviewView() + } + + PreviewView() + } +} +#endif diff --git a/Sources/SpeziAccount/Views/SignupForm.swift b/Sources/SpeziAccount/Views/SignupForm.swift new file mode 100644 index 00000000..d62ad2b4 --- /dev/null +++ b/Sources/SpeziAccount/Views/SignupForm.swift @@ -0,0 +1,138 @@ +// +// This source file is part of the Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import OrderedCollections +import SpeziViews +import SwiftUI + + +/// A generalized signup form used with arbitrary ``AccountService`` implementations. +/// +/// A `Form` that collects all configured account values (a ``AccountValueConfiguration`` supplied to ``AccountConfiguration``) +/// split into `Section`s according the their ``AccountKeyCategory`` (see ``AccountKey/category``). +public struct SignupForm: View { + private let service: Service + private let header: Header + + @EnvironmentObject private var account: Account + @Environment(\.dismiss) private var dismiss + + @StateObject private var signupDetailsBuilder = SignupDetails.Builder() + @StateObject private var validationEngines = ValidationEngines() + + @State private var viewState: ViewState = .idle + @FocusState private var focusedDataEntry: String? // see `AccountKey.Type/focusState` + + + private var signupValuesBySections: OrderedDictionary { + account.configuration.reduce(into: [:]) { result, configuration in + guard configuration.requirement != .supported else { + // we only show required and collected values in signup + return + } + + result[configuration.key.category, default: []] += [configuration.key] + } + } + + + public var body: some View { + form + .navigationTitle(Text("UP_SIGNUP", bundle: .module)) + .disableDismissiveActions(isProcessing: viewState) + .viewStateAlert(state: $viewState) + } + + @ViewBuilder var form: some View { + Form { + header + + sectionsView + .environment(\.accountServiceConfiguration, service.configuration) + .environment(\.accountViewType, .signup) + .environmentObject(signupDetailsBuilder) + .environmentObject(validationEngines) + .environmentObject(FocusStateObject(focusedField: $focusedDataEntry)) + + AsyncButton(state: $viewState, action: signupButtonAction) { + Text("UP_SIGNUP", bundle: .module) + .padding(16) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .disabled(!validationEngines.allInputValid) + .padding() + .padding(-36) + .listRowBackground(Color.clear) + } + .environment(\.defaultErrorDescription, .init("UP_SIGNUP_FAILED_DEFAULT_ERROR", bundle: .atURL(from: .module))) + } + + @ViewBuilder var sectionsView: some View { + // OrderedDictionary `elements` conforms to RandomAccessCollection so we can directly use it + ForEach(signupValuesBySections.elements, id: \.key) { category, accountKeys in + Section { + // the array doesn't change, so its fine to rely on the indices as identifiers + ForEach(accountKeys.indices, id: \.self) { index in + VStack { + accountKeys[index].emptyDataEntryView(for: SignupDetails.self) + } + } + } header: { + if let title = category.categoryTitle { + Text(title) + } + } footer: { + if category == .credentials && account.configuration[PasswordKey.self] != nil { + PasswordValidationRuleFooter(configuration: service.configuration) + } + } + } + } + + + init(using service: Service) where Header == Text { + self.service = service + self.header = Text("UP_SIGNUP_INSTRUCTIONS", bundle: .module) + } + + init(service: Service, @ViewBuilder header: () -> Header) { + self.service = service + self.header = header() + } + + + private func signupButtonAction() async throws { + guard validationEngines.validateSubviews(focusState: $focusedDataEntry) else { + return + } + + focusedDataEntry = nil + + let request: SignupDetails = try signupDetailsBuilder.build(checking: account.configuration) + + try await service.signUp(signupDetails: request) + + // go back if the view doesn't update anyway + dismiss() + } +} + + +#if DEBUG +struct DefaultUserIdPasswordSignUpView_Previews: PreviewProvider { + static let accountService = MockUserIdPasswordAccountService() + + static var previews: some View { + NavigationStack { + SignupForm(using: accountService) + } + .environmentObject(Account(accountService)) + } +} +#endif diff --git a/Sources/SpeziAccount/Views/SuccessfulPasswordResetView.swift b/Sources/SpeziAccount/Views/SuccessfulPasswordResetView.swift new file mode 100644 index 00000000..59eba5e1 --- /dev/null +++ b/Sources/SpeziAccount/Views/SuccessfulPasswordResetView.swift @@ -0,0 +1,54 @@ +// +// This source file is part of the Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SwiftUI + + +/// A simple success view for a password reset view. +public struct SuccessfulPasswordResetView: View { + private let successfulLabelLocalization: LocalizedStringResource + + public var body: some View { + Spacer() + + VStack(spacing: 32) { + Image(systemName: "checkmark.circle.fill") + .symbolRenderingMode(.hierarchical) + .resizable() + .foregroundColor(.green) + .frame(width: 100, height: 100) + .accessibilityHidden(true) + Text(successfulLabelLocalization) + .multilineTextAlignment(.center) + .lineLimit(nil) + } + .padding(32) + + Spacer() + Spacer() + } + + + /// Create a new success view. + /// - Parameter successfulLabel: Optionally a customized label localization. + public init(successfulLabel: LocalizedStringResource? = nil) { + self.successfulLabelLocalization = successfulLabel + ?? LocalizedStringResource("UAP_RESET_PASSWORD_PROCESS_SUCCESSFUL_LABEL", bundle: .atURL(from: .module)) + } +} + + +#if DEBUG +struct DefaultSuccessfulPasswordResetView_Previews: PreviewProvider { + static var previews: some View { + NavigationStack { + SuccessfulPasswordResetView() + } + } +} +#endif diff --git a/Sources/SpeziAccount/Views/UserIdPasswordEmbeddedView.swift b/Sources/SpeziAccount/Views/UserIdPasswordEmbeddedView.swift new file mode 100644 index 00000000..112aaa6d --- /dev/null +++ b/Sources/SpeziAccount/Views/UserIdPasswordEmbeddedView.swift @@ -0,0 +1,162 @@ +// +// This source file is part of the Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Foundation +import SpeziViews +import SwiftUI + + +private enum LoginFocusState { + case userId + case password +} + + +/// A default implementation for the embedded view of a ``UserIdPasswordAccountService``. +/// +/// Every ``EmbeddableAccountService`` might provide a view that is directly integrated into the ``AccountSetup`` +/// view for more easy navigation. This view implements such a view for ``UserIdPasswordAccountService``-based +/// account service implementations. +public struct UserIdPasswordEmbeddedView: View { + private let service: Service + private var userIdConfiguration: UserIdConfiguration { + service.configuration.userIdConfiguration + } + + @EnvironmentObject private var account: Account + + @State private var userId: String = "" + @State private var password: String = "" + + @State private var state: ViewState = .idle + @FocusState private var focusedField: LoginFocusState? + + // for login we do all checks server-side. Except that we don't pass empty values. + @StateObject private var userIdValidation = ValidationEngine(rules: [.nonEmpty], configuration: .hideFailedValidationOnEmptySubmit) + @StateObject private var passwordValidation = ValidationEngine(rules: [.nonEmpty], configuration: .hideFailedValidationOnEmptySubmit) + + @State private var presentingPasswordForgetSheet = false + + @State private var loginTask: Task? { + willSet { + loginTask?.cancel() + } + } + + public var body: some View { + VStack { + fields + .padding(.vertical, 0) + + AsyncButton(state: $state, action: loginButtonAction) { + Text("UP_LOGIN", bundle: .module) + .padding(8) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .disabled(!userIdValidation.inputValid || !passwordValidation.inputValid) + .environment(\.defaultErrorDescription, .init("UP_LOGIN_FAILED_DEFAULT_ERROR", bundle: .atURL(from: .module))) + .padding(.bottom, 12) + .padding(.top) + + + HStack { + Text("UP_NO_ACCOUNT_YET", bundle: .module) + NavigationLink { + service.viewStyle.makeSignupView() + } label: { + Text("UP_SIGNUP", bundle: .module) + } + } + .font(.footnote) + } + .disableDismissiveActions(isProcessing: state) + .viewStateAlert(state: $state) + .sheet(isPresented: $presentingPasswordForgetSheet) { + NavigationStack { + service.viewStyle.makePasswordResetView() + .navigationBarTitleDisplayMode(.inline) + } + } + .onTapGesture { + focusedField = nil + } + } + + + @ViewBuilder private var fields: some View { + VStack { + Group { + VerifiableTextField(userIdConfiguration.idType.localizedStringResource, text: $userId) + .environmentObject(userIdValidation) + .textContentType(userIdConfiguration.textContentType) + .keyboardType(userIdConfiguration.keyboardType) + .onTapFocus(focusedField: $focusedField, fieldIdentifier: .userId) + .padding(.bottom, 0.5) + + VerifiableTextField(.init("UP_PASSWORD", bundle: .atURL(from: .module)), text: $password, type: .secure) { + Button(action: { + presentingPasswordForgetSheet = true + }) { + Text("UP_FORGOT_PASSWORD", bundle: .module) + .font(.caption) + .bold() + .foregroundColor(Color(uiColor: .systemGray)) + } + } + .environmentObject(passwordValidation) + .textContentType(.password) + .onTapFocus(focusedField: $focusedField, fieldIdentifier: .password) + } + .disableFieldAssistants() + .textFieldStyle(.roundedBorder) + .font(.title3) + } + } + + + /// Create a new embedded view. + /// - Parameter service: The ``UserIdPasswordAccountService`` instance. + public init(using service: Service) { + self.service = service + } + + + private func loginButtonAction() async throws { + userIdValidation.runValidation(input: userId) + passwordValidation.runValidation(input: password) + + guard userIdValidation.inputValid else { + focusedField = .userId + return + } + + guard passwordValidation.inputValid else { + focusedField = .password + return + } + + focusedField = nil + + try await service.login(userId: userId, password: password) + } +} + + +#if DEBUG +struct DefaultUserIdPasswordBasedEmbeddedView_Previews: PreviewProvider { + static let accountService = MockUserIdPasswordAccountService() + + static var previews: some View { + NavigationStack { + UserIdPasswordEmbeddedView(using: accountService) + .environmentObject(Account(accountService)) + } + } +} +#endif diff --git a/Sources/SpeziAccount/Views/UserIdPasswordPrimaryView.swift b/Sources/SpeziAccount/Views/UserIdPasswordPrimaryView.swift new file mode 100644 index 00000000..96a77e38 --- /dev/null +++ b/Sources/SpeziAccount/Views/UserIdPasswordPrimaryView.swift @@ -0,0 +1,61 @@ +// +// This source file is part of the Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SpeziViews +import SwiftUI + + +/// A primary view implementation for a ``UserIdPasswordAccountService``. +public struct UserIdPasswordPrimaryView: View { + private let service: Service + + + public var body: some View { + GeometryReader { proxy in + ScrollView(.vertical) { + VStack { + DefaultAccountSetupHeader() + + Spacer() + + VStack { + UserIdPasswordEmbeddedView(using: service) + } + .padding(.horizontal, ViewSizing.innerHorizontalPadding) + .frame(maxWidth: ViewSizing.maxFrameWidth) + + Spacer() + Spacer() + Spacer() + } + .padding(.horizontal, ViewSizing.outerHorizontalPadding) + .frame(maxWidth: .infinity, minHeight: proxy.size.height) + } + } + } + + + init(using service: Service) { + self.service = service + } +} + + +#if DEBUG +struct DefaultUserIdPasswordPrimaryView_Previews: PreviewProvider { + static let accountService = MockUserIdPasswordAccountService() + + + static var previews: some View { + NavigationStack { + UserIdPasswordPrimaryView(using: accountService) + .environmentObject(Account(accountService)) + } + } +} +#endif diff --git a/Sources/SpeziAccount/Views/UserIdPasswordResetView.swift b/Sources/SpeziAccount/Views/UserIdPasswordResetView.swift new file mode 100644 index 00000000..3128489c --- /dev/null +++ b/Sources/SpeziAccount/Views/UserIdPasswordResetView.swift @@ -0,0 +1,158 @@ +// +// This source file is part of the Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Foundation +import SpeziViews +import SwiftUI + + +private enum PasswordResetFocusState { + case userId +} + + +/// A password reset view implementation for a ``UserIdPasswordAccountService``. +public struct UserIdPasswordResetView: View { + private let service: Service + private let successView: SuccessView + + private var userIdConfiguration: UserIdConfiguration { + service.configuration.userIdConfiguration + } + + @Environment(\.dismiss) private var dismiss + + @State private var userId = "" + @State private var requestSubmitted: Bool + + @State private var state: ViewState = .idle + @FocusState private var focusedField: PasswordResetFocusState? + @StateObject private var validationEngine = ValidationEngine(rules: .nonEmpty) + + + public var body: some View { + GeometryReader { proxy in + ScrollView(.vertical) { + VStack { + if requestSubmitted { + successView + } else { + resetPasswordForm + Spacer() + } + } + .navigationTitle(Text("UP_RESET_PASSWORD", bundle: .module)) + .frame(maxWidth: .infinity, minHeight: proxy.size.height) + .disableDismissiveActions(isProcessing: state) + .viewStateAlert(state: $state) + .toolbar { + Button(action: { + dismiss() + }) { + Text("DONE", bundle: .module) + } + } + .onTapGesture { + focusedField = nil + } + } + } + } + + @ViewBuilder private var resetPasswordForm: some View { + VStack { + Text("UAP_PASSWORD_RESET_SUBTITLE \(userIdConfiguration.idType.localizedStringResource)", bundle: .module) + .padding() + .padding(.bottom, 30) + + VerifiableTextField(userIdConfiguration.idType.localizedStringResource, text: $userId) + .environmentObject(validationEngine) + .textFieldStyle(.roundedBorder) + .disableFieldAssistants() + .textContentType(userIdConfiguration.textContentType) + .keyboardType(userIdConfiguration.keyboardType) + .onTapFocus(focusedField: $focusedField, fieldIdentifier: .userId) + .font(.title3) + + Spacer() + AsyncButton(state: $state, action: submitRequestAction) { + Text("Reset Password") + .padding(8) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .padding() + } + .padding() + .frame(maxWidth: ViewSizing.maxFrameWidth * 1.5) // landscape optimizations + .environment(\.defaultErrorDescription, .init("UAP_RESET_PASSWORD_FAILED_DEFAULT_ERROR", bundle: .atURL(from: .module))) + } + + fileprivate init(using service: Service, requestSubmitted: Bool, @ViewBuilder success successViewBuilder: () -> SuccessView) { + self.service = service + self.successView = successViewBuilder() + self._requestSubmitted = State(wrappedValue: requestSubmitted) + } + + + /// Create a new view. + /// - Parameters: + /// - service: The ``UserIdPasswordAccountService`` instance. + /// - successViewBuilder: A view to display on successful password reset. + public init(using service: Service, @ViewBuilder success successViewBuilder: @escaping () -> SuccessView) { + self.init(using: service, requestSubmitted: false, success: successViewBuilder) + } + + + private func submitRequestAction() async throws { + validationEngine.runValidation(input: userId) + guard validationEngine.inputValid else { + focusedField = .userId + return + } + + focusedField = nil + + try await service.resetPassword(userId: userId) + + withAnimation(.easeOut(duration: 0.5)) { + requestSubmitted = true + } + + Task { + // we are creating a detached task, as otherwise this one might be cancelled + // as the view update above results in our current ask getting freed + try await Task.sleep(for: .milliseconds(515)) + state = .idle + } + } +} + + +#if DEBUG +struct DefaultUserIdPasswordResetView_Previews: PreviewProvider { + static let accountService = MockUserIdPasswordAccountService() + + + static var previews: some View { + NavigationStack { + UserIdPasswordResetView(using: accountService) { + SuccessfulPasswordResetView() + } + .environmentObject(Account(accountService)) + } + + NavigationStack { + UserIdPasswordResetView(using: accountService, requestSubmitted: true) { + SuccessfulPasswordResetView() + } + .environmentObject(Account(accountService)) + } + } +} +#endif diff --git a/Sources/SpeziAccount/Views/VerifiableTextGridRow.swift b/Sources/SpeziAccount/Views/VerifiableTextGridRow.swift deleted file mode 100644 index 44260329..00000000 --- a/Sources/SpeziAccount/Views/VerifiableTextGridRow.swift +++ /dev/null @@ -1,191 +0,0 @@ -// -// This source file is part of the Spezi open-source project -// -// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -import SpeziViews -import SwiftUI - - -/// A reusable view to enable building verifiable text fields. -public struct VerifiableTextFieldGridRow: View { - private let description: Description - private let textField: TextField - private let validationRules: [ValidationRule] - - @Binding private var text: String - @Binding private var valid: Bool - - @State private var debounceTask: Task? { - willSet { - self.debounceTask?.cancel() - } - } - @State private var validationResults: [String] = [] - - - public var body: some View { - DescriptionGridRow { - description - } content: { - VStack { - textField - .onSubmit { - validation() - } - HStack { - VStack(alignment: .leading) { - ForEach(validationResults, id: \.self) { message in - Text(message) - .fixedSize(horizontal: false, vertical: true) - } - } - .gridColumnAlignment(.leading) - .font(.footnote) - .foregroundColor(.red) - Spacer(minLength: 0) - } - } - } - .onChange(of: text) { _ in - validation() - } - .onTapFocus() - } - - - /// - Parameters: - /// - text: The text that is displayed in the text field. - /// - valid: If the text is valid in accordance to the validation rules. - /// - validationRules: The validation rules that are applied. - /// - description: The description of the text field. - /// - textField: The text field that is verified. - public init( - text: Binding, - valid: Binding, - validationRules: [ValidationRule] = [], - @ViewBuilder description: () -> Description, - @ViewBuilder textField: (Binding) -> TextField - ) { - self._text = text - self._valid = valid - self.validationRules = validationRules - self.description = description() - self.textField = textField(text) - } - - /// - Parameters: - /// - text: The text that is displayed in the text field. - /// - valid: If the text is valid in accordance to the validation rules. - /// - validationRules: The validation rules that are applied. - /// - description: The description of the text field. - /// - textField: The text field that is verified. - public init( // swiftlint:disable:this function_default_parameter_at_end - text: Binding, - valid: Binding, - validationRules: [ValidationRule] = [], - description: String, - @ViewBuilder textField: (Binding) -> TextField - ) where Description == Text { - self.init( - text: text, - valid: valid, - description: { Text(description) }, - textField: textField - ) - } - - - private func validation() { - debounceTask = Task { - // Wait 0.5 seconds until you start the validation. - try? await Task.sleep(for: .seconds(0.2)) - - guard !Task.isCancelled else { - return - } - - SwiftUI.withAnimation(.easeInOut(duration: 0.2)) { - defer { - updateValid() - } - - guard !text.isEmpty else { - validationResults = [] - return - } - - validationResults = validationRules.compactMap { $0.validate(text) } - } - - self.debounceTask = nil - } - } - - private func updateValid() { - valid = !text.isEmpty && validationResults.isEmpty - } -} - - -#if DEBUG -struct VerifiableTextFieldGridRow_Previews: PreviewProvider { - private enum Field: Hashable { - case first - case second - } - - - @State private static var text: String = "" - @State private static var valid = false - @FocusState private static var focusedField: Field? - - - static var previews: some View { - VStack { - Form { - views - .padding(4) - } - views - .padding(32) - } - .background(Color(.systemGroupedBackground)) - } - - private static var views: some View { - Grid(horizontalSpacing: 8, verticalSpacing: 8) { - VerifiableTextFieldGridRow( - text: $text, - valid: $valid, - description: { - Text("Text") - }, - textField: { binding in - TextField(text: binding) { - Text("Placeholder ...") - } - } - ) - .onTapFocus(focusedField: _focusedField, fieldIdentifier: .first) - Divider() - VerifiableTextFieldGridRow( - text: $text, - valid: $valid, - description: { - Text("Text") - }, - textField: { binding in - SecureField(text: binding) { - Text("Secure Placeholder ...") - } - } - ) - .onTapFocus(focusedField: _focusedField, fieldIdentifier: .first) - } - } -} -#endif diff --git a/Tests/UITests/TestApp/AccountTests/AccountTestsView.swift b/Tests/UITests/TestApp/AccountTests/AccountTestsView.swift index f732887a..ce37e3a7 100644 --- a/Tests/UITests/TestApp/AccountTests/AccountTestsView.swift +++ b/Tests/UITests/TestApp/AccountTests/AccountTestsView.swift @@ -13,69 +13,99 @@ import SwiftUI struct AccountTestsView: View { + @Environment(\.features) var features + @EnvironmentObject var account: Account - @EnvironmentObject var user: User - @State var showLogin = false - @State var showSignUp = false - + + @State var showSetup = false + @State var showOverview = false + @State var isEditing = false + var body: some View { - List { - if account.signedIn { - HStack { - UserProfileView(name: user.name) - .frame(height: 30) - Text(user.username ?? user.name.formatted()) + // of by two + NavigationStack { // swiftlint:disable:this closure_body_length + List { + if let details = account.details { + Section("Account Details") { + Text(details.userId) + } } - } - Button("Login") { - showLogin.toggle() - } - Button("SignUp") { - showSignUp.toggle() - } - } - .sheet(isPresented: $showLogin) { - NavigationStack { - Login() + Button("Account Setup") { + showSetup = true } - } - .sheet(isPresented: $showSignUp) { - NavigationStack { - SignUp() + Button("Account Overview") { + showOverview = true } } - .onChange(of: account.signedIn) { signedIn in - if signedIn { - showLogin = false - showSignUp = false + .navigationTitle("Spezi Account") + .sheet(isPresented: $showSetup) { + NavigationStack { + AccountSetup { + finishButton + } + .toolbar { + toolbar(closing: $showSetup) + } + } + } + .sheet(isPresented: $showOverview) { + NavigationStack { + AccountOverview(isEditing: $isEditing) + .toolbar { + toolbar(closing: $showOverview) + } + } + } + .onChange(of: account.signedIn) { newValue in + if newValue { + showSetup = false + } + } + } + } + + + @ViewBuilder var finishButton: some View { + Button(action: { + showSetup = false + }, label: { + Text("Finish") + .frame(maxWidth: .infinity, minHeight: 38) + }) + .buttonStyle(.borderedProminent) + } + + + @ToolbarContentBuilder + func toolbar(closing flag: Binding) -> some ToolbarContent { + if !isEditing { + ToolbarItemGroup(placement: .cancellationAction) { + Button("Close") { + flag.wrappedValue = false } } + } } } #if DEBUG struct AccountTestsView_Previews: PreviewProvider { - @StateObject private static var account: Account = { - let accountServices: [any AccountService] = [ - UsernamePasswordAccountService(), - EmailPasswordAccountService() - ] - return Account(accountServices: accountServices) - }() - - + static let details = AccountDetails.Builder() + .set(\.userId, value: "andi.bauer@tum.de") + .set(\.name, value: PersonNameComponents(givenName: "Andreas", familyName: "Bauer")) + .set(\.genderIdentity, value: .male) + static var previews: some View { - NavigationStack { - AccountTestsView() - } - .environmentObject(account) + AccountTestsView() + .environmentObject(Account(TestAccountService(.emailAddress))) - NavigationStack { - AccountTestsView() - } - .environmentObject(Account(accountServices: [])) + AccountTestsView() + .environmentObject(Account(building: details, active: TestAccountService(.emailAddress))) + + AccountTestsView() + .environmentObject(Account()) } } #endif diff --git a/Tests/UITests/TestApp/AccountTests/BiographyKey.swift b/Tests/UITests/TestApp/AccountTests/BiographyKey.swift new file mode 100644 index 00000000..fbff9e69 --- /dev/null +++ b/Tests/UITests/TestApp/AccountTests/BiographyKey.swift @@ -0,0 +1,50 @@ +// +// This source file is part of the Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SpeziAccount +import SwiftUI + + +struct BiographyKey: AccountKey { + typealias Value = String + + static let name: LocalizedStringResource = "Biography" // we don't bother to translate + static let category: AccountKeyCategory = .personalDetails +} + + +extension AccountKeys { + var biography: BiographyKey.Type { + BiographyKey.self + } +} + + +extension AccountValues { + var biography: String? { + storage[BiographyKey.self] + } +} + + +extension BiographyKey { + public struct DataEntry: DataEntryView { + public typealias Key = BiographyKey + + @Binding private var biography: Value + + public init(_ value: Binding) { + self._biography = value + } + + public var body: some View { + VerifiableTextField(Key.name, text: $biography) + .autocorrectionDisabled() + } + } +} diff --git a/Tests/UITests/TestApp/AccountTests/MockAccountServiceError.swift b/Tests/UITests/TestApp/AccountTests/MockAccountServiceError.swift index 340eff2a..d4ca5361 100644 --- a/Tests/UITests/TestApp/AccountTests/MockAccountServiceError.swift +++ b/Tests/UITests/TestApp/AccountTests/MockAccountServiceError.swift @@ -10,14 +10,14 @@ import Foundation enum MockAccountServiceError: LocalizedError { - case usernameTaken + case credentialsTaken case wrongCredentials var errorDescription: String? { switch self { - case .usernameTaken: - return "Username is already taken" + case .credentialsTaken: + return "User Identifier is already taken" case .wrongCredentials: return "Credentials do not match" } @@ -29,8 +29,8 @@ enum MockAccountServiceError: LocalizedError { var recoverySuggestion: String? { switch self { - case .usernameTaken: - return "Please provide a different username." + case .credentialsTaken: + return "Please provide a different user identifier." case .wrongCredentials: return "Please ensure that the entered credentials are correct." } diff --git a/Tests/UITests/TestApp/AccountTests/MockEmailPasswordAccountService.swift b/Tests/UITests/TestApp/AccountTests/MockEmailPasswordAccountService.swift deleted file mode 100644 index 794bb76c..00000000 --- a/Tests/UITests/TestApp/AccountTests/MockEmailPasswordAccountService.swift +++ /dev/null @@ -1,54 +0,0 @@ -// -// This source file is part of the Spezi open-source project -// -// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -import SpeziAccount - - -class MockEmailPasswordAccountService: EmailPasswordAccountService { - let user: User - - - init(user: User) { - self.user = user - super.init() - } - - - override func login(username: String, password: String) async throws { - try await Task.sleep(for: .seconds(5)) - - guard username == "lelandstanford@stanford.edu", password == "StanfordRocks123!" else { - throw MockAccountServiceError.wrongCredentials - } - - await MainActor.run { - account?.signedIn = true - user.username = username - } - } - - override func signUp(signUpValues: SignUpValues) async throws { - try await Task.sleep(for: .seconds(5)) - - guard signUpValues.username != "lelandstanford@stanford.edu" else { - throw MockAccountServiceError.usernameTaken - } - - await MainActor.run { - account?.signedIn = true - user.username = signUpValues.username - user.name = signUpValues.name - user.dateOfBirth = signUpValues.dateOfBirth - user.gender = signUpValues.genderIdentity - } - } - - override func resetPassword(username: String) async throws { - try await Task.sleep(for: .seconds(5)) - } -} diff --git a/Tests/UITests/TestApp/AccountTests/MockUsernamePasswordAccountService.swift b/Tests/UITests/TestApp/AccountTests/MockUsernamePasswordAccountService.swift deleted file mode 100644 index c582ed1b..00000000 --- a/Tests/UITests/TestApp/AccountTests/MockUsernamePasswordAccountService.swift +++ /dev/null @@ -1,55 +0,0 @@ -// -// This source file is part of the Spezi open-source project -// -// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -import SpeziAccount - - -class MockUsernamePasswordAccountService: UsernamePasswordAccountService { - let user: User - - - init(user: User) { - self.user = user - super.init() - } - - - override func login(username: String, password: String) async throws { - try await Task.sleep(for: .seconds(5)) - - guard username == "lelandstanford", password == "StanfordRocks123!" else { - throw MockAccountServiceError.wrongCredentials - } - - await MainActor.run { - account?.signedIn = true - user.username = username - } - } - - - override func signUp(signUpValues: SignUpValues) async throws { - try await Task.sleep(for: .seconds(5)) - - guard signUpValues.username != "lelandstanford" else { - throw MockAccountServiceError.usernameTaken - } - - await MainActor.run { - account?.signedIn = true - user.username = signUpValues.username - user.name = signUpValues.name - user.dateOfBirth = signUpValues.dateOfBirth - user.gender = signUpValues.genderIdentity - } - } - - override func resetPassword(username: String) async throws { - try await Task.sleep(for: .seconds(5)) - } -} diff --git a/Tests/UITests/TestApp/AccountTests/TestAccountConfiguration.swift b/Tests/UITests/TestApp/AccountTests/TestAccountConfiguration.swift index a1e8df6e..4d45ef10 100644 --- a/Tests/UITests/TestApp/AccountTests/TestAccountConfiguration.swift +++ b/Tests/UITests/TestApp/AccountTests/TestAccountConfiguration.swift @@ -12,27 +12,31 @@ import Spezi import SpeziAccount -final class TestAccountConfiguration: Component, ObservableObjectProvider { - private let account: Account - private let user: User - - - var observableObjects: [any ObservableObject] { - [ - account, - user - ] - } - - - init(emptyAccountServices: Bool = false) { - self.user = User() - let accountServices: [any AccountService] = emptyAccountServices - ? [] - : [ - MockUsernamePasswordAccountService(user: user), - MockEmailPasswordAccountService(user: user) +final class TestAccountConfiguration: Component { + @Provide var accountServices: [any AccountService] + + init(features: Features) { + switch features.serviceType { + case .mail: + accountServices = [TestAccountService(.emailAddress, defaultAccount: features.defaultCredentials)] + case .both: + accountServices = [ + TestAccountService(.emailAddress, defaultAccount: features.defaultCredentials), + TestAccountService(.username) + ] + case .withIdentityProvider: + accountServices = [ + TestAccountService(.emailAddress, defaultAccount: features.defaultCredentials), + MockSignInWithAppleProvider() ] - self.account = Account(accountServices: accountServices) + case .empty: + accountServices = [] + } + } + + func configure() { + accountServices + .compactMap { $0 as? TestAccountService } + .forEach { $0.configure() } } } diff --git a/Tests/UITests/TestApp/AccountTests/TestAccountService.swift b/Tests/UITests/TestApp/AccountTests/TestAccountService.swift new file mode 100644 index 00000000..040782c5 --- /dev/null +++ b/Tests/UITests/TestApp/AccountTests/TestAccountService.swift @@ -0,0 +1,106 @@ +// +// This source file is part of the Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SpeziAccount + + +actor TestAccountService: UserIdPasswordAccountService { + nonisolated let configuration: AccountServiceConfiguration + private let defaultUserId: String + private let defaultAccountOnConfigure: Bool + + @AccountReference var account: Account + var registeredUser: UserStorage // simulates the backend + + + init(_ type: UserIdType, defaultAccount: Bool = false) { + configuration = AccountServiceConfiguration( + name: "\(type.localizedStringResource) and Password", + supportedKeys: .exactly(UserStorage.supportedKeys) + ) { + RequiredAccountKeys { + \.userId + \.password + } + UserIdConfiguration(type: type, keyboardType: type == .emailAddress ? .emailAddress : .default) + } + + defaultUserId = type == .emailAddress ? UserStorage.defaultEmail : UserStorage.defaultUsername + self.defaultAccountOnConfigure = defaultAccount + registeredUser = UserStorage(userId: defaultUserId) + } + + nonisolated func configure() { + if defaultAccountOnConfigure { + Task { + do { + try await updateUser() + } catch { + print("Failed to set default user: \(error)") + } + } + } + } + + + func login(userId: String, password: String) async throws { + try await Task.sleep(for: .seconds(1)) + + guard userId == registeredUser.userId && password == registeredUser.password else { + throw MockAccountServiceError.wrongCredentials + } + + registeredUser.userId = userId + try await updateUser() + } + + func signUp(signupDetails: SignupDetails) async throws { + try await Task.sleep(for: .seconds(1)) + + guard signupDetails.userId != registeredUser.userId else { + throw MockAccountServiceError.credentialsTaken + } + + registeredUser.userId = signupDetails.userId + registeredUser.name = signupDetails.name + registeredUser.genderIdentity = signupDetails.genderIdentity + registeredUser.dateOfBirth = signupDetails.dateOfBrith + try await updateUser() + } + + func updateAccountDetails(_ modifications: AccountModifications) async throws { + try await Task.sleep(for: .seconds(1)) + registeredUser.update(modifications) + + try await updateUser() + } + + func updateUser() async throws { + let details = AccountDetails.Builder() + .set(\.userId, value: registeredUser.userId) + .set(\.name, value: registeredUser.name) + .set(\.genderIdentity, value: registeredUser.genderIdentity) + .set(\.dateOfBirth, value: registeredUser.dateOfBirth) + .build(owner: self) + + try await account.supplyUserDetails(details) + } + + func resetPassword(userId: String) async throws { + try await Task.sleep(for: .seconds(2)) + } + + func logout() async throws { + await account.removeUserDetails() + } + + func delete() async throws { + await account.removeUserDetails() + registeredUser = UserStorage(userId: defaultUserId) + } +} diff --git a/Tests/UITests/TestApp/AccountTests/TestStandard.swift b/Tests/UITests/TestApp/AccountTests/TestStandard.swift new file mode 100644 index 00000000..35c161be --- /dev/null +++ b/Tests/UITests/TestApp/AccountTests/TestStandard.swift @@ -0,0 +1,43 @@ +// +// This source file is part of the Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Spezi +import SpeziAccount + + +// mock implementation of the AccountStorageStandard +actor TestStandard: AccountStorageStandard { + var records: [AdditionalRecordId: PartialAccountDetails.Builder] = [:] + + func create(_ identifier: AdditionalRecordId, _ details: SignupDetails) async throws { + records[identifier] = PartialAccountDetails.Builder(from: details) + } + + func load(_ identifier: AdditionalRecordId, _ keys: [any AccountKey.Type]) async throws -> PartialAccountDetails { + let details = records[identifier, default: .init()] + + // A real-world implementation would need the keys to construct the account details from e.g. database-supplied values + + return details.build() + } + + func modify(_ identifier: AdditionalRecordId, _ modifications: AccountModifications) async throws { + let builder = records[identifier, default: .init()] + .merging(modifications.modifiedDetails, allowOverwrite: true) + .remove(all: modifications.removedAccountDetails.keys) + records[identifier] = builder // we have a class type, the `default:` is not getting stored using _modify + } + + func clear(_ identifier: AdditionalRecordId) { + // we don't have a local cache in that sense + } + + func delete(_ identifier: AdditionalRecordId) async throws { + records[identifier] = nil + } +} diff --git a/Tests/UITests/TestApp/AccountTests/User.swift b/Tests/UITests/TestApp/AccountTests/User.swift deleted file mode 100644 index f64ca475..00000000 --- a/Tests/UITests/TestApp/AccountTests/User.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// This source file is part of the Spezi open-source project -// -// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -import Foundation -import SpeziAccount - - -actor User: ObservableObject { - @MainActor @Published var username: String? - @MainActor @Published var name = PersonNameComponents() - @MainActor @Published var gender: GenderIdentity? - @MainActor @Published var dateOfBirth: Date? - - - init( - username: String? = nil, - name: PersonNameComponents = PersonNameComponents(), - gender: GenderIdentity? = nil, - dateOfBirth: Date? = nil - ) { - Task { @MainActor in - self.username = username - self.name = name - self.gender = gender - self.dateOfBirth = dateOfBirth - } - } -} diff --git a/Tests/UITests/TestApp/AccountTests/UserStorage.swift b/Tests/UITests/TestApp/AccountTests/UserStorage.swift new file mode 100644 index 00000000..755c40fd --- /dev/null +++ b/Tests/UITests/TestApp/AccountTests/UserStorage.swift @@ -0,0 +1,74 @@ +// +// This source file is part of the Spezi open-source project +// +// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Foundation +import SpeziAccount + + +struct UserStorage { + private static let dateStyle = Date.FormatStyle() + .locale(.init(identifier: "de")) + .year() + .month(.twoDigits) + .day(.twoDigits) + + static let supportedKeys = AccountKeyCollection { + \.userId + \.password + \.name + \.genderIdentity + \.dateOfBirth + } + + static let defaultUsername = "lelandstanford" + static let defaultEmail = "lelandstanford@stanford.edu" + + var userId: String + var password: String + var name: PersonNameComponents? + var genderIdentity: GenderIdentity? + var dateOfBirth: Date? + + + init( + userId: String, + password: String = "StanfordRocks123!", + name: PersonNameComponents? = PersonNameComponents(givenName: "Leland", familyName: "Stanford"), + gender: GenderIdentity? = .male, + dateOfBirth: Date? = try? Date("09.03.1824", strategy: dateStyle) + ) { + self.userId = userId + self.password = password + self.name = name + self.genderIdentity = gender + self.dateOfBirth = dateOfBirth + } + + + mutating func update(_ modifications: AccountModifications) { + let modifiedDetails = modifications.modifiedDetails + let removedKeys = modifications.removedAccountDetails + + self.userId = modifiedDetails.storage[UserIdKey.self] ?? userId + self.password = modifiedDetails.password ?? password + self.name = modifiedDetails.name ?? name + self.genderIdentity = modifiedDetails.genderIdentity ?? genderIdentity + self.dateOfBirth = modifiedDetails.dateOfBrith ?? dateOfBirth + + // user Id cannot be removed! + if removedKeys.name != nil { + self.name = nil + } + if removedKeys.genderIdentity != nil { + self.genderIdentity = nil + } + if removedKeys.dateOfBrith != nil { + self.dateOfBirth = nil + } + } +} diff --git a/Tests/UITests/TestApp/FeatureFlags.swift b/Tests/UITests/TestApp/FeatureFlags.swift deleted file mode 100644 index f0e6db4f..00000000 --- a/Tests/UITests/TestApp/FeatureFlags.swift +++ /dev/null @@ -1,13 +0,0 @@ -// -// This source file is part of the Spezi open-source project -// -// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -/// A collection of feature flags for the Test App. -enum FeatureFlags { - /// Configures the SpeziAccount AccountServices to be empty! - static let emptyAccountServices = CommandLine.arguments.contains("--emptyAccountServices") -} diff --git a/Tests/UITests/TestApp/Features.swift b/Tests/UITests/TestApp/Features.swift new file mode 100644 index 00000000..b1680cb5 --- /dev/null +++ b/Tests/UITests/TestApp/Features.swift @@ -0,0 +1,48 @@ +// +// This source file is part of the Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import ArgumentParser +import SwiftUI + + +enum AccountServiceType: String, ExpressibleByArgument { + case mail + case both + case withIdentityProvider + case empty +} + + +enum AccountValueConfigurationType: String, ExpressibleByArgument { + case `default` + case allRequired +} + + +/// A collection of feature flags for the Test App. +struct Features: ParsableArguments, EnvironmentKey { + static let defaultValue = Features() + + @Option(help: "Define which type of account services are used for the tests.") var serviceType: AccountServiceType = .mail + + @Option(help: "Define which type of AccountValueConfiguration is used.") var configurationType: AccountValueConfigurationType = .default + + @Flag(help: "Control if the app should be populated with default credentials.") var defaultCredentials = false +} + + +extension EnvironmentValues { + var features: Features { + get { + self[Features.self] + } + set { + self[Features.self] = newValue + } + } +} diff --git a/Tests/UITests/TestApp/TestApp.swift b/Tests/UITests/TestApp/TestApp.swift index 6c7b0154..baf40eb9 100644 --- a/Tests/UITests/TestApp/TestApp.swift +++ b/Tests/UITests/TestApp/TestApp.swift @@ -9,19 +9,15 @@ import SpeziAccount import SwiftUI - @main struct UITestsApp: App { @UIApplicationDelegateAdaptor(TestAppDelegate.self) var appDelegate - var body: some Scene { WindowGroup { - NavigationStack { - AccountTestsView() - .navigationTitle("Spezi Account") - .spezi(appDelegate) - } + AccountTestsView() + .spezi(appDelegate) + .environment(\.features, appDelegate.features) } } } diff --git a/Tests/UITests/TestApp/TestAppDelegate.swift b/Tests/UITests/TestApp/TestAppDelegate.swift index d880cc06..9dce92a1 100644 --- a/Tests/UITests/TestApp/TestAppDelegate.swift +++ b/Tests/UITests/TestApp/TestAppDelegate.swift @@ -7,12 +7,47 @@ // import Spezi - +import SpeziAccount class TestAppDelegate: SpeziAppDelegate { + let features: Features = { + do { + let features = try Features.parse() + return features + } catch { + print("Error: \(error)") + print("Verify the supplied command line arguments: " + CommandLine.arguments.dropFirst().joined(separator: " ")) + print(Features.helpMessage()) + return Features() + } + }() + + var configuredValues: AccountValueConfiguration { + switch features.configurationType { + case .default: + return [ + .requires(\.userId), + .requires(\.password), + .collects(\.name), + .collects(\.genderIdentity), + .collects(\.dateOfBirth), + .supports(\.biography) + ] + case .allRequired: + return [ + .requires(\.userId), + .requires(\.password), + .requires(\.name), + .requires(\.genderIdentity), + .requires(\.dateOfBirth) + ] + } + } + override var configuration: Configuration { - Configuration { - TestAccountConfiguration(emptyAccountServices: FeatureFlags.emptyAccountServices) + Configuration(standard: TestStandard()) { + AccountConfiguration(configuration: configuredValues) + TestAccountConfiguration(features: features) } } } diff --git a/Tests/UITests/TestAppUITests/AccountLoginTests.swift b/Tests/UITests/TestAppUITests/AccountLoginTests.swift deleted file mode 100644 index 35ee57c7..00000000 --- a/Tests/UITests/TestAppUITests/AccountLoginTests.swift +++ /dev/null @@ -1,120 +0,0 @@ -// -// This source file is part of the Spezi open-source project -// -// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -import XCTest -import XCTestExtensions - - -final class AccountLoginTests: XCTestCase { - func testLoginUsernameComponents() throws { - let app = XCUIApplication() - app.launch() - - XCTAssert(app.buttons["Login"].waitForExistence(timeout: 2)) - app.buttons["Login"].tap() - - XCTAssert(app.buttons["Username and Password"].waitForExistence(timeout: 2)) - app.buttons["Username and Password"].tap() - - XCTAssert(app.navigationBars.buttons["Login"].waitForExistence(timeout: 2)) - - let usernameField = "Enter your username ..." - let passwordField = "Enter your password ..." - let username = "lelandstanford" - let password = "StanfordRocks123!" - - try app.enterCredentials( - username: (usernameField, username), - password: (passwordField, String(password.dropLast(2))) - ) - - XCTAssertTrue(app.alerts["Credentials do not match"].waitForExistence(timeout: 10.0)) - app.alerts["Credentials do not match"].scrollViews.otherElements.buttons["OK"].tap() - - try app.delete( - username: (usernameField, username.count), - password: (passwordField, password.count) - ) - - try app.enterCredentials( - username: (usernameField, username), - password: (passwordField, password) - ) - - XCTAssertTrue(app.collectionViews.staticTexts[username].waitForExistence(timeout: 10.0)) - } - - func testLoginEmailComponents() throws { - let app = XCUIApplication() - app.launch() - - XCTAssert(app.buttons["Login"].waitForExistence(timeout: 2)) - app.buttons["Login"].tap() - - XCTAssert(app.buttons["Email and Password"].waitForExistence(timeout: 2)) - app.buttons["Email and Password"].tap() - - XCTAssert(app.navigationBars.buttons["Login"].waitForExistence(timeout: 2)) - - let usernameField = "Enter your email ..." - let passwordField = "Enter your password ..." - let username = "lelandstanford@stanford.edu" - let password = "StanfordRocks123!" - - try app.textFields[usernameField].enter(value: String(username.dropLast(4))) - try app.secureTextFields[passwordField].enter(value: password) - - XCTAssertTrue(app.staticTexts["The entered email is not correct."].waitForExistence(timeout: 1.0)) - XCTAssertFalse(app.scrollViews.otherElements.buttons["Login, In progress"].waitForExistence(timeout: 0.5)) - - try app.delete( - username: (usernameField, username.dropLast(4).count), - password: (passwordField, password.count) - ) - - try app.enterCredentials( - username: (usernameField, username), - password: (passwordField, String(password.dropLast(2))) - ) - - XCTAssertTrue(XCUIApplication().alerts["Credentials do not match"].waitForExistence(timeout: 6.0)) - XCUIApplication().alerts["Credentials do not match"].scrollViews.otherElements.buttons["OK"].tap() - - try app.delete( - username: (usernameField, username.count), - password: (passwordField, password.count) - ) - - try app.enterCredentials( - username: (usernameField, username), - password: (passwordField, password) - ) - - XCTAssertTrue(app.collectionViews.staticTexts[username].waitForExistence(timeout: 6.0)) - } -} - - -extension XCUIApplication { - fileprivate func delete(username: (field: String, count: Int), password: (field: String, count: Int)) throws { - try textFields[username.field].delete(count: username.count) - try secureTextFields[password.field].delete(count: password.count) - } - - fileprivate func enterCredentials(username: (field: String, value: String), password: (field: String, value: String)) throws { - let buttonTitle = "Login" - - testPrimaryButton(enabled: false, title: buttonTitle) - - try textFields[username.field].enter(value: username.value) - testPrimaryButton(enabled: false, title: buttonTitle) - - try secureTextFields[password.field].enter(value: password.value) - testPrimaryButton(enabled: true, title: buttonTitle) - } -} diff --git a/Tests/UITests/TestAppUITests/AccountOverviewTests.swift b/Tests/UITests/TestAppUITests/AccountOverviewTests.swift new file mode 100644 index 00000000..9bd47f22 --- /dev/null +++ b/Tests/UITests/TestAppUITests/AccountOverviewTests.swift @@ -0,0 +1,247 @@ +// +// This source file is part of the Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import XCTest +import XCTestExtensions + + +final class AccountOverviewTests: XCTestCase { + override func setUpWithError() throws { + try super.setUpWithError() + + try disablePasswordAutofill() + } + + func testRequirementLevelsOverview() throws { + let app = TestApp.launch(defaultCredentials: true) + let overview = app.openAccountOverview() + + overview.verifyExistence(text: "Leland Stanford") + overview.verifyExistence(text: "lelandstanford@stanford.edu") + + overview.verifyExistence(text: "Name, E-Mail Address") + overview.verifyExistence(text: "Password & Security") + + overview.verifyExistence(text: "Gender Identity") + overview.verifyExistence(text: "Male") + + overview.verifyExistence(text: "Date of Birth") + overview.verifyExistence(text: "Mar 9, 1824") + + XCTAssertTrue(overview.buttons["Logout"].waitForExistence(timeout: 0.5)) + } + + func testEditView() throws { + let app = TestApp.launch(defaultCredentials: true) + let overview = app.openAccountOverview() + + overview.tap(button: "Edit") + + XCTAssertTrue(overview.buttons["Delete Account"].waitForExistence(timeout: 0.5)) + + overview.updateGenderIdentity(from: "Male", to: "Choose not to answer") + overview.changeDatePreviousMonthFirstDay() + + overview.tap(button: "Add Biography") + + try overview.enter(field: "Biography", text: "Hello Stanford") + sleep(3) + + overview.tap(button: "Done") + + sleep(3) + + overview.verifyExistence(text: "Choose not to answer") + overview.verifyExistence(text: "Hello Stanford") + } + + func testLogout() { + let app = TestApp.launch(defaultCredentials: true) + let overview = app.openAccountOverview() + + overview.tap(button: "Logout") + + let alert = "Are you sure you want to logout?" + XCTAssertTrue(XCUIApplication().alerts[alert].waitForExistence(timeout: 6.0)) + XCUIApplication().alerts[alert].scrollViews.otherElements.buttons["Logout"].tap() + + sleep(2) + app.verify() + XCTAssertFalse(app.staticTexts["lelandstanford@stanford.edu"].waitForExistence(timeout: 0.5)) + } + + func testAccountRemoval() { + let app = TestApp.launch(defaultCredentials: true) + let overview = app.openAccountOverview() + + overview.tap(button: "Edit") + + overview.tap(button: "Delete Account") + + let alert = "Are you sure you want to delete your account?" + XCTAssertTrue(XCUIApplication().alerts[alert].waitForExistence(timeout: 6.0)) + XCUIApplication().alerts[alert].scrollViews.otherElements.buttons["Delete"].tap() + + sleep(2) + app.verify() + XCTAssertFalse(app.staticTexts["lelandstanford@stanford.edu"].waitForExistence(timeout: 0.5)) + } + + func testEditDiscard() { + let app = TestApp.launch(defaultCredentials: true) + let overview = app.openAccountOverview() + + overview.tap(button: "Edit") + sleep(1) + + // no changes, should just leave edit mode + overview.tap(button: "Cancel") + XCTAssertTrue(app.buttons["Logout"].waitForExistence(timeout: 2)) + + + overview.tap(button: "Edit") + overview.updateGenderIdentity(from: "Male", to: "Choose not to answer") + + overview.tap(button: "Cancel") + + sleep(1) + let confirmation = "Are you sure you want to discard your changes?" + overview.verifyExistence(text: confirmation, timeout: 2.0) + overview.tap(button: "Keep Editing") + + overview.tap(button: "Cancel") + overview.verifyExistence(text: confirmation, timeout: 2.0) + overview.tap(button: "Discard Changes") + + overview.verifyExistence(text: "Male") // make sure value didn't change + } + + func testRemoveDiscard() { + let app = TestApp.launch(defaultCredentials: true) + let overview = app.openAccountOverview() + + overview.tap(button: "Edit") + sleep(1) + + // remove image on the list + let removeButtons = overview.images.matching(identifier: "remove") + removeButtons.firstMatch.tap() + overview.buttons["Delete"].tap() + + XCTAssertTrue(overview.buttons["Add Gender Identity"].waitForExistence(timeout: 2.0)) + + overview.tap(button: "Cancel") + let confirmation = "Are you sure you want to discard your changes?" + overview.verifyExistence(text: confirmation, timeout: 2.0) + overview.tap(button: "Discard Changes") + + overview.verifyExistence(text: "Male") // make sure value didn't change + } + + func testRemoval() { + let app = TestApp.launch(defaultCredentials: true) + let overview = app.openAccountOverview() + + overview.tap(button: "Edit") + sleep(1) + + // remove image on the list + let removeButtons = overview.images.matching(identifier: "remove") + removeButtons.firstMatch.tap() + overview.buttons["Delete"].tap() + + XCTAssertTrue(overview.buttons["Add Gender Identity"].waitForExistence(timeout: 2.0)) + + overview.tap(button: "Done") + sleep(3) + + XCTAssertFalse(overview.staticTexts["Male"].waitForExistence(timeout: 2.0)) // ensure value is gone + } + + func testNameOverview() throws { + let app = TestApp.launch(defaultCredentials: true) + let overview = app.openAccountOverview() + + overview.tap(button: "Name, E-Mail Address") + sleep(2) + XCTAssertTrue(overview.navigationBars.staticTexts["Name, E-Mail Address"].waitForExistence(timeout: 6.0)) + + overview.verifyExistence(text: "lelandstanford@stanford.edu") + overview.verifyExistence(text: "Leland Stanford") + + // open user id + overview.tap(button: "E-Mail Address, lelandstanford@stanford.edu") + sleep(2) + + XCTAssertFalse(overview.buttons["Done"].isEnabled) + + // edit email + try overview.textFields["E-Mail Address"].delete(count: 12, dismissKeyboard: false) + + // failed validation + XCTAssertTrue(overview.staticTexts["The provided email is invalid."].waitForExistence(timeout: 2.0)) + XCTAssertFalse(overview.buttons["Done"].isEnabled) + + overview.app.typeText("tum.de") // we still have keyboard focus + overview.app.dismissKeyboard() + overview.tap(button: "Done") + sleep(3) + + overview.verifyExistence(text: "lelandstanford@tum.de") + + // open name + overview.tap(button: "Name, Leland Stanford") + sleep(2) + XCTAssertFalse(overview.buttons["Done"].isEnabled) + + // edit name + try overview.delete(field: "enter last name", count: 8) + overview.tap(button: "Done") + sleep(3) + + overview.verifyExistence(text: "Leland") + + overview.navigationBars.buttons["Account Overview"].tap() + sleep(2) + XCTAssertTrue(overview.staticTexts["L"].waitForExistence(timeout: 2.0)) // ensure the "account image" is updated accordingly + } + + func testSecurityOverview() throws { + let app = TestApp.launch(defaultCredentials: true) + let overview = app.openAccountOverview() + + overview.tap(button: "Password & Security") + sleep(2) + XCTAssertTrue(overview.navigationBars.staticTexts["Password & Security"].waitForExistence(timeout: 6.0)) + + XCTAssertTrue(overview.buttons["Change Password"].waitForExistence(timeout: 2.0)) + overview.tap(button: "Change Password") + sleep(2) + XCTAssertTrue(overview.navigationBars.staticTexts["Change Password"].waitForExistence(timeout: 6.0)) + + let warningLength = "Your password must be at least 8 characters long." + overview.verifyExistence(text: warningLength) // the gray hint below + + try overview.secureTextFields["enter password"].enter(value: "12345", dismissKeyboard: false) + sleep(1) + XCTAssertEqual(overview.staticTexts.matching(identifier: warningLength).count, 2) + overview.app.typeText("6789") + + overview.app.dismissKeyboard() + sleep(1) + + try overview.secureTextFields["re-enter password"].enter(value: "12345", dismissKeyboard: false) + overview.verifyExistence(text: "Passwords do not match.", timeout: 2.0) + overview.app.typeText("6789") + + overview.tap(button: "Done") + sleep(2) + + XCTAssertFalse(overview.secureTextFields["enter password"].waitForExistence(timeout: 2.0)) + } +} diff --git a/Tests/UITests/TestAppUITests/AccountResetPasswordTests.swift b/Tests/UITests/TestAppUITests/AccountResetPasswordTests.swift deleted file mode 100644 index 5a4c1879..00000000 --- a/Tests/UITests/TestAppUITests/AccountResetPasswordTests.swift +++ /dev/null @@ -1,76 +0,0 @@ -// -// This source file is part of the Spezi open-source project -// -// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -import XCTest -import XCTestExtensions - - -final class AccountResetPasswordTests: XCTestCase { - func testResetPasswordUsernameComponents() throws { - let app = XCUIApplication() - app.launch() - - XCTAssert(app.buttons["Login"].waitForExistence(timeout: 2)) - app.buttons["Login"].tap() - - XCTAssert(app.buttons["Username and Password"].waitForExistence(timeout: 2)) - app.buttons["Username and Password"].tap() - - XCTAssert(app.buttons["Forgot Password?"].waitForExistence(timeout: 2)) - app.buttons["Forgot Password?"].tap() - - XCTAssert(app.navigationBars.buttons["Login"].waitForExistence(timeout: 2)) - - let usernameField = "Enter your username ..." - let username = "lelandstanford" - let buttonTitle = "Reset Password" - let navigationBarButtonTitle = "Login" - - app.testPrimaryButton(enabled: false, title: buttonTitle, navigationBarButtonTitle: navigationBarButtonTitle) - - try app.textFields[usernameField].enter(value: username) - - app.testPrimaryButton(enabled: true, title: buttonTitle, navigationBarButtonTitle: navigationBarButtonTitle) - - XCTAssertTrue(app.staticTexts["Sent out a link to reset the password."].waitForExistence(timeout: 6.0)) - } - - func testResetPasswordEmailComponents() throws { - let app = XCUIApplication() - app.launch() - - XCTAssert(app.buttons["Login"].waitForExistence(timeout: 2)) - app.buttons["Login"].tap() - - XCTAssert(app.buttons["Email and Password"].waitForExistence(timeout: 2)) - app.buttons["Email and Password"].tap() - - XCTAssert(app.buttons["Forgot Password?"].waitForExistence(timeout: 2)) - app.buttons["Forgot Password?"].tap() - - XCTAssert(app.navigationBars.buttons["Login"].waitForExistence(timeout: 2)) - - let usernameField = "Enter your email ..." - let username = "lelandstanford@stanford.edu" - let buttonTitle = "Reset Password" - let navigationBarButtonTitle = "Login" - - app.testPrimaryButton(enabled: false, title: buttonTitle, navigationBarButtonTitle: navigationBarButtonTitle) - - try app.textFields[usernameField].enter(value: String(username.dropLast(4))) - - XCTAssertTrue(app.staticTexts["The entered email is not correct."].waitForExistence(timeout: 1.0)) - - try app.textFields[usernameField].delete(count: username.count) - try app.textFields[usernameField].enter(value: username) - - app.testPrimaryButton(enabled: true, title: buttonTitle, navigationBarButtonTitle: navigationBarButtonTitle) - - XCTAssertTrue(app.staticTexts["Sent out a link to reset the password."].waitForExistence(timeout: 6.0)) - } -} diff --git a/Tests/UITests/TestAppUITests/AccountSetupTests.swift b/Tests/UITests/TestAppUITests/AccountSetupTests.swift new file mode 100644 index 00000000..1b253b84 --- /dev/null +++ b/Tests/UITests/TestAppUITests/AccountSetupTests.swift @@ -0,0 +1,303 @@ +// +// This source file is part of the Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import XCTest +import XCTestExtensions + + +final class AccountSetupTests: XCTestCase { + override func setUpWithError() throws { + try super.setUpWithError() + + try disablePasswordAutofill() + } + + func testEmbeddedViewValidation() throws { + let app = TestApp.launch(serviceType: "mail") + let setup = app.openAccountSetup() + + // check fields are not valid + XCTAssertTrue(setup.buttons["Login"].exists) + XCTAssertTrue(!setup.buttons["Login"].isEnabled) + XCTAssertFalse(app.staticTexts["This field cannot be empty."].exists) + + try setup.enter(field: "E-Mail Address", text: "aa") + try setup.enter(secureField: "Password", text: "bb") + + + usleep(500_000) + XCTAssertFalse(app.staticTexts["This field cannot be empty."].exists) + + // doing it in reverse order speeds up the input + try setup.delete(secureField: "Password", count: 2) + try setup.delete(field: "E-Mail Address", count: 2) + + // validation should not appear if we remove all content via keyboard + usleep(500_000) + XCTAssertFalse(app.staticTexts["This field cannot be empty."].exists) + XCTAssertTrue(setup.buttons["Login"].exists) + XCTAssertTrue(!setup.buttons["Login"].isEnabled) + } + + func testLoginWithEmail() throws { + let app = TestApp.launch(serviceType: "mail") + let setup = app.openAccountSetup() + + try setup.login(email: Defaults.email, password: Defaults.password.dropLast(3)) + + XCTAssertTrue(XCUIApplication().alerts["Credentials do not match"].waitForExistence(timeout: 6.0)) + XCUIApplication().alerts["Credentials do not match"].scrollViews.otherElements.buttons["OK"].tap() + + // retype password + try setup.delete(secureField: "Password", count: Defaults.password.dropLast(3).count) + try setup.enter(secureField: "Password", text: Defaults.password) + + setup.tapLogin(sleep: 3) // this takes us back to the home screen + + // verify we are back at the start screen + XCTAssertTrue(app.staticTexts[Defaults.email].waitForExistence(timeout: 2.0)) + } + + func testAccountSummary() throws { + let app = TestApp.launch(serviceType: "mail", defaultCredentials: true) + var setup = app.openAccountSetup() + + XCTAssertTrue(setup.staticTexts[Defaults.name].waitForExistence(timeout: 0.5)) + XCTAssertTrue(setup.staticTexts[Defaults.email].exists) + XCTAssertTrue(setup.buttons["Logout"].exists) + + setup.tap(button: "Finish") + + // verify we are back at the start screen + XCTAssertTrue(app.staticTexts[Defaults.email].waitForExistence(timeout: 2.0)) + + setup = app.openAccountSetup() + XCTAssertTrue(setup.buttons["Logout"].waitForExistence(timeout: 1.0)) + setup.tap(button: "Logout") + + XCTAssertTrue(setup.buttons["Login"].waitForExistence(timeout: 2.0)) + } + + func testLoginWithMultipleServices() throws { + let app = TestApp.launch(serviceType: "both") + let setup = app.openAccountSetup() + + setup.tap(button: "Username and Password") + + XCTAssertTrue(setup.buttons["Login"].waitForExistence(timeout: 1.0)) + + try setup.login(username: Defaults.username, password: Defaults.password, sleep: 3) + + XCTAssertTrue(app.staticTexts[Defaults.username].waitForExistence(timeout: 2.0)) + } + + func testBasicIdentityProviderLayout() throws { + let app = TestApp.launch(serviceType: "withIdentityProvider") + let setup = app.openAccountSetup() + + XCTAssertTrue(setup.buttons["Login"].waitForExistence(timeout: 0.5)) + setup.verifyExistence(text: "or") // divider + XCTAssertTrue(setup.buttons["Sign in with Apple"].waitForExistence(timeout: 0.5)) + } + + func testResetPassword() throws { + let app = TestApp.launch(serviceType: "mail") + let setup = app.openAccountSetup() + + setup.tap(button: "Forgot Password?") + + XCTAssertTrue(setup.staticTexts["Reset Password"].waitForExistence(timeout: 2.0)) + + setup.tap(button: "Reset Password") + XCTAssertTrue(app.staticTexts["This field cannot be empty."].waitForExistence(timeout: 1.0)) + + // field should already have focus, due to pressing the button + app.app.typeText(Defaults.email) + + setup.tap(button: "Reset Password") + + XCTAssertTrue(app.staticTexts["Sent out a link to reset the password."].waitForExistence(timeout: 6.0)) + + setup.tap(button: "Done") + XCTAssertFalse(setup.staticTexts["Reset Password"].waitForExistence(timeout: 0.5)) + + setup.tap(button: "Close") + + XCTAssertFalse(setup.staticTexts["Your Account"].waitForExistence(timeout: 0.5)) + } + + func testSignupCredentialsValidation() throws { + let app = TestApp.launch(serviceType: "mail") + var setup = app.openAccountSetup() + + let email = "new-adventure@stanford.edu" + let password = "123456789" + + XCTAssertTrue(setup.staticTexts["Don't have an Account yet?"].waitForExistence(timeout: 2.0)) + + var signupView = setup.openSignup() + + // verify basic validation + XCTAssertTrue(signupView.buttons["Signup"].exists) + XCTAssertTrue(!signupView.buttons["Signup"].isEnabled) + XCTAssertFalse(signupView.staticTexts["This field cannot be empty."].exists) + + // verify empty validation appearing + try signupView.textFields["E-Mail Address"].enter(value: "a", dismissKeyboard: false) + signupView.app.typeText(XCUIKeyboardKey.delete.rawValue) // we have remaining focus + signupView.app.dismissKeyboard() + + try signupView.secureTextFields["Password"].enter(value: "a", dismissKeyboard: false) + signupView.app.typeText(XCUIKeyboardKey.delete.rawValue) // we have remaining focus + signupView.app.dismissKeyboard() + + usleep(500_000) + XCTAssertEqual(signupView.staticTexts.matching(identifier: "This field cannot be empty.").count, 2) + + // not sure why, but text-field selection has issues due to the presented validation messages, so we exit a reenter to resolve this + setup = signupView.tapBack() + signupView = setup.openSignup() + + // enter email with validation + try signupView.textFields["E-Mail Address"].enter(value: String(email.dropLast(13)), dismissKeyboard: false) + XCTAssertTrue(signupView.staticTexts["The provided email is invalid."].waitForExistence(timeout: 2.0)) + signupView.app.typeText(String(email.dropFirst(13))) // we stay focused + signupView.app.dismissKeyboard() + + // enter password with validation + try signupView.secureTextFields["Password"].enter(value: String(password.dropLast(5)), dismissKeyboard: false) + XCTAssertTrue(signupView.staticTexts["Your password must be at least 8 characters long."].waitForExistence(timeout: 2.0)) + signupView.app.typeText(String(password.dropFirst(4))) // stay focused, such that password field will not reset after regaining focus + signupView.app.dismissKeyboard() + + signupView.signup(sleep: 3) // we will be back at the start page now + + // Now verify what we entered + let overview = app.openAccountOverview(timeout: 6.0) + + // basic verification of all information recorded + XCTAssertTrue(overview.staticTexts[email].waitForExistence(timeout: 2.0)) + XCTAssertTrue(overview.staticTexts["Gender Identity"].waitForExistence(timeout: 0.5)) + XCTAssertTrue(overview.staticTexts["Choose not to answer"].waitForExistence(timeout: 0.5)) + XCTAssertTrue(overview.images["Contact Photo"].waitForExistence(timeout: 0.5)) // verify the header works well without a name + } + + func testNameValidation() throws { + let app = TestApp.launch(config: "allRequired") + let signupView = app + .openAccountSetup() + .openSignup(sleep: 3) + + XCTAssertTrue(signupView.buttons["Signup"].exists) + XCTAssertFalse(signupView.buttons["Signup"].isEnabled) + + try signupView.enter(field: "enter first name", text: "a") + try signupView.delete(field: "enter first name", count: 1) + + XCTAssertTrue(signupView.staticTexts["The first name field cannot be empty!"].waitForExistence(timeout: 0.5)) + + try signupView.enter(field: "enter last name", text: "a") + try signupView.delete(field: "enter last name", count: 1) + + XCTAssertTrue(signupView.staticTexts["The first name field cannot be empty!"].waitForExistence(timeout: 0.5)) + XCTAssertTrue(signupView.staticTexts["The last name field cannot be empty!"].waitForExistence(timeout: 0.5)) + } + + func testInvalidCredentials() throws { + let app = TestApp.launch(serviceType: "mail") + + let signupView = app + .openAccountSetup() + .openSignup(sleep: 3) + + try signupView.fillForm(email: Defaults.email, password: Defaults.password) + + signupView.signup(sleep: 2) + + XCTAssertTrue(app.alerts["User Identifier is already taken"].waitForExistence(timeout: 10.0)) + app.alerts["User Identifier is already taken"].scrollViews.otherElements.buttons["OK"].tap() + } + + func testFullSignup() throws { + let app = TestApp.launch(serviceType: "mail") + let signupView = app + .openAccountSetup() + .openSignup(sleep: 3) + + try signupView.fillForm( + email: "lelandstanford2@stanford.edu", + password: Defaults.password, + name: .init("Leland Stanford"), + genderIdentity: "Male", + supplyDateOfBirth: true + ) + + signupView.signup(sleep: 3) + + // Now verify what we entered + let overview = app.openAccountOverview(timeout: 6.0) + + // verify all the details + overview.verifyExistence(text: "LS") + overview.verifyExistence(text: "Leland Stanford") + overview.verifyExistence(text: "lelandstanford2@stanford.edu") + overview.verifyExistence(text: "Gender Identity") + overview.verifyExistence(text: "Male") + overview.verifyExistence(text: "Date of Birth") + } + + func testRequirementLevelsSignup() throws { + let app = TestApp.launch(serviceType: "mail") + let signupView = app + .openAccountSetup() + .openSignup(sleep: 2) + + signupView.verifyExistence(textField: "E-Mail Address") + signupView.verifyExistence(secureField: "Password") + signupView.verifyExistence(text: "First") + signupView.verifyExistence(text: "Last") + signupView.verifyExistence(text: "Gender Identity") + XCTAssertTrue(app.buttons["Add Date of Birth"].waitForExistence(timeout: 0.5)) + } + + func testNameEmptinessCheck() throws { + // if we type in the name in the signup view but then remove all text input then (empty strings in the text fields) + // we shouldn't save a empty name but instead save no name at all + let app = TestApp.launch() + let signupView = app + .openAccountSetup() + .openSignup(sleep: 2) + + let email = "lelandstanford2@stanford.edu" + + try signupView.enter(field: "E-Mail Address", text: email) + try signupView.enter(secureField: "Password", text: "123456789") + + try signupView.enter(field: "enter first name", text: "Leland") + try signupView.delete(field: "enter first name", count: 6) + + signupView.signup(sleep: 3) + + // Now verify what we entered + let overview = app.openAccountOverview(timeout: 6.0) + + // basic verification of all information recorded + XCTAssertTrue(overview.staticTexts[email].waitForExistence(timeout: 2.0)) + XCTAssertTrue(overview.staticTexts["Gender Identity"].waitForExistence(timeout: 0.5)) + XCTAssertTrue(overview.staticTexts["Choose not to answer"].waitForExistence(timeout: 0.5)) + XCTAssertTrue(overview.images["Contact Photo"].waitForExistence(timeout: 0.5)) + + overview.tap(button: "Name, E-Mail Address") + sleep(2) + XCTAssertTrue(overview.navigationBars.staticTexts["Name, E-Mail Address"].waitForExistence(timeout: 6.0)) + + overview.verifyExistence(text: email) + XCTAssertFalse(overview.staticTexts["Leland"].waitForExistence(timeout: 1.0)) + overview.verifyExistence(text: "Add Name") + } +} diff --git a/Tests/UITests/TestAppUITests/AccountSignUpTests.swift b/Tests/UITests/TestAppUITests/AccountSignUpTests.swift deleted file mode 100644 index 4b4e40b4..00000000 --- a/Tests/UITests/TestAppUITests/AccountSignUpTests.swift +++ /dev/null @@ -1,128 +0,0 @@ -// -// This source file is part of the Spezi open-source project -// -// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -import XCTest -import XCTestExtensions - - -final class AccountSignUpTests: XCTestCase { - func testSignUpUsernameComponents() throws { - try disablePasswordAutofill() - - let app = XCUIApplication() - app.launch() - - XCTAssert(app.buttons["SignUp"].waitForExistence(timeout: 2)) - app.buttons["SignUp"].tap() - - XCTAssert(app.buttons["Username and Password"].waitForExistence(timeout: 2)) - app.buttons["Username and Password"].tap() - - let usernameField = "Enter your username ..." - let username = "lelandstanford" - let usernameReplacement = "lelandstanford2" - - try signUpUsername( - usernameField: usernameField, - username: username, - usernameReplacement: usernameReplacement - ) - } - - func testSignUpEmailComponents() throws { - try disablePasswordAutofill() - - let app = XCUIApplication() - app.launch() - - XCTAssert(app.buttons["SignUp"].waitForExistence(timeout: 2)) - app.buttons["SignUp"].tap() - - XCTAssert(app.buttons["Email and Password"].waitForExistence(timeout: 2)) - app.buttons["Email and Password"].tap() - - let usernameField = "Enter your email ..." - let username = "lelandstanford@stanford.edu" - let usernameReplacement = "lelandstanford2@stanford.edu" - - try signUpUsername( - usernameField: usernameField, - username: username, - usernameReplacement: usernameReplacement - ) { - try app.textFields[usernameField].enter(value: String(username.dropLast(4))) - app.testPrimaryButton(enabled: false, title: "Sign Up") - - XCTAssertTrue(app.staticTexts["The entered email is not correct."].waitForExistence(timeout: 5.0)) - - try app.textFields[usernameField].delete(count: username.count) - } - } - - func signUpUsername( - usernameField: String, - username: String, - usernameReplacement: String, - initialTests: () throws -> Void = { } - ) throws { - let app = XCUIApplication() - let buttonTitle = "Sign Up" - - app.testPrimaryButton(enabled: false, title: buttonTitle) - - try initialTests() - - try app.textFields[usernameField].enter(value: username) - app.testPrimaryButton(enabled: false, title: buttonTitle) - - let passwordField = "Enter your password ..." - let password = "StanfordRocks123!" - try app.secureTextFields[passwordField].enter(value: password) - app.testPrimaryButton(enabled: false, title: buttonTitle) - - let passwordRepeatField = "Repeat your password ..." - var passwordRepeat = "StanfordRocks123" - try app.secureTextFields[passwordRepeatField].enter(value: passwordRepeat) - app.testPrimaryButton(enabled: false, title: buttonTitle) - - XCTAssertTrue(app.staticTexts["The entered passwords are not equal."].waitForExistence(timeout: 1.0)) - - try app.secureTextFields[passwordRepeatField].delete(count: passwordRepeat.count) - passwordRepeat = password - try app.secureTextFields[passwordRepeatField].enter(value: passwordRepeat) - app.testPrimaryButton(enabled: false, title: buttonTitle) - - app.datePickers.firstMatch.tap() - app.staticTexts["Date of Birth"].tap() - app.testPrimaryButton(enabled: false, title: buttonTitle) - - app.staticTexts["Choose not to answer"].tap() - app.buttons["Male"].tap() - app.testPrimaryButton(enabled: false, title: buttonTitle) - - let givenNameField = "Enter your first name ..." - let givenName = "Leland" - try app.textFields[givenNameField].enter(value: givenName) - app.testPrimaryButton(enabled: false, title: buttonTitle) - - let familyNameField = "Enter your last name ..." - let familyName = "Stanford" - try app.textFields[familyNameField].enter(value: familyName) - app.testPrimaryButton(enabled: true, title: buttonTitle) - - XCTAssertTrue(app.alerts["Username is already taken"].waitForExistence(timeout: 10.0)) - app.alerts["Username is already taken"].scrollViews.otherElements.buttons["OK"].tap() - - app.swipeDown() - try app.textFields[usernameField].delete(count: username.count) - try app.textFields[usernameField].enter(value: usernameReplacement) - app.testPrimaryButton(enabled: true, title: buttonTitle) - - XCTAssertTrue(app.collectionViews.staticTexts[usernameReplacement].waitForExistence(timeout: 10.0)) - } -} diff --git a/Tests/UITests/TestAppUITests/DocumentationHintsTests.swift b/Tests/UITests/TestAppUITests/DocumentationHintsTests.swift new file mode 100644 index 00000000..db31821e --- /dev/null +++ b/Tests/UITests/TestAppUITests/DocumentationHintsTests.swift @@ -0,0 +1,54 @@ +// +// This source file is part of the Spezi open-source project +// +// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import XCTest +import XCTestExtensions + + +final class DocumentationHintsTests: XCTestCase { + func testDocumentationHint(type: String, button: String, hint: String) { + let testApp = TestApp.launch(serviceType: type) + let app = testApp.app + + app.buttons[button].tap() + + // Note for the `hint`, you have to escape any ' characters! + let predicate = NSPredicate(format: "label LIKE '\(hint)'") // hint may be longer than 128 characters. + XCTAssertTrue(app.staticTexts.element(matching: predicate).waitForExistence(timeout: 6.0)) + + XCTAssertTrue(app.buttons["Open Documentation"].waitForExistence(timeout: 6)) + app.buttons["Open Documentation"].tap() + + let safari = XCUIApplication(bundleIdentifier: "com.apple.mobilesafari") + XCTAssert(safari.wait(for: .runningForeground, timeout: 10)) + + app.activate() + } + + func testEmptyAccountServices() { + testDocumentationHint( + type: "empty", + button: "Account Setup", + hint: """ + **No Account Services set up.**\\n\\n\ + Please refer to the documentation of the SpeziAccount package on how to set up an AccountService! + """ + ) + } + + func testMissingAccount() { + testDocumentationHint( + type: "mail", + button: "Account Overview", + hint: """ + **Couldn\\'t find a user account.**\\n\\nThis view requires an active user account.\\n\ + Refer to the documentation of the AccountSetup view on how to setup a user account! + """ + ) + } +} diff --git a/Tests/UITests/TestAppUITests/EmptyAccountServicesTests.swift b/Tests/UITests/TestAppUITests/EmptyAccountServicesTests.swift deleted file mode 100644 index 653d0ce0..00000000 --- a/Tests/UITests/TestAppUITests/EmptyAccountServicesTests.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// This source file is part of the Spezi open-source project -// -// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -import XCTest -import XCTestExtensions - -final class EmptyAccountServicesTests: XCTestCase { - func testDocumentationHint(forButton: String) throws { - let app = XCUIApplication() - app.launchArguments = ["--emptyAccountServices"] - app.launch() - - app.buttons[forButton].tap() - - let text = "No Account Services set up.\n Please refer to the documentation of the SpeziAccount package on how to set up an AccountService!" - XCTAssertTrue(app.staticTexts[text].waitForExistence(timeout: 6.0)) - - XCTAssertTrue(app.buttons["Open Documentation"].waitForExistence(timeout: 6)) - app.buttons["Open Documentation"].tap() - - let safari = XCUIApplication(bundleIdentifier: "com.apple.mobilesafari") - XCTAssert(safari.wait(for: .runningForeground, timeout: 10)) - - app.activate() - } - - func testDocumentationHintLogin() throws { - try testDocumentationHint(forButton: "Login") - } - - func testDocumentationHintSignup() throws { - try testDocumentationHint(forButton: "SignUp") - } -} diff --git a/Tests/UITests/TestAppUITests/Utils/AccountValueView.swift b/Tests/UITests/TestAppUITests/Utils/AccountValueView.swift new file mode 100644 index 00000000..d91e31b1 --- /dev/null +++ b/Tests/UITests/TestAppUITests/Utils/AccountValueView.swift @@ -0,0 +1,41 @@ +// +// This source file is part of the Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import XCTest + + +protocol AccountValueView: TestableView {} + + +extension AccountValueView { + func updateGenderIdentity(from: String, to: String) { + app.staticTexts[from].tap() + XCTAssertTrue(app.buttons[to].waitForExistence(timeout: 0.5)) + app.buttons[to].tap() + } + + func changeDatePreviousMonthFirstDay() { + // add date button is presented if date is not required or doesn't exists yet + if app.buttons["Add Date of Birth"].exists { // uses the accessibility label + app.buttons["Add Date of Birth"].tap() + } + + XCTAssertTrue(app.datePickers.firstMatch.waitForExistence(timeout: 2.0)) + app.datePickers.firstMatch.tap() + + // navigate to previous month and select the first date + XCTAssertTrue(app.datePickers.buttons["Previous Month"].waitForExistence(timeout: 2.0)) + app.datePickers.buttons["Previous Month"].tap() + + usleep(500_000) + app.datePickers.collectionViews.buttons.element(boundBy: 0).tap() + + // close the date picker again + app.staticTexts["Date of Birth"].tap() + } +} diff --git a/Tests/UITests/TestAppUITests/Utils/Defaults.swift b/Tests/UITests/TestAppUITests/Utils/Defaults.swift new file mode 100644 index 00000000..7fc8f9f1 --- /dev/null +++ b/Tests/UITests/TestAppUITests/Utils/Defaults.swift @@ -0,0 +1,18 @@ +// +// This source file is part of the Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + + +enum Defaults { + static let username = "lelandstanford" + static let email = "lelandstanford@stanford.edu" + static let password = "StanfordRocks123!" + + static let firstname = "Leland" + static let lastname = "Stanford" + static let name = "\(firstname) \(lastname)" +} diff --git a/Tests/UITests/TestAppUITests/Utils/SignupView.swift b/Tests/UITests/TestAppUITests/Utils/SignupView.swift new file mode 100644 index 00000000..f24755ed --- /dev/null +++ b/Tests/UITests/TestAppUITests/Utils/SignupView.swift @@ -0,0 +1,67 @@ +// +// This source file is part of the Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import XCTest + + +struct SignupView: AccountValueView { + let app: XCUIApplication + + init(app: XCUIApplication) { + self.app = app + } + + func verify() { + XCTAssertTrue(app.staticTexts["Please fill out the details below to create a new account."].waitForExistence(timeout: 6.0)) + } + + func fillForm( + email: String, + password: String, + name: PersonNameComponents? = nil, + genderIdentity: String? = nil, + supplyDateOfBirth: Bool = false + ) throws { + try enter(field: "E-Mail Address", text: email) + + try enter(secureField: "Password", text: password) + + if let name { + if let firstname = name.givenName { + try enter(field: "enter first name", text: firstname) + } + if let lastname = name.familyName { + try enter(field: "enter last name", text: lastname) + } + } + + if let genderIdentity { + self.updateGenderIdentity(from: "Choose not to answer", to: genderIdentity) + } + + if supplyDateOfBirth { + self.changeDatePreviousMonthFirstDay() + } + } + + func signup(sleep sleepMillis: UInt32 = 0) { + tap(button: "Signup") + if sleepMillis > 0 { + sleep(sleepMillis) + } + } + + func tapBack(timeout: TimeInterval = 1.0) -> TestableAccountSetup { + XCTAssertTrue(app.navigationBars.buttons["Back"].waitForExistence(timeout: timeout)) + app.navigationBars.buttons["Back"].tap() + + let setup = TestableAccountSetup(app: app) + setup.verify() + return setup + } +} diff --git a/Tests/UITests/TestAppUITests/Utils/TestApp.swift b/Tests/UITests/TestAppUITests/Utils/TestApp.swift new file mode 100644 index 00000000..c2b0d164 --- /dev/null +++ b/Tests/UITests/TestAppUITests/Utils/TestApp.swift @@ -0,0 +1,55 @@ +// +// This source file is part of the Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import XCTest + + +struct TestApp: TestableView { + let app: XCUIApplication + + init(app: XCUIApplication) { + self.app = app + } + + static func launch( // swiftlint:disable:this function_default_parameter_at_end + serviceType: String = "mail", + config: String = "default", + defaultCredentials: Bool = false, + flags: String... + ) -> TestApp { + let app = XCUIApplication() + app.launchArguments = ["--service-type", serviceType, "--configuration-type", config] + + (defaultCredentials ? ["--default-credentials"] : []) + + flags + app.launch() + + let testApp = TestApp(app: app) + testApp.verify() + return testApp + } + + func verify(timeout: TimeInterval = 1.0) { + XCTAssertTrue(app.staticTexts["Spezi Account"].waitForExistence(timeout: timeout)) + } + + func openAccountSetup(timeout: TimeInterval = 1.0) -> TestableAccountSetup { + tap(button: "Account Setup", timeout: timeout) + + let setup = TestableAccountSetup(app: app) + setup.verify() + return setup + } + + func openAccountOverview(timeout: TimeInterval = 1.0) -> TestableAccountOverview { + tap(button: "Account Overview", timeout: timeout) + + let overview = TestableAccountOverview(app: app) + overview.verify() + return overview + } +} diff --git a/Tests/UITests/TestAppUITests/Utils/TestableAccountOverview.swift b/Tests/UITests/TestAppUITests/Utils/TestableAccountOverview.swift new file mode 100644 index 00000000..e1df0fe0 --- /dev/null +++ b/Tests/UITests/TestAppUITests/Utils/TestableAccountOverview.swift @@ -0,0 +1,23 @@ +// +// This source file is part of the Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import XCTest + + +struct TestableAccountOverview: AccountValueView { + let app: XCUIApplication + + init(app: XCUIApplication) { + self.app = app + } + + func verify(headerText: String = "Account Overview") { + XCTAssertTrue(app.staticTexts[headerText].waitForExistence(timeout: 6.0)) + XCTAssertTrue(app.buttons["Edit"].waitForExistence(timeout: 6.0)) + } +} diff --git a/Tests/UITests/TestAppUITests/Utils/TestableAccountSetup.swift b/Tests/UITests/TestAppUITests/Utils/TestableAccountSetup.swift new file mode 100644 index 00000000..0447a04a --- /dev/null +++ b/Tests/UITests/TestAppUITests/Utils/TestableAccountSetup.swift @@ -0,0 +1,53 @@ +// +// This source file is part of the Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import XCTest + + +struct TestableAccountSetup: AccountValueView { + let app: XCUIApplication + + init(app: XCUIApplication) { + self.app = app + } + + func verify(headerText: String = "Your Account") { + XCTAssertTrue(app.staticTexts[headerText].waitForExistence(timeout: 2.0)) + } + + func tapLogin(sleep sleepMillis: UInt32 = 0) { + tap(button: "Login") + if sleepMillis > 0 { + sleep(sleepMillis) + } + } + + func login(email: Email, password: Password, sleep sleepMillis: UInt32 = 0) throws { + try enter(field: "E-Mail Address", text: email) + try enter(secureField: "Password", text: password) + + tapLogin(sleep: sleepMillis) + } + + func login(username: Username, password: Password, sleep sleepMillis: UInt32 = 0) throws { + try enter(field: "Username", text: username) + try enter(secureField: "Password", text: password) + + tapLogin(sleep: sleepMillis) + } + + func openSignup(sleep sleepMillis: UInt32 = 0) -> SignupView { + tap(button: "Signup") + let view = SignupView(app: app) + view.verify() + if sleepMillis > 0 { + sleep(sleepMillis) + } + return view + } +} diff --git a/Tests/UITests/TestAppUITests/Utils/TestableView.swift b/Tests/UITests/TestAppUITests/Utils/TestableView.swift new file mode 100644 index 00000000..ee453dbf --- /dev/null +++ b/Tests/UITests/TestAppUITests/Utils/TestableView.swift @@ -0,0 +1,77 @@ +// +// This source file is part of the Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import XCTest + + +@dynamicMemberLookup +protocol TestableView { + var app: XCUIApplication { get } + + func tap(button: String) + + func tap(button: String, timeout: TimeInterval) + + func enter(field: String, text: Input) throws + + func enter(secureField: String, text: Input) throws + + func delete(field: String, count: Int) throws + + func delete(secureField: String, count: Int) throws + + subscript(dynamicMember dynamicMember: KeyPath) -> Value { get } +} + +extension TestableView { + func tap(button: String) { + tap(button: button, timeout: 1.0) + } + + func tap(button: String, timeout: TimeInterval) { + XCTAssertTrue(app.buttons[button].waitForExistence(timeout: timeout)) + app.buttons[button].tap() + } + + func enter(field: String, text: Input) throws { + XCTAssertTrue(app.textFields[field].waitForExistence(timeout: 1.0)) + try app.textFields[field].enter(value: String(text)) + } + + func enter(secureField: String, text: Input) throws { + XCTAssertTrue(app.secureTextFields[secureField].waitForExistence(timeout: 1.0)) + try app.secureTextFields[secureField].enter(value: String(text)) + } + + func delete(field: String, count: Int) throws { + XCTAssertTrue(app.textFields[field].waitForExistence(timeout: 1.0)) + try app.textFields[field].delete(count: count) + } + + func delete(secureField: String, count: Int) throws { + XCTAssertTrue(app.secureTextFields[secureField].waitForExistence(timeout: 1.0)) + try app.secureTextFields[secureField].delete(count: count) + } + + func verifyExistence(text: String, timeout: TimeInterval = 0.5) { + XCTAssertTrue(app.staticTexts[text].waitForExistence(timeout: timeout)) + } + + func verifyExistence(textField: String, timeout: TimeInterval = 0.5) { + XCTAssertTrue(app.textFields[textField].waitForExistence(timeout: timeout)) + } + + func verifyExistence(secureField: String, timeout: TimeInterval = 0.5) { + XCTAssertTrue(app.secureTextFields[secureField].waitForExistence(timeout: timeout)) + } + + + subscript(dynamicMember dynamicMember: KeyPath) -> Value { + app[keyPath: dynamicMember] + } +} diff --git a/Tests/UITests/UITests.xcodeproj/project.pbxproj b/Tests/UITests/UITests.xcodeproj/project.pbxproj index 352832c5..c03fd30c 100644 --- a/Tests/UITests/UITests.xcodeproj/project.pbxproj +++ b/Tests/UITests/UITests.xcodeproj/project.pbxproj @@ -10,20 +10,28 @@ 2F027C8629D6C2AD00234098 /* AccountTestsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F027C7B29D6C29B00234098 /* AccountTestsView.swift */; }; 2F027C8729D6C2AD00234098 /* TestAccountConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F027C7E29D6C29B00234098 /* TestAccountConfiguration.swift */; }; 2F027C8829D6C2AD00234098 /* MockAccountServiceError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F027C7A29D6C29B00234098 /* MockAccountServiceError.swift */; }; - 2F027C8929D6C2AD00234098 /* User.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F027C7F29D6C29B00234098 /* User.swift */; }; - 2F027C8A29D6C2AD00234098 /* MockEmailPasswordAccountService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F027C7C29D6C29B00234098 /* MockEmailPasswordAccountService.swift */; }; - 2F027C8B29D6C2AD00234098 /* MockUsernamePasswordAccountService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F027C7D29D6C29B00234098 /* MockUsernamePasswordAccountService.swift */; }; - 2F027C8F29D6C2CD00234098 /* AccountLoginTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F027C8C29D6C2CC00234098 /* AccountLoginTests.swift */; }; - 2F027C9029D6C2CD00234098 /* AccountResetPasswordTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F027C8D29D6C2CC00234098 /* AccountResetPasswordTests.swift */; }; - 2F027C9129D6C2CD00234098 /* AccountSignUpTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F027C8E29D6C2CD00234098 /* AccountSignUpTests.swift */; }; + 2F027C8929D6C2AD00234098 /* UserStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F027C7F29D6C29B00234098 /* UserStorage.swift */; }; 2F027C9529D6C63100234098 /* TestAppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F027C9429D6C63100234098 /* TestAppDelegate.swift */; }; 2F027C9B29D6C91E00234098 /* XCTestExtensions in Frameworks */ = {isa = PBXBuildFile; productRef = 2F027C9A29D6C91E00234098 /* XCTestExtensions */; }; 2F027C9D29D6CA1100234098 /* XCUIApplication+TestPrimaryButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F027C9C29D6CA1100234098 /* XCUIApplication+TestPrimaryButton.swift */; }; 2F6D139A28F5F386007C25D6 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 2F6D139928F5F386007C25D6 /* Assets.xcassets */; }; 2FA7382C290ADFAA007ACEB9 /* TestApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FA7382B290ADFAA007ACEB9 /* TestApp.swift */; }; 2FAD38C02A455FC200E79ED1 /* SpeziAccount in Frameworks */ = {isa = PBXBuildFile; productRef = 2FAD38BF2A455FC200E79ED1 /* SpeziAccount */; }; - A9EE7D282A3357D900C2B9A9 /* EmptyAccountServicesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9EE7D272A3357D900C2B9A9 /* EmptyAccountServicesTests.swift */; }; - A9EE7D2A2A3359E800C2B9A9 /* FeatureFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9EE7D292A3359E800C2B9A9 /* FeatureFlags.swift */; }; + A969240F2A9A198800E2128B /* ArgumentParser in Frameworks */ = {isa = PBXBuildFile; productRef = A969240E2A9A198800E2128B /* ArgumentParser */; }; + A96924112A9A2E7800E2128B /* TestAccountService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A96924102A9A2E7800E2128B /* TestAccountService.swift */; }; + A9B6E3F72A9B6F5B0008B232 /* AccountSetupTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9B6E3F62A9B6F5B0008B232 /* AccountSetupTests.swift */; }; + A9B6E3F92A9B6F660008B232 /* AccountOverviewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9B6E3F82A9B6F660008B232 /* AccountOverviewTests.swift */; }; + A9B6E3FB2A9B70360008B232 /* Defaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9B6E3FA2A9B70360008B232 /* Defaults.swift */; }; + A9B6E3FD2A9B74830008B232 /* BiographyKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9B6E3FC2A9B74830008B232 /* BiographyKey.swift */; }; + A9B6E3FF2A9B795C0008B232 /* TestStandard.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9B6E3FE2A9B795C0008B232 /* TestStandard.swift */; }; + A9B6E4012A9BB2670008B232 /* TestableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9B6E4002A9BB2670008B232 /* TestableView.swift */; }; + A9B6E4032A9BB2720008B232 /* AccountValueView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9B6E4022A9BB2720008B232 /* AccountValueView.swift */; }; + A9B6E4052A9BB27A0008B232 /* TestableAccountSetup.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9B6E4042A9BB27A0008B232 /* TestableAccountSetup.swift */; }; + A9B6E4072A9BB2830008B232 /* SignupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9B6E4062A9BB2830008B232 /* SignupView.swift */; }; + A9B6E4092A9BB28C0008B232 /* TestableAccountOverview.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9B6E4082A9BB28C0008B232 /* TestableAccountOverview.swift */; }; + A9B6E40B2A9BB2980008B232 /* TestApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9B6E40A2A9BB2980008B232 /* TestApp.swift */; }; + A9EE7D282A3357D900C2B9A9 /* DocumentationHintsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9EE7D272A3357D900C2B9A9 /* DocumentationHintsTests.swift */; }; + A9EE7D2A2A3359E800C2B9A9 /* Features.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9EE7D292A3359E800C2B9A9 /* Features.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -39,15 +47,9 @@ /* Begin PBXFileReference section */ 2F027C7A29D6C29B00234098 /* MockAccountServiceError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockAccountServiceError.swift; sourceTree = ""; }; 2F027C7B29D6C29B00234098 /* AccountTestsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AccountTestsView.swift; sourceTree = ""; }; - 2F027C7C29D6C29B00234098 /* MockEmailPasswordAccountService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockEmailPasswordAccountService.swift; sourceTree = ""; }; - 2F027C7D29D6C29B00234098 /* MockUsernamePasswordAccountService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockUsernamePasswordAccountService.swift; sourceTree = ""; }; 2F027C7E29D6C29B00234098 /* TestAccountConfiguration.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TestAccountConfiguration.swift; sourceTree = ""; }; - 2F027C7F29D6C29B00234098 /* User.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = User.swift; sourceTree = ""; }; - 2F027C8C29D6C2CC00234098 /* AccountLoginTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AccountLoginTests.swift; sourceTree = ""; }; - 2F027C8D29D6C2CC00234098 /* AccountResetPasswordTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AccountResetPasswordTests.swift; sourceTree = ""; }; - 2F027C8E29D6C2CD00234098 /* AccountSignUpTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AccountSignUpTests.swift; sourceTree = ""; }; + 2F027C7F29D6C29B00234098 /* UserStorage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UserStorage.swift; sourceTree = ""; }; 2F027C9429D6C63100234098 /* TestAppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestAppDelegate.swift; sourceTree = ""; }; - 2F027C9729D6C6DB00234098 /* TestAppStandard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestAppStandard.swift; sourceTree = ""; }; 2F027C9C29D6CA1100234098 /* XCUIApplication+TestPrimaryButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "XCUIApplication+TestPrimaryButton.swift"; sourceTree = ""; }; 2F6D139228F5F384007C25D6 /* TestApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TestApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; 2F6D139928F5F386007C25D6 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -55,8 +57,20 @@ 2FA7382B290ADFAA007ACEB9 /* TestApp.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TestApp.swift; sourceTree = ""; }; 2FAD38BE2A455F7D00E79ED1 /* SpeziAccount */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = SpeziAccount; path = ../..; sourceTree = ""; }; 2FE750C92A8720CE00723EAE /* TestApp.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = TestApp.xctestplan; sourceTree = ""; }; - A9EE7D272A3357D900C2B9A9 /* EmptyAccountServicesTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EmptyAccountServicesTests.swift; sourceTree = ""; }; - A9EE7D292A3359E800C2B9A9 /* FeatureFlags.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureFlags.swift; sourceTree = ""; }; + A96924102A9A2E7800E2128B /* TestAccountService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestAccountService.swift; sourceTree = ""; }; + A9B6E3F62A9B6F5B0008B232 /* AccountSetupTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountSetupTests.swift; sourceTree = ""; }; + A9B6E3F82A9B6F660008B232 /* AccountOverviewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountOverviewTests.swift; sourceTree = ""; }; + A9B6E3FA2A9B70360008B232 /* Defaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Defaults.swift; sourceTree = ""; }; + A9B6E3FC2A9B74830008B232 /* BiographyKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BiographyKey.swift; sourceTree = ""; }; + A9B6E3FE2A9B795C0008B232 /* TestStandard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestStandard.swift; sourceTree = ""; }; + A9B6E4002A9BB2670008B232 /* TestableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestableView.swift; sourceTree = ""; }; + A9B6E4022A9BB2720008B232 /* AccountValueView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountValueView.swift; sourceTree = ""; }; + A9B6E4042A9BB27A0008B232 /* TestableAccountSetup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestableAccountSetup.swift; sourceTree = ""; }; + A9B6E4062A9BB2830008B232 /* SignupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignupView.swift; sourceTree = ""; }; + A9B6E4082A9BB28C0008B232 /* TestableAccountOverview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestableAccountOverview.swift; sourceTree = ""; }; + A9B6E40A2A9BB2980008B232 /* TestApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestApp.swift; sourceTree = ""; }; + A9EE7D272A3357D900C2B9A9 /* DocumentationHintsTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DocumentationHintsTests.swift; sourceTree = ""; }; + A9EE7D292A3359E800C2B9A9 /* Features.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Features.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -64,6 +78,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + A969240F2A9A198800E2128B /* ArgumentParser in Frameworks */, 2FAD38C02A455FC200E79ED1 /* SpeziAccount in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -84,10 +99,11 @@ children = ( 2F027C7B29D6C29B00234098 /* AccountTestsView.swift */, 2F027C7A29D6C29B00234098 /* MockAccountServiceError.swift */, - 2F027C7C29D6C29B00234098 /* MockEmailPasswordAccountService.swift */, - 2F027C7D29D6C29B00234098 /* MockUsernamePasswordAccountService.swift */, 2F027C7E29D6C29B00234098 /* TestAccountConfiguration.swift */, - 2F027C7F29D6C29B00234098 /* User.swift */, + 2F027C7F29D6C29B00234098 /* UserStorage.swift */, + A96924102A9A2E7800E2128B /* TestAccountService.swift */, + A9B6E3FC2A9B74830008B232 /* BiographyKey.swift */, + A9B6E3FE2A9B795C0008B232 /* TestStandard.swift */, ); path = AccountTests; sourceTree = ""; @@ -117,10 +133,9 @@ isa = PBXGroup; children = ( 2F027C9629D6C63300234098 /* AccountTests */, - A9EE7D292A3359E800C2B9A9 /* FeatureFlags.swift */, + A9EE7D292A3359E800C2B9A9 /* Features.swift */, 2FA7382B290ADFAA007ACEB9 /* TestApp.swift */, 2F027C9429D6C63100234098 /* TestAppDelegate.swift */, - 2F027C9729D6C6DB00234098 /* TestAppStandard.swift */, 2F6D139928F5F386007C25D6 /* Assets.xcassets */, ); path = TestApp; @@ -129,11 +144,11 @@ 2F6D13AF28F5F386007C25D6 /* TestAppUITests */ = { isa = PBXGroup; children = ( + A96924122A9A6BA400E2128B /* Utils */, 2F027C9C29D6CA1100234098 /* XCUIApplication+TestPrimaryButton.swift */, - 2F027C8C29D6C2CC00234098 /* AccountLoginTests.swift */, - 2F027C8D29D6C2CC00234098 /* AccountResetPasswordTests.swift */, - A9EE7D272A3357D900C2B9A9 /* EmptyAccountServicesTests.swift */, - 2F027C8E29D6C2CD00234098 /* AccountSignUpTests.swift */, + A9EE7D272A3357D900C2B9A9 /* DocumentationHintsTests.swift */, + A9B6E3F62A9B6F5B0008B232 /* AccountSetupTests.swift */, + A9B6E3F82A9B6F660008B232 /* AccountOverviewTests.swift */, ); path = TestAppUITests; sourceTree = ""; @@ -145,6 +160,20 @@ name = Frameworks; sourceTree = ""; }; + A96924122A9A6BA400E2128B /* Utils */ = { + isa = PBXGroup; + children = ( + A9B6E3FA2A9B70360008B232 /* Defaults.swift */, + A9B6E4002A9BB2670008B232 /* TestableView.swift */, + A9B6E4022A9BB2720008B232 /* AccountValueView.swift */, + A9B6E4042A9BB27A0008B232 /* TestableAccountSetup.swift */, + A9B6E4062A9BB2830008B232 /* SignupView.swift */, + A9B6E4082A9BB28C0008B232 /* TestableAccountOverview.swift */, + A9B6E40A2A9BB2980008B232 /* TestApp.swift */, + ); + path = Utils; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -163,6 +192,7 @@ name = TestApp; packageProductDependencies = ( 2FAD38BF2A455FC200E79ED1 /* SpeziAccount */, + A969240E2A9A198800E2128B /* ArgumentParser */, ); productName = Example; productReference = 2F6D139228F5F384007C25D6 /* TestApp.app */; @@ -220,6 +250,7 @@ mainGroup = 2F6D138928F5F384007C25D6; packageReferences = ( 2F027C9929D6C91D00234098 /* XCRemoteSwiftPackageReference "XCTestExtensions" */, + A969240D2A9A198800E2128B /* XCRemoteSwiftPackageReference "swift-argument-parser" */, ); productRefGroup = 2F6D139328F5F384007C25D6 /* Products */; projectDirPath = ""; @@ -256,13 +287,14 @@ files = ( 2F027C8629D6C2AD00234098 /* AccountTestsView.swift in Sources */, 2FA7382C290ADFAA007ACEB9 /* TestApp.swift in Sources */, - 2F027C8B29D6C2AD00234098 /* MockUsernamePasswordAccountService.swift in Sources */, 2F027C8829D6C2AD00234098 /* MockAccountServiceError.swift in Sources */, - A9EE7D2A2A3359E800C2B9A9 /* FeatureFlags.swift in Sources */, + A9EE7D2A2A3359E800C2B9A9 /* Features.swift in Sources */, 2F027C9529D6C63100234098 /* TestAppDelegate.swift in Sources */, - 2F027C8929D6C2AD00234098 /* User.swift in Sources */, + 2F027C8929D6C2AD00234098 /* UserStorage.swift in Sources */, 2F027C8729D6C2AD00234098 /* TestAccountConfiguration.swift in Sources */, - 2F027C8A29D6C2AD00234098 /* MockEmailPasswordAccountService.swift in Sources */, + A96924112A9A2E7800E2128B /* TestAccountService.swift in Sources */, + A9B6E3FF2A9B795C0008B232 /* TestStandard.swift in Sources */, + A9B6E3FD2A9B74830008B232 /* BiographyKey.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -270,11 +302,17 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - A9EE7D282A3357D900C2B9A9 /* EmptyAccountServicesTests.swift in Sources */, - 2F027C9029D6C2CD00234098 /* AccountResetPasswordTests.swift in Sources */, + A9B6E3F72A9B6F5B0008B232 /* AccountSetupTests.swift in Sources */, + A9B6E3FB2A9B70360008B232 /* Defaults.swift in Sources */, + A9EE7D282A3357D900C2B9A9 /* DocumentationHintsTests.swift in Sources */, 2F027C9D29D6CA1100234098 /* XCUIApplication+TestPrimaryButton.swift in Sources */, - 2F027C8F29D6C2CD00234098 /* AccountLoginTests.swift in Sources */, - 2F027C9129D6C2CD00234098 /* AccountSignUpTests.swift in Sources */, + A9B6E4072A9BB2830008B232 /* SignupView.swift in Sources */, + A9B6E40B2A9BB2980008B232 /* TestApp.swift in Sources */, + A9B6E4012A9BB2670008B232 /* TestableView.swift in Sources */, + A9B6E4092A9BB28C0008B232 /* TestableAccountOverview.swift in Sources */, + A9B6E4032A9BB2720008B232 /* AccountValueView.swift in Sources */, + A9B6E4052A9BB27A0008B232 /* TestableAccountSetup.swift in Sources */, + A9B6E3F92A9B6F660008B232 /* AccountOverviewTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -544,7 +582,15 @@ repositoryURL = "https://github.com/StanfordSpezi/XCTestExtensions"; requirement = { kind = upToNextMinorVersion; - minimumVersion = 0.4.3; + minimumVersion = 0.4.7; + }; + }; + A969240D2A9A198800E2128B /* XCRemoteSwiftPackageReference "swift-argument-parser" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/apple/swift-argument-parser.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.0.0; }; }; /* End XCRemoteSwiftPackageReference section */ @@ -559,6 +605,11 @@ isa = XCSwiftPackageProductDependency; productName = SpeziAccount; }; + A969240E2A9A198800E2128B /* ArgumentParser */ = { + isa = XCSwiftPackageProductDependency; + package = A969240D2A9A198800E2128B /* XCRemoteSwiftPackageReference "swift-argument-parser" */; + productName = ArgumentParser; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 2F6D138A28F5F384007C25D6 /* Project object */; diff --git a/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme b/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme index ab95fb6c..0c97f064 100644 --- a/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme +++ b/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme @@ -1,25 +1,10 @@ - - - - - - - - - - + + + + + + + + - + - +