From af9636a51d4e60f0d19709a22a5ac4a88d7fd089 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Sun, 2 Jul 2023 18:46:18 +0200 Subject: [PATCH] Initial draft of the AccountValue system; some localization, Account Summary Views --- Sources/SpeziAccount/Account.swift | 21 +- .../UserIdPasswordAccountService.swift | 14 +- Sources/SpeziAccount/AccountSetup.swift | 182 ++++++++++++------ .../AccountSetupViewStyle.swift | 2 +- .../UserIdPasswordAccountSetupViewStyle.swift | 4 +- .../AccountService/MockAccountServices.swift | 5 +- .../Model/AccountValue/AccountValueKey.swift | 13 ++ .../AccountValueRequirements.swift | 61 ++++++ .../AccountValueStorage+Builder.swift | 51 +++++ .../AccountValue/AccountValueStorage.swift | 76 ++++++++ .../DateOfBirthAccountValueKey.swift | 31 +++ .../GenderIdentityAccountValueKey.swift | 28 +++ .../AccountValue/NameAccountValueKey.swift | 31 +++ .../PasswordAccountValueKey.swift | 28 +++ .../AccountValue/UserIdAccountValueKey.swift | 33 ++++ .../Model/Signup/GenderIdentity.swift | 16 +- .../Model/SignupRequest/SignupRequest.swift | 15 ++ .../Components/Model/UserIdType.swift | 28 +++ .../DefaultAccountSetupViewStyle.swift | 4 +- .../Button/AsyncDataEntrySubmitButton.swift | 22 ++- .../DefaultSuccessfulPasswordResetView.swift | 18 +- ...aultUserIdPasswordAccountSummaryView.swift | 48 +++-- .../DefaultUserIdPasswordEmbeddedView.swift | 80 +++----- .../DefaultUserIdPasswordPrimaryView.swift | 24 +-- .../DefaultUserIdPasswordResetView.swift | 6 +- .../DefaultUserIdPasswordSignUpView.swift | 65 ++++--- .../Views/Fields/NameTextFields.swift | 1 + .../Views/Picker/GenderIdentityPicker.swift | 2 +- .../Views/User/UserInformation.swift | 58 ++++++ .../Localization/Localization.swift | 3 - .../UsernamePasswordResetPasswordView.swift | 2 +- .../Shared/AccountInputFields.swift | 6 +- .../Shared/UsernamePasswordFields.swift | 2 +- .../Resources/de.lproj/Localizable.strings | 8 +- .../Resources/en.lproj/Localizable.strings | 44 ++++- 35 files changed, 794 insertions(+), 238 deletions(-) create mode 100644 Sources/SpeziAccount/Components/Model/AccountValue/AccountValueKey.swift create mode 100644 Sources/SpeziAccount/Components/Model/AccountValue/AccountValueRequirements.swift create mode 100644 Sources/SpeziAccount/Components/Model/AccountValue/AccountValueStorage+Builder.swift create mode 100644 Sources/SpeziAccount/Components/Model/AccountValue/AccountValueStorage.swift create mode 100644 Sources/SpeziAccount/Components/Model/AccountValue/DateOfBirthAccountValueKey.swift create mode 100644 Sources/SpeziAccount/Components/Model/AccountValue/GenderIdentityAccountValueKey.swift create mode 100644 Sources/SpeziAccount/Components/Model/AccountValue/NameAccountValueKey.swift create mode 100644 Sources/SpeziAccount/Components/Model/AccountValue/PasswordAccountValueKey.swift create mode 100644 Sources/SpeziAccount/Components/Model/AccountValue/UserIdAccountValueKey.swift create mode 100644 Sources/SpeziAccount/Components/Model/SignupRequest/SignupRequest.swift create mode 100644 Sources/SpeziAccount/Components/Model/UserIdType.swift create mode 100644 Sources/SpeziAccount/Components/Views/User/UserInformation.swift diff --git a/Sources/SpeziAccount/Account.swift b/Sources/SpeziAccount/Account.swift index 99b2c340..1befe7f7 100644 --- a/Sources/SpeziAccount/Account.swift +++ b/Sources/SpeziAccount/Account.swift @@ -11,13 +11,19 @@ import SwiftUI /// Account-related Spezi module managing a collection of ``AccountService``s. +/// TODO update docs! /// /// The ``Account/Account`` type also enables interaction with the ``AccountService``s from anywhere in the view hierarchy. public actor Account: ObservableObject { /// The ``Account/Account/signedIn`` determines if the the current Account context is signed in or not yet signed in. + @MainActor + public var signedIn: Bool { + account != nil + } + @MainActor @Published - public var signedIn = false + public private(set) var account: AccountValuesWhat? // TODO UserAccount/User! TODO must only be accessible/modifieable through an AccountService! // TODO how to get to the account service that holds the active account? @@ -34,4 +40,17 @@ public actor Account: ObservableObject { // accountService.inject(account: self) // } } + + init(accountServices: [any AccountService] = [], account: AccountValuesWhat) { + self.accountServices = accountServices + self._account = Published(wrappedValue: account) + } +} + +public struct AccountValuesWhat: Sendable, ModifiableAccountValueStorageContainer { // TODO naming is off! + public var storage: AccountValueStorage + + public init(storage: AccountValueStorage) { + self.storage = storage + } } diff --git a/Sources/SpeziAccount/AccountService/UserIdPasswordAccountService.swift b/Sources/SpeziAccount/AccountService/UserIdPasswordAccountService.swift index 549136ef..5fdbc721 100644 --- a/Sources/SpeziAccount/AccountService/UserIdPasswordAccountService.swift +++ b/Sources/SpeziAccount/AccountService/UserIdPasswordAccountService.swift @@ -9,6 +9,7 @@ import Foundation import SwiftUI +// TODO this configuration should be user accessible (userIdType, userIdField config)! public struct UserIdPasswordServiceConfiguration { public static var defaultAccountImage: Image { Image(systemName: "person.crop.circle.fill") @@ -19,9 +20,11 @@ public struct UserIdPasswordServiceConfiguration { public let name: LocalizedStringResource public let image: Image - public let signUpOptions: SignUpOptions + // TODO they are not the requirements? you might enter optional values, those are displayed but not required! + public let signUpRequirements: AccountValueRequirements // TODO replace this with a type that is queryable! // TODO localization + public let userIdType: UserIdType public let userIdField: FieldConfiguration // TODO login and reset just validates non-empty! @@ -32,14 +35,16 @@ public struct UserIdPasswordServiceConfiguration { public init( name: LocalizedStringResource, image: Image = defaultAccountImage, - signUpOptions: SignUpOptions = .default, + signUpRequirements: AccountValueRequirements = AccountValueRequirements(), // TODO provide default! + userIdType: UserIdType = .emailAddress, userIdField: FieldConfiguration = .username, userIdSignupValidations: [ValidationRule] = [.nonEmpty], passwordSignupValidations: [ValidationRule] = [.nonEmpty] ) { self.name = name self.image = image - self.signUpOptions = signUpOptions + self.signUpRequirements = signUpRequirements + self.userIdType = userIdType self.userIdField = userIdField self.userIdSignupValidations = userIdSignupValidations self.passwordSignupValidations = passwordSignupValidations @@ -51,8 +56,7 @@ public protocol UserIdPasswordAccountService: AccountService, EmbeddableAccountS func login(userId: String, password: String) async throws - // TODO ability to abstract SignUpValues - func signUp(signUpValues: SignUpValues) async throws // TODO refactor SignUpValues property names! + func signUp(signupRequest: SignupRequest) async throws func resetPassword(userId: String) async throws } diff --git a/Sources/SpeziAccount/AccountSetup.swift b/Sources/SpeziAccount/AccountSetup.swift index d92aab5e..1bc05c6f 100644 --- a/Sources/SpeziAccount/AccountSetup.swift +++ b/Sources/SpeziAccount/AccountSetup.swift @@ -7,23 +7,58 @@ // import AuthenticationServices +import SpeziViews import SwiftUI -struct AccountSetup: View { - public enum Constants { - static let outerHorizontalPadding: CGFloat = 16 // TODO use 32? - static let innerHorizontalPadding: CGFloat = 16 // TODO use 32? - static let maxFrameWidth: CGFloat = 450 - } +public enum Constants { + static let outerHorizontalPadding: CGFloat = 16 // TODO use 32? + static let innerHorizontalPadding: CGFloat = 16 // TODO use 32? + static let maxFrameWidth: CGFloat = 450 +} +/// A view which provides the default titlte and subtitlte text. +public struct DefaultHeader: View { // TODO rename! @EnvironmentObject - var account: Account + private var account: Account + + public var body: some View { + // TODO provide customizable with AccountViewStyle! + Text("ACCOUNT_WELCOME".localized(.module)) + .font(.largeTitle) + .bold() + .multilineTextAlignment(.center) + .padding(.bottom) + .padding(.top, 30) + + Group { + if !account.signedIn { + Text("ACCOUNT_WELCOME_SUBTITLE".localized(.module)) + } else { + Text("ACCOUNT_WELCOME_SIGNED_IN_SUBTITLE".localized(.module)) + } + } + .multilineTextAlignment(.center) + } + + public init() {} +} + +public struct AccountSetup: View { + private let header: Header + + @EnvironmentObject var account: Account + @Environment(\.colorScheme) + var colorScheme - var services: [any AccountService] { + private var services: [any AccountService] { account.accountServices } - var embeddableAccountService: (any EmbeddableAccountService)? { + private var identityProviders: [String] { + ["Apple"] // TODO query from account and model them + } + + private var embeddableAccountService: (any EmbeddableAccountService)? { let embeddableServices = services .filter { $0 is any EmbeddableAccountService } @@ -35,35 +70,35 @@ struct AccountSetup: View { return nil } - var nonEmbeddableAccountServices: [any AccountService] { + private var nonEmbeddableAccountServices: [any AccountService] { services .filter { !($0 is any EmbeddableAccountService) } } - @Environment(\.colorScheme) - var colorScheme + 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 { + public var body: some View { GeometryReader { proxy in ScrollView(.vertical) { VStack { - // TODO draw account summary if we are already signed in! header Spacer() - VStack { - primaryAccountServicesReplacement - - // TODO show divider only if there is a least one account service AND identity provider! - servicesDivider - - identityProviderButtons + if let account = account.account { + displayAccount(account: account) + } else { + noAccountState } - .padding(.horizontal, Constants.innerHorizontalPadding) - .frame(maxWidth: Constants.maxFrameWidth) // landscape optimizations - // TODO for large dynamic size it would make sense to scale it though? + // TODO provide ability to inject footer (e.g., terms and conditions?) Spacer() Spacer() Spacer() @@ -75,30 +110,43 @@ struct AccountSetup: View { } } - /// The Views Title and subtitle text. - @ViewBuilder - var header: some View { - // TODO provide customizable with AccountViewStyle! - Text("Welcome! 👋") // TODO localize - .font(.largeTitle) - .bold() - .multilineTextAlignment(.center) - .padding(.bottom) - .padding(.top, 30) + @ViewBuilder var noAccountState: some View { + if services.isEmpty && identityProviders.isEmpty { + showEmptyView + } else { + VStack { + accountServicesSection - Text("Please create an account to do whatever. You may create an account if you don't have one already!") // TODO localize! + if !services.isEmpty && !identityProviders.isEmpty { + servicesDivider + } + + identityProviderSection + } + .padding(.horizontal, Constants.innerHorizontalPadding) + .frame(maxWidth: Constants.maxFrameWidth) // landscape optimizations + // TODO for large dynamic size it would make sense to scale it though? + } + } + + @ViewBuilder var showEmptyView: some View { + Text("MISSING_ACCOUNT_SERVICES".localized(.module)) .multilineTextAlignment(.center) + .foregroundColor(.secondary) + + Button(action: { + UIApplication.shared.open(documentationUrl) + }) { + Text("OPEN_DOCUMENTATION".localized(.module)) + } + .padding() } - @ViewBuilder - var primaryAccountServicesReplacement: some View { - if services.isEmpty { - Text("Empty!! :(((") // TODO only place hint if there are not even identity providers! - } else if let embeddableService = embeddableAccountService { + @ViewBuilder var accountServicesSection: some View { + if let embeddableService = embeddableAccountService { let embeddableViewStyle = embeddableService.viewStyle // TODO i can get back type erasure right? AnyView(embeddableViewStyle.makeEmbeddedAccountView()) - // TODO inject account service!! lol, nothing is typed! if !nonEmbeddableAccountServices.isEmpty { @@ -135,20 +183,12 @@ struct AccountSetup: View { // TODO may we provide a default implementation, or work with a optional serviceButton style? } - /* - Button(action: { - print("Navigation?") - }) { - Text("Account service \(index)") // TODO we need a name? - } - */ } } } // The "or" divider between primary account services and the third-party identity providers - @ViewBuilder - var servicesDivider: some View { + @ViewBuilder var servicesDivider: some View { HStack { VStack { Divider() @@ -166,17 +206,17 @@ struct AccountSetup: View { } /// VStack of buttons provided by the identity providers - @ViewBuilder - var identityProviderButtons: some View { + @ViewBuilder var identityProviderSection: some View { VStack { - SignInWithAppleButton { request in - print("Sign in request!") - } onCompletion: { result in - print("sing in completed") - } + ForEach(identityProviders.indices, id: \.self) { index in + SignInWithAppleButton { request in + print("Sign in request!") + } onCompletion: { result in + print("sing in completed") + } .frame(height: 55) - .signInWithAppleButtonStyle(colorScheme == .light ? .black : .white) + } } // TODO we want to check if there is a single username/password provider and the rest are identity providers! @@ -185,6 +225,19 @@ struct AccountSetup: View { // => KeyPasswordBasedAuthentication[Service] // => IdentityProvideBasedAuthentication[Service] } + + // TODO docs + public init(@ViewBuilder _ header: () -> Header = { DefaultHeader() }) { + self.header = header() + } + + func displayAccount(account: AccountValuesWhat) -> some View { + let service = self.account.accountServices.first! // TODO how to get the primary account service! + + // TODO someone needs to place the Continue button? + + return AnyView(service.viewStyle.makeAccountSummary(account: account)) + } } #if DEBUG @@ -203,6 +256,11 @@ struct AccountView_Previews: PreviewProvider { ] }() + static let account1: AccountValuesWhat = AccountValueStorageBuilder() + .add(UserIdAccountValueKey.self, value: "andi.bauer@tum.de") + .add(NameAccountValueKey.self, value: PersonNameComponents(givenName: "Andreas", familyName: "Bauer")) + .build() + static var previews: some View { ForEach(accountServicePermutations.indices, id: \.self) { index in NavigationStack { @@ -210,6 +268,14 @@ struct AccountView_Previews: PreviewProvider { } .environmentObject(Account(accountServices: accountServicePermutations[index])) } + + NavigationStack { + AccountSetup() + } + .environmentObject(Account( + accountServices: [DefaultUsernamePasswordAccountService()], + account: account1 + )) } } #endif diff --git a/Sources/SpeziAccount/AccountSetupViewStyle/AccountSetupViewStyle.swift b/Sources/SpeziAccount/AccountSetupViewStyle/AccountSetupViewStyle.swift index d1e30069..d1a80f40 100644 --- a/Sources/SpeziAccount/AccountSetupViewStyle/AccountSetupViewStyle.swift +++ b/Sources/SpeziAccount/AccountSetupViewStyle/AccountSetupViewStyle.swift @@ -27,5 +27,5 @@ public protocol AccountSetupViewStyle { func makePrimaryView() -> PrimaryView @ViewBuilder - func makeAccountSummary() -> AccountSummaryView + func makeAccountSummary(account: AccountValuesWhat) -> AccountSummaryView } diff --git a/Sources/SpeziAccount/AccountSetupViewStyle/UserIdPasswordAccountSetupViewStyle.swift b/Sources/SpeziAccount/AccountSetupViewStyle/UserIdPasswordAccountSetupViewStyle.swift index abf4bfbf..12353517 100644 --- a/Sources/SpeziAccount/AccountSetupViewStyle/UserIdPasswordAccountSetupViewStyle.swift +++ b/Sources/SpeziAccount/AccountSetupViewStyle/UserIdPasswordAccountSetupViewStyle.swift @@ -47,8 +47,8 @@ extension UserIdPasswordAccountSetupViewStyle { } } - public func makeAccountSummary() -> some View { - DefaultUserIdPasswordAccountSummaryView(using: service) + public func makeAccountSummary(account: AccountValuesWhat) -> some View { + DefaultUserIdPasswordAccountSummaryView(using: service, account: account) } public func makeAccountServiceButtonLabel() -> some View { diff --git a/Sources/SpeziAccount/Components/AccountService/MockAccountServices.swift b/Sources/SpeziAccount/Components/AccountService/MockAccountServices.swift index c174b670..4faf74d8 100644 --- a/Sources/SpeziAccount/Components/AccountService/MockAccountServices.swift +++ b/Sources/SpeziAccount/Components/AccountService/MockAccountServices.swift @@ -24,8 +24,8 @@ struct DefaultUsernamePasswordAccountService: UserIdPasswordAccountService { try? await Task.sleep(nanoseconds: 1000_000_000) } - func signUp(signUpValues: SignUpValues) async throws { - print("signup \(signUpValues)") + func signUp(signupRequest: SignupRequest) async throws { + print("signup \(signupRequest)") try? await Task.sleep(nanoseconds: 1000_000_000) } @@ -36,5 +36,6 @@ struct DefaultUsernamePasswordAccountService: UserIdPasswordAccountService { func logout() async throws { print("logout") + try? await Task.sleep(nanoseconds: 1000_000_000) } } diff --git a/Sources/SpeziAccount/Components/Model/AccountValue/AccountValueKey.swift b/Sources/SpeziAccount/Components/Model/AccountValue/AccountValueKey.swift new file mode 100644 index 00000000..103ed7c0 --- /dev/null +++ b/Sources/SpeziAccount/Components/Model/AccountValue/AccountValueKey.swift @@ -0,0 +1,13 @@ +// +// 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 +// + +public protocol AccountValueKey { // TODO this mandates a statically required account value key + associatedtype Value: Sendable +} + +public protocol OptionalAccountValueKey: AccountValueKey {} diff --git a/Sources/SpeziAccount/Components/Model/AccountValue/AccountValueRequirements.swift b/Sources/SpeziAccount/Components/Model/AccountValue/AccountValueRequirements.swift new file mode 100644 index 00000000..e3927381 --- /dev/null +++ b/Sources/SpeziAccount/Components/Model/AccountValue/AccountValueRequirements.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 Foundation + +private enum RequirementType { + /// The respective AccountValue MUST be provided by the user. + case required + /// The respective AccountValue CAN be provided by the user but there is no obligation to do so. + case displayed +} + +private protocol AnyRequirement { + var type: RequirementType { get } + + func hasValue(in storage: AccountValueStorage) -> Bool +} + +private struct Requirement: AnyRequirement { + let type: RequirementType + + func hasValue(in storage: AccountValueStorage) -> Bool { + // TODO how to check the value without throwing? + return true + } +} + +public struct AccountValueRequirements { + private var requirements: [ObjectIdentifier: AnyRequirement] = [:] + + public init() {} + + // TODO how to build? + + private func requirementType(for key: Key.Type) -> RequirementType? { + requirements[ObjectIdentifier(key)]?.type + } + + public func configured(_ key: Key.Type) -> Bool { + requirementType(for: key) != nil + } + + public func required(_ key: Key.Type) -> Bool { + requirementType(for: key) == .required + } + + public func validateRequirements(in storage: AccountValueStorage) { + for requirement in requirements.values where requirement.type == .required { + if !requirement.hasValue(in: storage) { + fatalError("Failed to have value in storage!") + // TODO get the name to throw! + // TODO make the error + } + } + } +} diff --git a/Sources/SpeziAccount/Components/Model/AccountValue/AccountValueStorage+Builder.swift b/Sources/SpeziAccount/Components/Model/AccountValue/AccountValueStorage+Builder.swift new file mode 100644 index 00000000..15ffb23b --- /dev/null +++ b/Sources/SpeziAccount/Components/Model/AccountValue/AccountValueStorage+Builder.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 +// + +public class AccountValueStorageBuilder { + private var contents: AccountValueStorage.StorageType + + public init() { + self.contents = [:] + } + + @discardableResult + public func add(_ key: Key.Type, value: Key.Value) -> Self { + contents[ObjectIdentifier(key)] = Entry(value: value) + return self + } + + @discardableResult + public func add( + _ key: Key.Type, + value: @autoclosure () -> Key.Value, + ifConfigured requirements: AccountValueRequirements + ) -> Self { + if requirements.configured(key) { + return add(key, value: value()) + } + return self + } + + public func build(_ type: Container.Type = Container.self) -> Container { + Container(storage: AccountValueStorage(contents: contents)) + } + + public func build( + _ type: Container.Type = Container.self, + checking requirements: AccountValueRequirements? = nil + ) -> Container { + let storage = AccountValueStorage(contents: contents) + + if let requirements { + // TODO sanity checks that all required properties are set! + requirements.validateRequirements(in: storage) + } + + return Container(storage: storage) + } +} diff --git a/Sources/SpeziAccount/Components/Model/AccountValue/AccountValueStorage.swift b/Sources/SpeziAccount/Components/Model/AccountValue/AccountValueStorage.swift new file mode 100644 index 00000000..fcc42c3b --- /dev/null +++ b/Sources/SpeziAccount/Components/Model/AccountValue/AccountValueStorage.swift @@ -0,0 +1,76 @@ +// +// 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 +// + +public protocol AnyEntry: Sendable {} + +struct Entry: AnyEntry { + let value: Key.Value +} + +public struct AccountValueStorage: Sendable { + typealias StorageType = [ObjectIdentifier: AnyEntry] + + private var contents: StorageType + + init(contents: StorageType = [:]) { + self.contents = contents + } + + public subscript(_ key: Key.Type) -> Key.Value { + get { + guard let value = get(key) else { + fatalError("The required AccountValue \(Key.self) was requested but not part of the container!") + } + + return value + } + set { + set(key, value: newValue) + } + } + + public subscript(_ key: Key.Type) -> Key.Value? { + get { + guard let value = get(key) else { + return nil + } + + return value + } + set { + set(key, value: newValue) + } + } + + private func get(_ key: Key.Type) -> Key.Value? { + // TODO check for Computed AccountValue? + guard let value = contents[ObjectIdentifier(key)] as? Key.Value else { + return nil + } + + return value + } + + private mutating func set(_ key: Key.Type, value: Key.Value?) { + if let value { + contents[ObjectIdentifier(key)] = Entry(value: value) + } else { + contents[ObjectIdentifier(key)] = nil + } + } +} + +public protocol AccountValueStorageContainer { + init(storage: AccountValueStorage) + + var storage: AccountValueStorage { get } +} + +public protocol ModifiableAccountValueStorageContainer: AccountValueStorageContainer { + var storage: AccountValueStorage { get set } +} diff --git a/Sources/SpeziAccount/Components/Model/AccountValue/DateOfBirthAccountValueKey.swift b/Sources/SpeziAccount/Components/Model/AccountValue/DateOfBirthAccountValueKey.swift new file mode 100644 index 00000000..9a57caea --- /dev/null +++ b/Sources/SpeziAccount/Components/Model/AccountValue/DateOfBirthAccountValueKey.swift @@ -0,0 +1,31 @@ +// +// 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 + +// TODO these are keys!!! +public struct DateOfBirthAccountValueKey: OptionalAccountValueKey { + public typealias Value = Date +} + +extension AccountValueStorageContainer { + public var dateOfBrith: DateOfBirthAccountValueKey.Value? { + storage[DateOfBirthAccountValueKey.self] + } +} + +extension ModifiableAccountValueStorageContainer { + public var dateOfBrith: DateOfBirthAccountValueKey.Value? { + get { + storage[DateOfBirthAccountValueKey.self] + } + set { + storage[DateOfBirthAccountValueKey.self] = newValue + } + } +} diff --git a/Sources/SpeziAccount/Components/Model/AccountValue/GenderIdentityAccountValueKey.swift b/Sources/SpeziAccount/Components/Model/AccountValue/GenderIdentityAccountValueKey.swift new file mode 100644 index 00000000..64865209 --- /dev/null +++ b/Sources/SpeziAccount/Components/Model/AccountValue/GenderIdentityAccountValueKey.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 +// + +public struct GenderIdentityAccountValueKey: OptionalAccountValueKey { + public typealias Value = GenderIdentity +} + +extension AccountValueStorageContainer { + public var genderIdentity: GenderIdentityAccountValueKey.Value? { + storage[GenderIdentityAccountValueKey.self] + } +} + +extension ModifiableAccountValueStorageContainer { + public var genderIdentity: GenderIdentityAccountValueKey.Value? { + get { + storage[GenderIdentityAccountValueKey.self] + } + set { + storage[GenderIdentityAccountValueKey.self] = newValue + } + } +} diff --git a/Sources/SpeziAccount/Components/Model/AccountValue/NameAccountValueKey.swift b/Sources/SpeziAccount/Components/Model/AccountValue/NameAccountValueKey.swift new file mode 100644 index 00000000..6e6ee5e6 --- /dev/null +++ b/Sources/SpeziAccount/Components/Model/AccountValue/NameAccountValueKey.swift @@ -0,0 +1,31 @@ +// +// 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 + +// TODO those are all called AccountValues now! +public struct NameAccountValueKey: AccountValueKey { + public typealias Value = PersonNameComponents +} + +extension AccountValueStorageContainer { + public var name: NameAccountValueKey.Value { + storage[NameAccountValueKey.self] + } +} + +extension ModifiableAccountValueStorageContainer { + public var name: NameAccountValueKey.Value { + get { + storage[NameAccountValueKey.self] + } + set { + storage[NameAccountValueKey.self] = newValue + } + } +} diff --git a/Sources/SpeziAccount/Components/Model/AccountValue/PasswordAccountValueKey.swift b/Sources/SpeziAccount/Components/Model/AccountValue/PasswordAccountValueKey.swift new file mode 100644 index 00000000..1ed542ff --- /dev/null +++ b/Sources/SpeziAccount/Components/Model/AccountValue/PasswordAccountValueKey.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 +// + +public struct PasswordAccountValueKey: AccountValueKey { + public typealias Value = String +} + +extension AccountValueStorageContainer { + public var password: PasswordAccountValueKey.Value { + storage[PasswordAccountValueKey.self] + } +} + +extension ModifiableAccountValueStorageContainer { + public var password: PasswordAccountValueKey.Value { + get { + storage[PasswordAccountValueKey.self] + } + set { + storage[PasswordAccountValueKey.self] = newValue + } + } +} diff --git a/Sources/SpeziAccount/Components/Model/AccountValue/UserIdAccountValueKey.swift b/Sources/SpeziAccount/Components/Model/AccountValue/UserIdAccountValueKey.swift new file mode 100644 index 00000000..3303a8ac --- /dev/null +++ b/Sources/SpeziAccount/Components/Model/AccountValue/UserIdAccountValueKey.swift @@ -0,0 +1,33 @@ +// +// 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 +// + +// TODO provide explicit UserNameSignupValue? +// TODO provide explicit E-MailSignup Value? + +public struct UserIdAccountValueKey: AccountValueKey { + public typealias Value = String +} + +extension AccountValueStorageContainer { + public var userId: UserIdAccountValueKey.Value { + get { + storage[UserIdAccountValueKey.self] + } + } +} + +extension ModifiableAccountValueStorageContainer { + public var userId: UserIdAccountValueKey.Value { + get { + storage[UserIdAccountValueKey.self] + } + set { + storage[UserIdAccountValueKey.self] = newValue + } + } +} diff --git a/Sources/SpeziAccount/Components/Model/Signup/GenderIdentity.swift b/Sources/SpeziAccount/Components/Model/Signup/GenderIdentity.swift index ab2132ca..17ed0a46 100644 --- a/Sources/SpeziAccount/Components/Model/Signup/GenderIdentity.swift +++ b/Sources/SpeziAccount/Components/Model/Signup/GenderIdentity.swift @@ -29,18 +29,22 @@ public enum GenderIdentity: Int, Sendable, CaseIterable, Identifiable, Hashable } extension GenderIdentity: CustomLocalizedStringResourceConvertible { - public var localizedStringResource: LocalizedStringResource { + private var localizationValue: String.LocalizationValue { switch self { case .female: - return LocalizedStringResource("GENDER_IDENTITY_FEMALE", bundle: .atURL(Bundle.module.bundleURL)) + return "GENDER_IDENTITY_FEMALE" case .male: - return LocalizedStringResource("GENDER_IDENTITY_MALE", bundle: .atURL(Bundle.module.bundleURL)) + return "GENDER_IDENTITY_MALE" case .transgender: - return LocalizedStringResource("GENDER_IDENTITY_TRANSGENDER", bundle: .atURL(Bundle.module.bundleURL)) + return "GENDER_IDENTITY_TRANSGENDER" case .nonBinary: - return LocalizedStringResource("GENDER_IDENTITY_NON_BINARY", bundle: .atURL(Bundle.module.bundleURL)) + return "GENDER_IDENTITY_NON_BINARY" case .preferNotToState: - return LocalizedStringResource("GENDER_IDENTITY_PREFER_NOT_TO_STATE", bundle: .atURL(Bundle.module.bundleURL)) + return "GENDER_IDENTITY_PREFER_NOT_TO_STATE" } } + + public var localizedStringResource: LocalizedStringResource { + LocalizedStringResource(localizationValue, bundle: .atURL(from: .module)) + } } diff --git a/Sources/SpeziAccount/Components/Model/SignupRequest/SignupRequest.swift b/Sources/SpeziAccount/Components/Model/SignupRequest/SignupRequest.swift new file mode 100644 index 00000000..f3b9403e --- /dev/null +++ b/Sources/SpeziAccount/Components/Model/SignupRequest/SignupRequest.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 +// + +public struct SignupRequest: Sendable, AccountValueStorageContainer { + public let storage: AccountValueStorage + + public init(storage: AccountValueStorage) { + self.storage = storage + } +} diff --git a/Sources/SpeziAccount/Components/Model/UserIdType.swift b/Sources/SpeziAccount/Components/Model/UserIdType.swift new file mode 100644 index 00000000..08bbc41a --- /dev/null +++ b/Sources/SpeziAccount/Components/Model/UserIdType.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 + +public enum UserIdType { + case emailAddress + case username + case custom(_ label: LocalizedStringResource) +} + +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/Components/ViewStyle/DefaultAccountSetupViewStyle.swift b/Sources/SpeziAccount/Components/ViewStyle/DefaultAccountSetupViewStyle.swift index 16aaf934..91c24b85 100644 --- a/Sources/SpeziAccount/Components/ViewStyle/DefaultAccountSetupViewStyle.swift +++ b/Sources/SpeziAccount/Components/ViewStyle/DefaultAccountSetupViewStyle.swift @@ -31,7 +31,7 @@ struct DefaultAccountSetupViewStyle: AccountSetupViewSt Text("Hello World") } - func makeAccountSummary() -> some View { - Text("Conditionally show Account summary, or login stuff!") + func makeAccountSummary(account: AccountValuesWhat) -> some View { + Text("Account for \(account.userId)") } } diff --git a/Sources/SpeziAccount/Components/Views/Button/AsyncDataEntrySubmitButton.swift b/Sources/SpeziAccount/Components/Views/Button/AsyncDataEntrySubmitButton.swift index b26a6266..fe718f6e 100644 --- a/Sources/SpeziAccount/Components/Views/Button/AsyncDataEntrySubmitButton.swift +++ b/Sources/SpeziAccount/Components/Views/Button/AsyncDataEntrySubmitButton.swift @@ -9,39 +9,39 @@ import SwiftUI // TODO move that to SpeziViews? public struct AsyncDataEntrySubmitButton: View { private var buttonLabel: ButtonLabel + private var role: ButtonRole? private var action: () async throws -> Void @Environment(\.defaultErrorDescription) var defaultErrorDescription @Binding private var state: ViewState public var body: some View { - Button(action: submitAction) { + Button(role: role, action: submitAction) { buttonLabel - // TODO .padding(6) - // .frame(maxWidth: .infinity) .replaceWithProcessingIndicator(ifProcessing: state) } - .buttonStyle(.borderedProminent) .disabled(state == .processing) - // TODO .padding() } public init( _ title: LocalizedStringResource, + role: ButtonRole? = nil, state: Binding, action: @escaping () async throws -> Void ) where ButtonLabel == Text { - self.init(state: state, action: action) { + self.init(role: role, state: state, action: action) { Text(title) } } public init( + role: ButtonRole? = nil, state: Binding, action: @escaping () async throws -> Void, @ViewBuilder _ label: () -> ButtonLabel ) { self.buttonLabel = label() + self.role = role self._state = state self.action = action } @@ -81,21 +81,22 @@ public struct AsyncDataEntrySubmitButton: View { struct AsyncDataEntrySubmitButton_Previews: PreviewProvider { struct PreviewView: View { var title: LocalizedStringResource + var role: ButtonRole? var action: () async throws -> Void @State var state: ViewState var body: some View { - AsyncDataEntrySubmitButton(title, state: $state) { - print("Button pressed!") + AsyncDataEntrySubmitButton(title, role: role, state: $state) { try await Task.sleep(for: .seconds(1)) try await action() } .viewStateAlert(state: $state) } - init(_ title: LocalizedStringResource = "Test Button", state: ViewState = .idle, action: @escaping () async throws -> Void = {}) { + init(_ title: LocalizedStringResource = "Test Button", role: ButtonRole? = nil, state: ViewState = .idle, action: @escaping () async throws -> Void = {}) { self.title = title + self.role = role self.action = action self._state = State(initialValue: state) @@ -111,6 +112,9 @@ struct AsyncDataEntrySubmitButton_Previews: PreviewProvider { throw CancellationError() } + PreviewView("Destructive Button", role: .destructive) + .buttonStyle(.automatic) + AsyncDataEntrySubmitButton(state: $state, action: { print("button pressed") }) { Text("Test Button!") } diff --git a/Sources/SpeziAccount/Components/Views/DefaultSuccessfulPasswordResetView.swift b/Sources/SpeziAccount/Components/Views/DefaultSuccessfulPasswordResetView.swift index 7099adfb..b3d0756c 100644 --- a/Sources/SpeziAccount/Components/Views/DefaultSuccessfulPasswordResetView.swift +++ b/Sources/SpeziAccount/Components/Views/DefaultSuccessfulPasswordResetView.swift @@ -1,15 +1,16 @@ // -// Created by Andreas Bauer on 27.06.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 Foundation import SwiftUI public struct DefaultSuccessfulPasswordResetView: View { private let successfulLabelLocalization: LocalizedStringResource - // TODO remove @Environment(\.dismiss) var dismiss - public var body: some View { Spacer() @@ -25,15 +26,6 @@ public struct DefaultSuccessfulPasswordResetView: View { } .padding(32) - /* - Button(action: { - dismiss() - }) { - Text("Continue") // TODO whatever! - } - */ - // TODO how to dismiss? - Spacer() Spacer() } diff --git a/Sources/SpeziAccount/Components/Views/DefaultUserIdPasswordAccountSummaryView.swift b/Sources/SpeziAccount/Components/Views/DefaultUserIdPasswordAccountSummaryView.swift index e91ac502..9c0ab606 100644 --- a/Sources/SpeziAccount/Components/Views/DefaultUserIdPasswordAccountSummaryView.swift +++ b/Sources/SpeziAccount/Components/Views/DefaultUserIdPasswordAccountSummaryView.swift @@ -1,8 +1,9 @@ // -// SwiftUIView.swift -// +// This source file is part of the Spezi open-source project // -// Created by Andreas Bauer on 29.06.23. +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT // import SpeziViews @@ -10,39 +11,36 @@ import SwiftUI public struct DefaultUserIdPasswordAccountSummaryView: View { private let service: Service + private let account: AccountValuesWhat + + @State private var viewState: ViewState = .idle public var body: some View { - // TODO move this to a UserInformationView! - HStack(spacing: 16) { - let name = try! PersonNameComponents("Andreas Bauer") - UserProfileView(name: name) - .frame(height: 40) - - VStack(alignment: .leading, spacing: 4) { - Text(name.formatted(.name(style: .medium))) - if let email = .some("andi.bauer@tum.de") { - Text(email) - } + VStack { + UserInformation(name: account.name, caption: account.userId) + + + AsyncDataEntrySubmitButton("UP_LOGOUT".localized(.module), role: .destructive, state: $viewState) { + try await service.logout() } - Spacer() + .environment(\.defaultErrorDescription, .init("UP_LOGOUT_FAILED_DEFAULT_ERROR", bundle: .atURL(from: .module))) + .padding() } - .padding() - .background( - RoundedRectangle(cornerRadius: 8) - .fill(.background) - .shadow(color: .gray, radius: 2) - ) - .frame(maxWidth: AccountSetup.Constants.maxFrameWidth) } - public init(using service: Service) { + public init(using service: Service, account: AccountValuesWhat) { self.service = service + self.account = account } } struct DefaultUserIdPasswordAccountSummaryView_Previews: PreviewProvider { + static let account1: AccountValuesWhat = AccountValueStorageBuilder() + .add(UserIdAccountValueKey.self, value: "andi.bauer@tum.de") + .add(NameAccountValueKey.self, value: PersonNameComponents(givenName: "Andreas", familyName: "Bauer")) + .build() + static var previews: some View { - DefaultUserIdPasswordAccountSummaryView(using: DefaultUsernamePasswordAccountService()) - .padding() + DefaultUserIdPasswordAccountSummaryView(using: DefaultUsernamePasswordAccountService(), account: account1) } } diff --git a/Sources/SpeziAccount/Components/Views/DefaultUserIdPasswordEmbeddedView.swift b/Sources/SpeziAccount/Components/Views/DefaultUserIdPasswordEmbeddedView.swift index f965856d..01d3c463 100644 --- a/Sources/SpeziAccount/Components/Views/DefaultUserIdPasswordEmbeddedView.swift +++ b/Sources/SpeziAccount/Components/Views/DefaultUserIdPasswordEmbeddedView.swift @@ -10,51 +10,43 @@ import Foundation import SpeziViews import SwiftUI -struct DefaultUserIdPasswordEmbeddedView: View { +public struct DefaultUserIdPasswordEmbeddedView: View { private let service: Service - private let localization: ConfigurableLocalization // TODO remove! - - // TODO we want a view model! + // TODO we want a view model? @State private var userId: String = "" @State private var password: String = "" - @State - private var state: ViewState = .idle - @FocusState - private var focusedField: AccountInputFields? + @State private var state: ViewState = .idle + @FocusState private var focusedField: AccountInputFields? - @StateObject private var userIdValidation = ValidationEngine(rules: [.nonEmpty]) // TODO no more rules right? - @StateObject private var passwordValidation = ValidationEngine(rules: [.nonEmpty]) // TODO pass rules + // for login we do all checks server-side. Except that don't pass empty values. + @StateObject private var userIdValidation = ValidationEngine(rules: [.nonEmpty]) + @StateObject private var passwordValidation = ValidationEngine(rules: [.nonEmpty]) - @State - private var loginTask: Task? { + @State private var loginTask: Task? { willSet { loginTask?.cancel() } } - @MainActor - var body: some View { + @MainActor public var body: some View { VStack { VStack { - // TODO localization (which is implementation dependent!) Group { - // TODO localization! - VerifiableTextField("E-Mail Address or Username", text: $userId) + VerifiableTextField(service.configuration.userIdType.localizedStringResource, text: $userId) .environmentObject(userIdValidation) .fieldConfiguration(service.configuration.userIdField) - .onTapFocus(focusedField: _focusedField, fieldIdentifier: .username) + .onTapFocus(focusedField: _focusedField, fieldIdentifier: .userId) .padding(.bottom, 0.5) // TODO .padding([.leading, .bottom], 8) for the red texts? - // TODO supply LocalizedStringResource before text! - VerifiableTextField("Password", text: $password, type: .secure) { + VerifiableTextField("UP_PASSWORD".localized(.module), text: $password, type: .secure) { NavigationLink { service.viewStyle.makePasswordResetView() } label: { - Text("Forgot Password?") // TODO localize + Text("UP_FORGOT_PASSWORD".localized(.module)) .font(.caption) .bold() .foregroundColor(Color(uiColor: .systemGray)) // TODO color primary? secondary? @@ -66,44 +58,31 @@ struct DefaultUserIdPasswordEmbeddedView: } .disableFieldAssistants() .textFieldStyle(.roundedBorder) - .font(.title3)/* - .onChange(of: userId) { newId in - if !newId.isEmpty { - idEmpty = false - } - } - .onChange(of: password) { newPassword in - if !newPassword.isEmpty { - passwordEmpty = false - } - } - */ + .font(.title3) } .padding(.vertical, 0) AsyncDataEntrySubmitButton(state: $state, action: loginButtonAction) { - Text("Login") + Text("UP_LOGIN".localized(.module)) .padding(8) .frame(maxWidth: .infinity) } + .buttonStyle(.borderedProminent) .padding(.bottom, 12) .padding(.top) - // TODO supply default error description! HStack { - Text("Dont' have an Account yet?") // TODO localize! - // TODO navigation link + Text("UP_NO_ACCOUNT_YET".localized(.module)) NavigationLink { service.viewStyle.makeSignupView() } label: { - Text("Signup") // TODO primary accent color! + Text("UP_SIGNUP".localized(.module)) } // TODO .padding(.horizontal, 0) } .font(.footnote) } - // TODO a "keep user" modifier? .disableAnyDismissiveActions(ifProcessing: state) .viewStateAlert(state: $state) .onTapGesture { @@ -116,24 +95,12 @@ struct DefaultUserIdPasswordEmbeddedView: // TODO loginTask?.cancel() // => app exit? } + // TODO inject somwhere else + .environment(\.defaultErrorDescription, .init("UP_LOGIN_FAILED_DEFAULT_ERROR", bundle: .atURL(from: .module))) } - /// Instantiate a new `DefaultIdPasswordBasedEmbeddedView` TODO docs - /// - /// - Parameters: - /// - service: TODO document account service! - /// - idValidationRules: A collection of ``ValidationRule``s to validate to the entered user key. - /// - passwordValidationRules: A collection of ``ValidationRule``s to validate to the entered password. - /// - idFieldConfiguration: TODO docs - /// - passwordFieldConfiguration: TODO docs - /// - localization: A ``ConfigurableLocalization`` to define the localization of this view. - /// The default value uses the localization provided by the ``UsernamePasswordAccountService`` provided in the SwiftUI environment. TODO docs! - public init( - using service: Service, - localization: ConfigurableLocalization = .environment - ) { + public init(using service: Service) { self.service = service - self.localization = localization } private func loginButtonAction() async throws { @@ -142,7 +109,7 @@ struct DefaultUserIdPasswordEmbeddedView: passwordValidation.runValidation(input: password) guard userIdValidation.inputValid else { - focusedField = .username + focusedField = .userId return } @@ -152,6 +119,9 @@ struct DefaultUserIdPasswordEmbeddedView: } try await service.login(userId: userId, password: password) + + // TODO we could emit a debug warning if there was a login request but the + // user isn't logged in afterwards? } } diff --git a/Sources/SpeziAccount/Components/Views/DefaultUserIdPasswordPrimaryView.swift b/Sources/SpeziAccount/Components/Views/DefaultUserIdPasswordPrimaryView.swift index d67e86a3..e8044d7e 100644 --- a/Sources/SpeziAccount/Components/Views/DefaultUserIdPasswordPrimaryView.swift +++ b/Sources/SpeziAccount/Components/Views/DefaultUserIdPasswordPrimaryView.swift @@ -6,6 +6,7 @@ // SPDX-License-Identifier: MIT // +import SpeziViews import SwiftUI struct DefaultUserIdPasswordPrimaryView: View { @@ -15,39 +16,38 @@ struct DefaultUserIdPasswordPrimaryView: GeometryReader { proxy in ScrollView(.vertical) { VStack { - header + welcomeHeader Spacer() VStack { - DefaultUserIdPasswordEmbeddedView(using: service) // TODO pass all the other things + DefaultUserIdPasswordEmbeddedView(using: service) } - .padding(.horizontal, AccountSetup.Constants.innerHorizontalPadding) - .frame(maxWidth: AccountSetup.Constants.maxFrameWidth) + .padding(.horizontal, Constants.innerHorizontalPadding) + .frame(maxWidth: Constants.maxFrameWidth) Spacer() Spacer() Spacer() } - .padding(.horizontal, AccountSetup.Constants.outerHorizontalPadding) - .frame(minHeight: proxy.size.height) - .frame(maxWidth: .infinity) + .padding(.horizontal, Constants.outerHorizontalPadding) + .frame(maxWidth: .infinity, minHeight: proxy.size.height) } } } /// The Views Title and subtitle text. @ViewBuilder - var header: some View { + var welcomeHeader: some View { // TODO provide customizable with AccountViewStyle! - Text("Welcome! 👋") // TODO localize + Text("ACCOUNT_WELCOME".localized(.module)) .font(.largeTitle) .bold() .multilineTextAlignment(.center) .padding(.bottom) .padding(.top, 30) - Text("Please create an account to do whatever. You may create an account if you don't have one already!") // TODO localize! + Text("ACCOUNT_WELCOME_SUBTITLE".localized(.module)) .multilineTextAlignment(.center) } @@ -58,6 +58,8 @@ struct DefaultUserIdPasswordPrimaryView: struct DefaultUserIdPasswordPrimaryView_Previews: PreviewProvider { static var previews: some View { - DefaultUserIdPasswordPrimaryView(using: DefaultUsernamePasswordAccountService()) + NavigationStack { + DefaultUserIdPasswordPrimaryView(using: DefaultUsernamePasswordAccountService()) + } } } diff --git a/Sources/SpeziAccount/Components/Views/DefaultUserIdPasswordResetView.swift b/Sources/SpeziAccount/Components/Views/DefaultUserIdPasswordResetView.swift index 543333f0..268c396d 100644 --- a/Sources/SpeziAccount/Components/Views/DefaultUserIdPasswordResetView.swift +++ b/Sources/SpeziAccount/Components/Views/DefaultUserIdPasswordResetView.swift @@ -32,7 +32,7 @@ public struct DefaultUserIdPasswordResetView: View { private let service: Service - private var signUpOptions: SignUpOptions { - service.configuration.signUpOptions + private var signupRequirements: AccountValueRequirements { + service.configuration.signUpRequirements } @State private var userId = "" @@ -31,20 +31,21 @@ struct DefaultUserIdPasswordSignUpView: V .viewStateAlert(state: $state) } - @ViewBuilder - var form: some View { + @ViewBuilder var form: some View { Form { - Text("Thanks for creating a new account and stuff!") // TODO localize! + // TODO form instructions should be customizable! + Text("UP_SIGNUP_INSTRUCTIONS".localized(.module)) - if signUpOptions.contains(.usernameAndPassword) { // TODO isn't that required? - Section("Credentials") { - VerifiableTextField("E-Mail or Username", text: $userId) + // TODO both are required? + if signupRequirements.configured(UserIdAccountValueKey.self) && signupRequirements.configured(PasswordAccountValueKey.self) { + Section("UP_CREDENTIALS".localized(.module).localizedString()) { + VerifiableTextField(service.configuration.userIdType.localizedStringResource, text: $userId) .environmentObject(userIdValidation) .fieldConfiguration(service.configuration.userIdField) - .onTapFocus(focusedField: _focusedField, fieldIdentifier: .username) + .onTapFocus(focusedField: _focusedField, fieldIdentifier: .userId) // TODO single password, but with visibility toggle: https://stackoverflow.com/questions/63095851/show-hide-password-how-can-i-add-this-feature - VerifiableTextField("Password", text: $password, type: .secure) + VerifiableTextField("UP_PASSWORD".localized(.module), text: $password, type: .secure) .environmentObject(passwordValidation) .fieldConfiguration(.newPassword) .onTapFocus(focusedField: _focusedField, fieldIdentifier: .password) @@ -52,36 +53,41 @@ struct DefaultUserIdPasswordSignUpView: V .disableFieldAssistants() } - if signUpOptions.contains(.name) { - Section("Name") { + // TODO we could also think about a solution where the SignupValue places the + // UI element => makes the whole thing more reuseable! + if signupRequirements.configured(NameAccountValueKey.self) { + Section("UP_NAME".localized(.module).localizedString()) { // TODO Name Text Fields with empty validation! NameTextFields(name: $name, focusState: _focusedField) } } - if !signUpOptions.isDisjoint(with: [.dateOfBirth, .genderIdentity]) { - Section("Personal Details") { - if signUpOptions.contains(.dateOfBirth) { + // TODO this not nice? + if signupRequirements.configured(DateOfBirthAccountValueKey.self) + || signupRequirements.configured(GenderIdentityAccountValueKey.self) { + Section("UP_PERSONAL_DETAILS".localized(.module).localizedString()) { + if signupRequirements.configured(DateOfBirthAccountValueKey.self) { // TODO validate that user inputted data DateOfBirthPicker(date: $dateOfBirth) } - if signUpOptions.contains(.genderIdentity) { + if signupRequirements.configured(GenderIdentityAccountValueKey.self) { GenderIdentityPicker(genderIdentity: $genderIdentity) } } } AsyncDataEntrySubmitButton(state: $state, action: signupButtonAction) { - Text("Signup") + Text("UP_SIGNUP".localized(.module)) .padding(16) .frame(maxWidth: .infinity) } + .buttonStyle(.borderedProminent) .padding() .padding(-36) .listRowBackground(Color.clear) - // TODO default error localization! } + .environment(\.defaultErrorDescription, .init("UP_SIGNUP_FAILED_DEFAULT_ERROR", bundle: .atURL(from: .module))) } init(using service: Service) { @@ -95,22 +101,27 @@ struct DefaultUserIdPasswordSignUpView: V passwordValidation.runValidation(input: password) guard userIdValidation.inputValid else { - focusedField = .username + focusedField = .userId return } guard passwordValidation.inputValid else { - focusedField = .password // TODO does this erase the password? + focusedField = .password return } - try await service.signUp(signUpValues: SignUpValues( - userId: userId, - password: password, - name: name, - genderIdentity: genderIdentity, - dateOfBirth: dateOfBirth - )) + let requestBuilder = AccountValueStorageBuilder() + .add(UserIdAccountValueKey.self, value: userId) + .add(PasswordAccountValueKey.self, value: password) + .add(NameAccountValueKey.self, value: name) + .add(GenderIdentityAccountValueKey.self, value: genderIdentity, ifConfigured: signupRequirements) + .add(DateOfBirthAccountValueKey.self, value: dateOfBirth, ifConfigured: signupRequirements) + + let request: SignupRequest = requestBuilder.build(checking: signupRequirements) + + // TODO we might want to have keys that have optional value but are still displayed! + try await service.signUp(signupRequest: request) + // TODO do we impose any requirements, that there should a logged in used after this? } } diff --git a/Sources/SpeziAccount/Components/Views/Fields/NameTextFields.swift b/Sources/SpeziAccount/Components/Views/Fields/NameTextFields.swift index 019bbc3e..8b117f59 100644 --- a/Sources/SpeziAccount/Components/Views/Fields/NameTextFields.swift +++ b/Sources/SpeziAccount/Components/Views/Fields/NameTextFields.swift @@ -30,6 +30,7 @@ struct NameTextFields: View { var body: some View { + // TODO are there name fields without a grid? SpeziViews.NameFields( name: $name, givenNameField: givenNameLocalization, diff --git a/Sources/SpeziAccount/Components/Views/Picker/GenderIdentityPicker.swift b/Sources/SpeziAccount/Components/Views/Picker/GenderIdentityPicker.swift index 3f1234fe..3608222d 100644 --- a/Sources/SpeziAccount/Components/Views/Picker/GenderIdentityPicker.swift +++ b/Sources/SpeziAccount/Components/Views/Picker/GenderIdentityPicker.swift @@ -31,7 +31,7 @@ struct GenderIdentityPicker: View { init( genderIdentity: Binding, - title: LocalizedStringResource = LocalizedStringResource("UAP_SIGNUP_GENDER_IDENTITY_TITLE", bundle: .atURL(from: .module)) + title: LocalizedStringResource = LocalizedStringResource("GENDER_IDENTITY_TITLE", bundle: .atURL(from: .module)) ) { self._genderIdentity = genderIdentity self.titleLocalization = title diff --git a/Sources/SpeziAccount/Components/Views/User/UserInformation.swift b/Sources/SpeziAccount/Components/Views/User/UserInformation.swift new file mode 100644 index 00000000..04ead7c5 --- /dev/null +++ b/Sources/SpeziAccount/Components/Views/User/UserInformation.swift @@ -0,0 +1,58 @@ +// +// Created by Andreas Bauer on 02.07.23. +// + +import SpeziViews +import SwiftUI + +struct UserInformation: View { + private let nameComponents: PersonNameComponents + private let caption: Caption + + public init(name nameComponents: PersonNameComponents, caption: String) where Caption == Text { + self.init(name: nameComponents) { + Text(verbatim: caption) // TODO use case to also pass LocalizedStringResource? + } + } + + public init(name nameComponents: PersonNameComponents, @ViewBuilder caption: () -> Caption = { EmptyView() }) { + self.nameComponents = nameComponents + self.caption = caption() + } + + var body: some View { + HStack(spacing: 16) { + UserProfileView(name: nameComponents) + .frame(height: 40) + + VStack(alignment: .leading, spacing: 4) { + Text(nameComponents.formatted(.name(style: .medium))) + caption + } + Spacer() + } + .padding() + .background( + RoundedRectangle(cornerRadius: 8) + .fill(.background) + .shadow(color: .gray, radius: 2) + ) + .frame(maxWidth: Constants.maxFrameWidth) + } +} + +struct UserInformation_Previews: PreviewProvider { + static var previews: some View { + VStack { + UserInformation(name: try! PersonNameComponents("Andreas Bauer"), caption: "andi.bauer@tum.de") + .padding(.vertical) + + UserInformation(name: try! PersonNameComponents("Paul Schmiedmayer")) { + Text("Postdoc (Stanford Byers Center for Biodesign)") + .foregroundColor(.secondary) + .font(.caption) + } + } + .padding() + } +} diff --git a/Sources/SpeziAccount/Old/Username and Password/Localization/Localization.swift b/Sources/SpeziAccount/Old/Username and Password/Localization/Localization.swift index 8a47806d..57e76ce4 100644 --- a/Sources/SpeziAccount/Old/Username and Password/Localization/Localization.swift +++ b/Sources/SpeziAccount/Old/Username and Password/Localization/Localization.swift @@ -7,9 +7,6 @@ // -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. diff --git a/Sources/SpeziAccount/Old/Username and Password/ResetPassword/UsernamePasswordResetPasswordView.swift b/Sources/SpeziAccount/Old/Username and Password/ResetPassword/UsernamePasswordResetPasswordView.swift index 4eeaa050..00fd8737 100644 --- a/Sources/SpeziAccount/Old/Username and Password/ResetPassword/UsernamePasswordResetPasswordView.swift +++ b/Sources/SpeziAccount/Old/Username and Password/ResetPassword/UsernamePasswordResetPasswordView.swift @@ -111,7 +111,7 @@ public struct UsernamePasswordResetPasswordView: View { .textContentType(.username) } ) - .onTapFocus(focusedField: _focusedField, fieldIdentifier: .username) + .onTapFocus(focusedField: _focusedField, fieldIdentifier: .userId) } .padding(.leading, 16) .padding(.vertical, 12) diff --git a/Sources/SpeziAccount/Old/Username and Password/Shared/AccountInputFields.swift b/Sources/SpeziAccount/Old/Username and Password/Shared/AccountInputFields.swift index 4aafda44..db542480 100644 --- a/Sources/SpeziAccount/Old/Username and Password/Shared/AccountInputFields.swift +++ b/Sources/SpeziAccount/Old/Username and Password/Shared/AccountInputFields.swift @@ -7,10 +7,10 @@ // -enum AccountInputFields: Hashable { - case username // TODO rename to key? +enum AccountInputFields: Hashable { // TODO find a extendable alternative! + case userId case password - case passwordRepeat + case passwordRepeat // TODO remove! case givenName case familyName case genderIdentity diff --git a/Sources/SpeziAccount/Old/Username and Password/Shared/UsernamePasswordFields.swift b/Sources/SpeziAccount/Old/Username and Password/Shared/UsernamePasswordFields.swift index be1cd53d..d318fe96 100644 --- a/Sources/SpeziAccount/Old/Username and Password/Shared/UsernamePasswordFields.swift +++ b/Sources/SpeziAccount/Old/Username and Password/Shared/UsernamePasswordFields.swift @@ -157,7 +157,7 @@ struct UsernamePasswordFields: View { .textContentType(.username) } ) - .onTapFocus(focusedField: _focusedField, fieldIdentifier: .username) + .onTapFocus(focusedField: _focusedField, fieldIdentifier: .userId) } private var passwordSecureField: some View { diff --git a/Sources/SpeziAccount/Resources/de.lproj/Localizable.strings b/Sources/SpeziAccount/Resources/de.lproj/Localizable.strings index 7f5dc39f..c85eb628 100644 --- a/Sources/SpeziAccount/Resources/de.lproj/Localizable.strings +++ b/Sources/SpeziAccount/Resources/de.lproj/Localizable.strings @@ -22,7 +22,7 @@ "UAP_LOGIN_PASSWORD_TITLE" = "Passwort"; "UAP_LOGIN_PASSWORD_PLACEHOLDER" = "Passwort eingeben ..."; "UAP_LOGIN_ACTION_BUTTON_TITLE" = "Anmelden"; -"UAP_LOGIN_FAILED_DEFAULT_ERROR" = "Anmeldung Fehlgeschlagen"; +"UAP_LOGIN_FAILED_DEFAULT_ERROR" = "Anmeldung fehlgeschlagen"; // SignUp @@ -40,10 +40,9 @@ Wiederholen"; "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"; +"UAP_SIGNUP_FAILED_DEFAULT_ERROR" = "Benutzerkontoerstellung fehlgeschlagen"; // Reset @@ -53,7 +52,7 @@ Wiederholen"; "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"; +"UAP_RESET_PASSWORD_FAILED_DEFAULT_ERROR" = "Passwort Zurücksetzen fehlgeschlagen"; // MARK: - Email and Password @@ -64,6 +63,7 @@ Wiederholen"; // 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 da25a793..2f9bd9ce 100644 --- a/Sources/SpeziAccount/Resources/en.lproj/Localizable.strings +++ b/Sources/SpeziAccount/Resources/en.lproj/Localizable.strings @@ -22,7 +22,7 @@ "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"; +"UAP_LOGIN_FAILED_DEFAULT_ERROR" = "Could not login!"; // SignUp "UAP_SIGNUP_BUTTON_TITLE" = "Username and Password"; @@ -31,18 +31,16 @@ "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_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"; +"UAP_SIGNUP_FAILED_DEFAULT_ERROR" = "Could not sign up!"; // Reset @@ -52,7 +50,7 @@ Password"; "UAP_RESET_PASSWORD_USERNAME_PLACEHOLDER" = "Enter your username ..."; "UAP_RESET_PASSWORD_ACTION_BUTTON_TITLE" = "Reset Password"; "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: - Email and Password @@ -62,8 +60,42 @@ Password"; "EAP_LOGIN_USERNAME_PLACEHOLDER" = "Enter your email ..."; "EAP_EMAIL_VERIFICATION_ERROR" = "The entered email is not correct."; +// 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."; + +// MARK: - UserId and Password +"UP_PASSWORD" = "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_PERSONAL_DETAILS" = "Personal Details"; +"UP_SIGNUP_FAILED_DEFAULT_ERROR" = "Could not sign up!"; + +// MARK: - UserId and Password (Password Reset) + +// MARK: - UserId and Password (Account Summary) +"UP_LOGOUT_FAILED_DEFAULT_ERROR" = "Could not logout!"; + +// MARK: - Validation Rules + +// MARK: - UserIdType +"USER_ID_EMAIL" = "E-Mail Address"; +"USER_ID_USERNAME" = "Username"; // MARK: - Gender Identity +"GENDER_IDENTITY_TITLE" = "Gender Identity"; "GENDER_IDENTITY_FEMALE" = "Female"; "GENDER_IDENTITY_MALE" = "Male"; "GENDER_IDENTITY_TRANSGENDER" = "Transgender";