From 1eca29373a5c27e7dfe7871be77be306ca626989 Mon Sep 17 00:00:00 2001 From: Nikolai Madlener <33159293+NikolaiMadlener@users.noreply.github.com> Date: Mon, 16 Oct 2023 14:37:14 +0200 Subject: [PATCH] Add Customizable Area to AccountOverview (#27) # Add Customizable Area to AccountOverview ## :recycle: Current situation & Problem Like described in issue #25, some applications like the Spezi Template application could display some additional information such as licensing information or other settings at the bottom of the Account Overview. Furthermore, some developers might want to do it the other way around and link to account information within a settings page. ## :bulb: Proposed Solution Add footer components to AccountOverview, as suggested by @PSchmiedmayer Implement an additional "AccountHeader" that looks something like the one in the Apple Settings app, as suggested by @Supereg ## :gear: Release Notes This PR adds two major things. 1. A customizable content section ViewBuilder to the `AccountOverview`. Simply pass any view as a trailing closure of AccountOverview and it will be rendered as a Section right between the other AccountOverview information and the log out button. Here is an example: ``` AccountOverview() { NavigationLink { // ... } label: { Text("General Settings") } NavigationLink { // ... } label: { Text("License Information") } } ``` Screenshot 2023-09-27 at 17 30 04 2. A `AccountHeader`, similar to the one of the iOS Settings App, that could be used as a button that brings a user to the `AccountOverview`. Here is an example: ``` Section { NavigationLink { AccountOverview() } label: { AccountHeader(details: details) } } ``` Screenshot 2023-09-27 at 17 29 33 ## :books: Documentation *in progress* ## :white_check_mark: Testing UI tests for the customizable content section have been written. ## :pencil: Code of Conduct & Contributing Guidelines By submitting creating this pull request, you agree to follow our [Code of Conduct](https://github.com/StanfordSpezi/.github/blob/main/CODE_OF_CONDUCT.md) and [Contributing Guidelines](https://github.com/StanfordSpezi/.github/blob/main/CONTRIBUTING.md): - [x] I agree to follow the [Code of Conduct](https://github.com/StanfordSpezi/.github/blob/main/CODE_OF_CONDUCT.md) and [Contributing Guidelines](https://github.com/StanfordSpezi/.github/blob/main/CONTRIBUTING.md). --------- Co-authored-by: Paul Schmiedmayer --- .github/workflows/build-and-test.yml | 6 - CONTRIBUTORS.md | 1 + Sources/SpeziAccount/AccountHeader.swift | 106 ++++++++++++++++ Sources/SpeziAccount/AccountOverview.swift | 61 ++++++++-- .../Resources/de.lproj/Localizable.strings | 3 + .../Resources/en.lproj/Localizable.strings | 3 + .../AccountOverviewSections.swift | 113 ++++++++++-------- .../AccountOverview/PasswordChangeSheet.swift | 1 - .../Views/AccountSummaryBox.swift | 1 + .../AccountTests/AccountTestsView.swift | 61 +++++----- .../TestAppUITests/AccountOverviewTests.swift | 11 ++ 11 files changed, 274 insertions(+), 93 deletions(-) create mode 100644 Sources/SpeziAccount/AccountHeader.swift diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 7abcf33f..365e9889 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -23,12 +23,6 @@ jobs: artifactname: SpeziAccount.xcresult runsonlabels: '["macOS", "self-hosted"]' scheme: SpeziAccount - build: - name: Build Swift Package on Xcode 14 - uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 - with: - runsonlabels: '["macos-13"]' - scheme: SpeziAccount buildandtestuitests: name: Build and Test UI Tests uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 90beb818..4e5026c1 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -13,3 +13,4 @@ Spezi Account Contributors * [Paul Schmiedmayer](https://github.com/PSchmiedmayer) * [Andreas Bauer](https://github.com/Supereg) +* [Nikolai Madlener](https://github.com/NikolaiMadlener) diff --git a/Sources/SpeziAccount/AccountHeader.swift b/Sources/SpeziAccount/AccountHeader.swift new file mode 100644 index 00000000..b5d20b28 --- /dev/null +++ b/Sources/SpeziAccount/AccountHeader.swift @@ -0,0 +1,106 @@ +// +// 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 + + +/// A summary view for ``SpeziAccountOverview`` that can be used as a Button to link to ``SpeziAccountOverview``. +/// +/// Below is a short code example on how to use the `AccountHeader` view. +/// +/// ```swift +/// struct MyView: View { +/// var body: some View { +/// NavigationStack { +/// Form { +/// Section { +/// NavigationLink { +/// AccountOverview() +/// } label: { +/// AccountHeader(details: details) +/// } +/// } +/// } +/// } +/// } +/// } +/// ``` +public struct AccountHeader: View { + /// Default values for the ``AccountHeader`` view. + @_documentation(visibility: internal) + public enum Defaults { + /// Default caption. + @_documentation(visibility: internal) + public static let caption = LocalizedStringResource("ACCOUNT_HEADER_CAPTION", bundle: .atURL(from: .module)) // swiftlint:disable:this attributes + } + + @EnvironmentObject private var account: Account + private var caption: LocalizedStringResource + + public var body: some View { + let accountDetails = account.details + + HStack { + UserProfileView(name: accountDetails?.name ?? PersonNameComponents(givenName: "Placeholder", familyName: "Placeholder")) + .frame(height: 60) + .redacted(reason: account.details == nil ? .placeholder : []) + .accessibilityHidden(true) + VStack(alignment: .leading) { + Text(accountDetails?.name?.formatted() ?? "Placeholder") + .font(.title2) + .fontWeight(.semibold) + .redacted(reason: account.details == nil ? .placeholder : []) + Text(caption) + .font(.caption) + } + } + } + + /// Display a new Account Header. + /// - Parameter caption: A descriptive text displayed under the account name giving the user a brief explanation of what to expect when they interact with the header. + public init(caption: LocalizedStringResource = Defaults.caption) { + self.caption = caption + } +} + + +#if DEBUG +#Preview { + let details = AccountDetails.Builder() + .set(\.userId, value: "andi.bauer@tum.de") + .set(\.name, value: PersonNameComponents(givenName: "Andreas", familyName: "Bauer")) + + return AccountHeader() + .environmentObject(Account(building: details, active: MockUserIdPasswordAccountService())) +} + +#Preview { + AccountHeader(caption: "Email, Password, Preferences") + .environmentObject(Account(MockUserIdPasswordAccountService())) +} + +#Preview { + let details = AccountDetails.Builder() + .set(\.userId, value: "andi.bauer@tum.de") + .set(\.name, value: PersonNameComponents(givenName: "Andreas", familyName: "Bauer")) + + return NavigationStack { + Form { + Section { + NavigationLink { + AccountOverview() + } label: { + AccountHeader() + } + } + } + } + .environmentObject(Account(building: details, active: MockUserIdPasswordAccountService())) +} +#endif diff --git a/Sources/SpeziAccount/AccountOverview.swift b/Sources/SpeziAccount/AccountOverview.swift index a4924c26..a71749b2 100644 --- a/Sources/SpeziAccount/AccountOverview.swift +++ b/Sources/SpeziAccount/AccountOverview.swift @@ -33,16 +33,35 @@ import SwiftUI /// AccountOverview() /// } /// } +/// ``` +/// +/// Optionally, additional sections can be passed to AccountOverview within the trailing closure, providing the opportunity for customization and extension of the view." +/// Below is a short code example. +/// +/// ```swift +/// struct MyView: View { +/// var body: some View { +/// AccountOverview { +/// NavigationLink { +/// // ... next view +/// } label: { +/// Text("General Settings") +/// } +/// } +/// } +/// } +/// ``` /// /// - 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 { +public struct AccountOverview: View { @EnvironmentObject private var account: Account - + @Binding private var isEditing: Bool - + + let additionalSections: AdditionalSections + public var body: some View { VStack { if let details = account.details { @@ -53,7 +72,9 @@ public struct AccountOverview: View { account: account, details: details, isEditing: $isEditing - ) + ) { + additionalSections + } } .padding(.top, -20) } else { @@ -68,12 +89,15 @@ public struct AccountOverview: View { .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)) { + /// - Parameters: + /// - isEditing: A Binding that allows you to read the current editing state of the Account Overview view. + /// - additionalSections: Optional additional sections displayed between the other AccountOverview information and the log out button. + public init(isEditing: Binding = .constant(false), @ViewBuilder additionalSections: () -> AdditionalSections = { EmptyView() }) { self._isEditing = isEditing + self.additionalSections = additionalSections() } } @@ -84,13 +108,26 @@ struct AccountOverView_Previews: PreviewProvider { .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() + AccountOverview { + NavigationLink { + Text("") + .navigationTitle(Text("Settings")) + } label: { + Text("General Settings") + } + NavigationLink { + Text("") + .navigationTitle(Text("Package Dependencies")) + } label: { + Text("License Information") + } + } } .environmentObject(Account(building: details, active: MockUserIdPasswordAccountService())) - + NavigationStack { AccountOverview() } diff --git a/Sources/SpeziAccount/Resources/de.lproj/Localizable.strings b/Sources/SpeziAccount/Resources/de.lproj/Localizable.strings index 6c5547ca..a107688a 100644 --- a/Sources/SpeziAccount/Resources/de.lproj/Localizable.strings +++ b/Sources/SpeziAccount/Resources/de.lproj/Localizable.strings @@ -70,6 +70,9 @@ "VALUE_ADD %@" = "%@ Hinzufügen"; "CHANGE_PASSWORD" = "Passwort Ändern"; +// MARK - Account Header +"ACCOUNT_HEADER_CAPTION" = "Kontoinformationen & Details"; + // MARK - Confirmation Dialogs "CONFIRMATION_DISCARD_CHANGES_TITLE" = "Willst du alle Änderungen verwerfen?"; "CONFIRMATION_DISCARD_INPUT_TITLE" = "Willst du deine Eingaben verwerfen?"; diff --git a/Sources/SpeziAccount/Resources/en.lproj/Localizable.strings b/Sources/SpeziAccount/Resources/en.lproj/Localizable.strings index c23c7955..161d19f8 100644 --- a/Sources/SpeziAccount/Resources/en.lproj/Localizable.strings +++ b/Sources/SpeziAccount/Resources/en.lproj/Localizable.strings @@ -70,6 +70,9 @@ "VALUE_ADD %@" = "Add %@"; "CHANGE_PASSWORD" = "Change Password"; +// MARK - Account Header +"ACCOUNT_HEADER_CAPTION" = "Account Information & Details"; + // MARK - Confirmation Dialogs "CONFIRMATION_DISCARD_CHANGES_TITLE" = "Are you sure you want to discard your changes?"; "CONFIRMATION_DISCARD_INPUT_TITLE" = "Are you sure you want to discard your input?"; diff --git a/Sources/SpeziAccount/Views/AccountOverview/AccountOverviewSections.swift b/Sources/SpeziAccount/Views/AccountOverview/AccountOverviewSections.swift index be710f4e..e6268893 100644 --- a/Sources/SpeziAccount/Views/AccountOverview/AccountOverviewSections.swift +++ b/Sources/SpeziAccount/Views/AccountOverview/AccountOverviewSections.swift @@ -12,33 +12,34 @@ import SwiftUI /// A internal subview of ``AccountOverview`` that expects to be embedded into a `Form`. -struct AccountOverviewSections: View { +struct AccountOverviewSections: View { + let additionalSections: AdditionalSections 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 @@ -95,8 +96,8 @@ struct AccountOverviewSections: View { }) { Text("UP_LOGOUT", bundle: .module) } - .environment(\.defaultErrorDescription, .init("UP_LOGOUT_FAILED_DEFAULT_ERROR", bundle: .atURL(from: .module))) - + .environment(\.defaultErrorDescription, .init("UP_LOGOUT_FAILED_DEFAULT_ERROR", bundle: .atURL(from: .module))) + Button(role: .cancel, action: {}) { Text("CANCEL", bundle: .module) } @@ -110,7 +111,7 @@ struct AccountOverviewSections: View { Text("DELETE", bundle: .module) } .environment(\.defaultErrorDescription, .init("REMOVE_DEFAULT_ERROR", bundle: .atURL(from: .module))) - + Button(role: .cancel, action: {}) { Text("CANCEL", bundle: .module) } @@ -121,7 +122,7 @@ struct AccountOverviewSections: View { // sync the edit mode with the outer view isEditing = newValue } - + Section { NavigationLink { NameOverview(model: model, details: accountDetails) @@ -134,31 +135,35 @@ struct AccountOverviewSections: View { 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) + + additionalSections + + Section { + 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) } - .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) { @@ -167,7 +172,7 @@ struct AccountOverviewSections: View { let forEachWrappers = accountKeys.map { key in ForEachAccountKeyWrapper(key) } - + ForEach(forEachWrappers, id: \.id) { wrapper in AccountKeyEditRow(details: accountDetails, for: wrapper.accountKey, model: model) } @@ -182,43 +187,44 @@ struct AccountOverviewSections: View { } } } - - + init( account: Account, details accountDetails: AccountDetails, - isEditing: Binding + isEditing: Binding, + @ViewBuilder additionalSections: (() -> AdditionalSections) = { EmptyView() } ) { self.accountDetails = accountDetails self._model = StateObject(wrappedValue: AccountOverviewFormViewModel(account: account)) self._isEditing = isEditing + self.additionalSections = additionalSections() } - - + + 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 { @@ -226,7 +232,7 @@ struct AccountOverviewSections: View { // 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) @@ -241,10 +247,23 @@ struct AccountOverviewSections_Previews: PreviewProvider { .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() + AccountOverview { + Section(header: Text("App")) { + NavigationLink { + Text("") + } label: { + Text("General Settings") + } + NavigationLink { + Text("") + } label: { + Text("License Information") + } + } + } } .environmentObject(Account(building: details, active: MockUserIdPasswordAccountService())) } diff --git a/Sources/SpeziAccount/Views/AccountOverview/PasswordChangeSheet.swift b/Sources/SpeziAccount/Views/AccountOverview/PasswordChangeSheet.swift index 8d9ab366..7ea903f6 100644 --- a/Sources/SpeziAccount/Views/AccountOverview/PasswordChangeSheet.swift +++ b/Sources/SpeziAccount/Views/AccountOverview/PasswordChangeSheet.swift @@ -84,7 +84,6 @@ struct PasswordChangeSheet: View { Divider() .gridCellUnsizedAxes(.horizontal) - PasswordKey.DataEntry($repeatPassword) .environment(\.passwordFieldType, .repeat) .focused($focusedDataEntry, equals: "$-newPassword") diff --git a/Sources/SpeziAccount/Views/AccountSummaryBox.swift b/Sources/SpeziAccount/Views/AccountSummaryBox.swift index 464454f2..fd9920cd 100644 --- a/Sources/SpeziAccount/Views/AccountSummaryBox.swift +++ b/Sources/SpeziAccount/Views/AccountSummaryBox.swift @@ -58,6 +58,7 @@ public struct AccountSummaryBox: View { } } + #if DEBUG struct AccountSummary_Previews: PreviewProvider { static let emailDetails = AccountDetails.Builder() diff --git a/Tests/UITests/TestApp/AccountTests/AccountTestsView.swift b/Tests/UITests/TestApp/AccountTests/AccountTestsView.swift index 2d05cbaf..7ea8b1fd 100644 --- a/Tests/UITests/TestApp/AccountTests/AccountTestsView.swift +++ b/Tests/UITests/TestApp/AccountTests/AccountTestsView.swift @@ -14,14 +14,14 @@ import SwiftUI struct AccountTestsView: View { @Environment(\.features) var features - + @EnvironmentObject var account: Account @EnvironmentObject var standard: TestStandard - + @State var showSetup = false @State var showOverview = false @State var isEditing = false - + var body: some View { NavigationStack { @@ -34,33 +34,40 @@ struct AccountTestsView: View { showOverview = true } } - .navigationTitle("Spezi Account") - .sheet(isPresented: $showSetup) { - NavigationStack { - AccountSetup { - finishButton - } - .toolbar { - toolbar(closing: $showSetup) - } + .navigationTitle("Spezi Account") + .sheet(isPresented: $showSetup) { + NavigationStack { + AccountSetup { + finishButton } - } - .sheet(isPresented: $showOverview) { - NavigationStack { - AccountOverview(isEditing: $isEditing) - .toolbar { - toolbar(closing: $showOverview) - } + .toolbar { + toolbar(closing: $showSetup) } } - .onChange(of: account.signedIn) { newValue in - if newValue { - showSetup = false + } + .sheet(isPresented: $showOverview) { + NavigationStack { + AccountOverview(isEditing: $isEditing) { + NavigationLink { + Text("") + .navigationTitle(Text("Package Dependencies")) + } label: { + Text("License Information") + } } } + .toolbar { + toolbar(closing: $showOverview) + } + } + } + .onChange(of: account.signedIn) { newValue in + if newValue { + showSetup = false + } } } - + @ViewBuilder var header: some View { if let details = account.details { Section("Account Details") { @@ -82,7 +89,7 @@ struct AccountTestsView: View { Text("Finish") .frame(maxWidth: .infinity, minHeight: 38) }) - .buttonStyle(.borderedProminent) + .buttonStyle(.borderedProminent) } @@ -105,14 +112,14 @@ struct AccountTestsView_Previews: PreviewProvider { .set(\.userId, value: "andi.bauer@tum.de") .set(\.name, value: PersonNameComponents(givenName: "Andreas", familyName: "Bauer")) .set(\.genderIdentity, value: .male) - + static var previews: some View { AccountTestsView() .environmentObject(Account(TestAccountService(.emailAddress))) - + AccountTestsView() .environmentObject(Account(building: details, active: TestAccountService(.emailAddress))) - + AccountTestsView() .environmentObject(Account()) } diff --git a/Tests/UITests/TestAppUITests/AccountOverviewTests.swift b/Tests/UITests/TestAppUITests/AccountOverviewTests.swift index 7be27dde..d29f131c 100644 --- a/Tests/UITests/TestAppUITests/AccountOverviewTests.swift +++ b/Tests/UITests/TestAppUITests/AccountOverviewTests.swift @@ -30,6 +30,8 @@ final class AccountOverviewTests: XCTestCase { overview.verifyExistence(text: "Gender Identity, Male") overview.verifyExistence(text: "Date of Birth, Mar 9, 1824") + + overview.verifyExistence(text: "License Information") XCTAssertTrue(overview.buttons["Logout"].waitForExistence(timeout: 0.5)) } @@ -244,4 +246,13 @@ final class AccountOverviewTests: XCTestCase { XCTAssertFalse(overview.secureTextFields["enter password"].waitForExistence(timeout: 2.0)) } + + func testLicenseOverview() throws { + let app = TestApp.launch(defaultCredentials: true) + let overview = app.openAccountOverview() + + overview.tap(button: "License Information") + sleep(2) + XCTAssertTrue(overview.navigationBars.staticTexts["Package Dependencies"].waitForExistence(timeout: 6.0)) + } }