Create change password screen

This commit is contained in:
ismailgulek 2022-06-09 20:25:36 +03:00
parent 54b7333665
commit 1778608d95
No known key found for this signature in database
GPG key ID: E96336D42D9470A9
9 changed files with 685 additions and 0 deletions

View file

@ -51,6 +51,7 @@ enum MockAppScreens {
MockSpaceCreationSettingsScreenState.self,
MockSpaceCreationPostProcessScreenState.self,
MockTimelinePollScreenState.self,
MockChangePasswordScreenState.self,
MockTemplateSimpleScreenScreenState.self,
MockTemplateUserProfileScreenState.self,
MockTemplateRoomListScreenState.self,

View file

@ -0,0 +1,65 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
// MARK: View model
enum ChangePasswordViewModelResult {
/// Submit with old and new passwords and sign out of all devices option
case submit(String, String, Bool)
}
// MARK: View
struct ChangePasswordViewState: BindableState {
/// View state that can be bound to from SwiftUI.
var bindings: ChangePasswordBindings
/// Whether the user can submit the form: old password should be entered, and new passwords should match
var canSubmit: Bool {
!bindings.oldPassword.isEmpty
&& !bindings.newPassword1.isEmpty
&& bindings.newPassword1 == bindings.newPassword2
}
}
struct ChangePasswordBindings {
/// The password input by the user.
var oldPassword: String
/// The new password input by the user.
var newPassword1: String
/// The new password confirmation input by the user.
var newPassword2: String
/// The signout all devices checkbox status
var signoutAllDevices: Bool
/// Information describing the currently displayed alert.
var alertInfo: AlertInfo<ChangePasswordErrorType>?
}
enum ChangePasswordViewAction {
/// Send an email to the entered address.
case submit
/// Toggle sign out of all devices
case toggleSignoutAllDevices
}
enum ChangePasswordErrorType: Hashable {
/// An error response from the homeserver.
case mxError(String)
/// An unknown error occurred.
case unknown
}

View file

@ -0,0 +1,69 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
typealias ChangePasswordViewModelType = StateStoreViewModel<ChangePasswordViewState,
Never,
ChangePasswordViewAction>
class ChangePasswordViewModel: ChangePasswordViewModelType, ChangePasswordViewModelProtocol {
// MARK: - Properties
// MARK: Private
// MARK: Public
var callback: (@MainActor (ChangePasswordViewModelResult) -> Void)?
// MARK: - Setup
init(oldPassword: String = "",
newPassword1: String = "",
newPassword2: String = "",
signoutAllDevices: Bool = true) {
let bindings = ChangePasswordBindings(oldPassword: oldPassword,
newPassword1: newPassword1,
newPassword2: newPassword2,
signoutAllDevices: signoutAllDevices)
let viewState = ChangePasswordViewState(bindings: bindings)
super.init(initialViewState: viewState)
}
// MARK: - Public
override func process(viewAction: ChangePasswordViewAction) {
switch viewAction {
case .submit:
Task { await callback?(.submit(state.bindings.oldPassword,
state.bindings.newPassword1,
state.bindings.signoutAllDevices)) }
case .toggleSignoutAllDevices:
state.bindings.signoutAllDevices.toggle()
}
}
@MainActor func displayError(_ type: ChangePasswordErrorType) {
switch type {
case .mxError(let message):
state.bindings.alertInfo = AlertInfo(id: type,
title: VectorL10n.error,
message: message)
case .unknown:
state.bindings.alertInfo = AlertInfo(id: type)
}
}
}

View file

@ -0,0 +1,26 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
protocol ChangePasswordViewModelProtocol {
var callback: (@MainActor (ChangePasswordViewModelResult) -> Void)? { get set }
var context: ChangePasswordViewModelType.Context { get }
/// Display an error to the user.
@MainActor func displayError(_ type: ChangePasswordErrorType)
}

View file

@ -0,0 +1,134 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
import CommonKit
struct ChangePasswordCoordinatorParameters {
let restClient: MXRestClient
}
final class ChangePasswordCoordinator: Coordinator, Presentable {
// MARK: - Properties
// MARK: Private
private let parameters: ChangePasswordCoordinatorParameters
private let changePasswordHostingController: VectorHostingController
private var changePasswordViewModel: ChangePasswordViewModelProtocol
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
private var loadingIndicator: UserIndicator?
private var currentTask: Task<Void, Error>? {
willSet {
currentTask?.cancel()
}
}
// MARK: Public
// Must be used only internally
var childCoordinators: [Coordinator] = []
var callback: (@MainActor () -> Void)?
// MARK: - Setup
@MainActor init(parameters: ChangePasswordCoordinatorParameters) {
self.parameters = parameters
let viewModel = ChangePasswordViewModel()
let view = ChangePasswordScreen(viewModel: viewModel.context)
changePasswordViewModel = viewModel
changePasswordHostingController = VectorHostingController(rootView: view)
changePasswordHostingController.vc_removeBackTitle()
changePasswordHostingController.enableNavigationBarScrollEdgeAppearance = true
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: changePasswordHostingController)
}
// MARK: - Public
func start() {
MXLog.debug("[ChangePasswordCoordinator] did start.")
Task { await setupViewModel() }
}
func toPresentable() -> UIViewController {
return self.changePasswordHostingController
}
// MARK: - Private
/// Set up the view model. This method is extracted from `start()` so it can run on the `MainActor`.
@MainActor private func setupViewModel() {
changePasswordViewModel.callback = { [weak self] result in
guard let self = self else { return }
MXLog.debug("[ChangePasswordCoordinator] ChangePasswordViewModel did complete with result: \(result).")
switch result {
case .submit(let oldPassword, let newPassword, let signoutAllDevices):
self.changePassword(from: oldPassword, to: newPassword, signoutAllDevices: signoutAllDevices)
}
}
}
/// Show an activity indicator whilst loading.
@MainActor private func startLoading() {
loadingIndicator = indicatorPresenter.present(.loading(label: VectorL10n.loading, isInteractionBlocking: true))
}
/// Hide the currently displayed activity indicator.
@MainActor private func stopLoading() {
loadingIndicator = nil
}
/// Submits a reset password request with signing out of all devices option
@MainActor private func changePassword(from oldPassword: String, to newPassword: String, signoutAllDevices: Bool) {
startLoading()
currentTask = Task { [weak self] in
do {
try await parameters.restClient.changePassword(from: oldPassword, to: newPassword, logoutDevices: signoutAllDevices)
// Shouldn't be reachable but just in case, continue the flow.
guard !Task.isCancelled else { return }
self?.stopLoading()
self?.callback?()
} catch is CancellationError {
return
} catch {
self?.stopLoading()
self?.handleError(error)
}
}
}
/// Processes an error to either update the flow or display it to the user.
@MainActor private func handleError(_ error: Error) {
if let mxError = MXError(nsError: error as NSError) {
changePasswordViewModel.displayError(.mxError(mxError.error))
return
}
// TODO: Handle another other error types as needed.
changePasswordViewModel.displayError(.unknown)
}
}

View file

@ -0,0 +1,64 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
import SwiftUI
/// Using an enum for the screen allows you define the different state cases with
/// the relevant associated data for each case.
@available(iOS 14.0, *)
enum MockChangePasswordScreenState: MockScreenState, CaseIterable {
// A case for each state you want to represent
// with specific, minimal associated data that will allow you
// mock that screen.
case allEmpty
case cannotSubmit
case canSubmit
case canSubmitAndSignoutAllDevicesChecked
/// The associated screen
var screenType: Any.Type {
ChangePasswordScreen.self
}
/// Generate the view struct for the screen state.
var screenView: ([Any], AnyView) {
let viewModel: ChangePasswordViewModel
switch self {
case .allEmpty:
viewModel = ChangePasswordViewModel()
case .cannotSubmit:
viewModel = ChangePasswordViewModel(oldPassword: "12345678",
newPassword1: "87654321",
newPassword2: "97654321")
case .canSubmit:
viewModel = ChangePasswordViewModel(oldPassword: "12345678",
newPassword1: "87654321",
newPassword2: "87654321")
case .canSubmitAndSignoutAllDevicesChecked:
viewModel = ChangePasswordViewModel(oldPassword: "12345678",
newPassword1: "87654321",
newPassword2: "87654321",
signoutAllDevices: true)
}
// can simulate service and viewModel actions here if needs be.
return (
[viewModel], AnyView(ChangePasswordScreen(viewModel: viewModel.context))
)
}
}

View file

@ -0,0 +1,140 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import XCTest
import RiotSwiftUI
class ChangePasswordUITests: MockScreenTest {
override class var screenType: MockScreenState.Type {
return MockChangePasswordScreenState.self
}
override class func createTest() -> MockScreenTest {
return ChangePasswordUITests(selector: #selector(verifyChangePasswordScreen))
}
func verifyChangePasswordScreen() throws {
guard let screenState = screenState as? MockChangePasswordScreenState else { fatalError("no screen") }
switch screenState {
case .allEmpty:
verifyAllEmpty()
case .cannotSubmit:
verifyCannotSubmit()
case .canSubmit:
verifyCanSubmit()
case .canSubmitAndSignoutAllDevicesChecked:
verifyCanSubmitAndSignoutAllDevicesChecked()
}
}
func verifyAllEmpty() {
XCTAssertTrue(app.staticTexts["titleLabel"].exists, "The title should be shown.")
let oldPasswordTextField = app.secureTextFields["oldPasswordTextField"]
XCTAssertTrue(oldPasswordTextField.exists, "The text field should be shown.")
XCTAssertEqual(oldPasswordTextField.label, "old password", "The text field should be showing the placeholder before text is input.")
let newPasswordTextField1 = app.secureTextFields["newPasswordTextField1"]
XCTAssertTrue(newPasswordTextField1.exists, "The text field should be shown.")
XCTAssertEqual(newPasswordTextField1.label, "new password", "The text field should be showing the placeholder before text is input.")
let newPasswordTextField2 = app.secureTextFields["newPasswordTextField2"]
XCTAssertTrue(newPasswordTextField2.exists, "The text field should be shown.")
XCTAssertEqual(newPasswordTextField2.label, "confirm password", "The text field should be showing the placeholder before text is input.")
let submitButton = app.buttons["submitButton"]
XCTAssertTrue(submitButton.exists, "The submit button should be shown.")
XCTAssertFalse(submitButton.isEnabled, "The submit button should be disabled when not able to submit.")
let signoutAllDevicesToggle = app.switches["signoutAllDevicesToggle"]
XCTAssertTrue(signoutAllDevicesToggle.exists, "Sign out all devices toggle should exist")
XCTAssertTrue(signoutAllDevicesToggle.isOn, "Sign out all devices should be checked")
}
func verifyCannotSubmit() {
XCTAssertTrue(app.staticTexts["titleLabel"].exists, "The title should be shown.")
let oldPasswordTextField = app.secureTextFields["oldPasswordTextField"]
XCTAssertTrue(oldPasswordTextField.exists, "The text field should be shown.")
XCTAssertEqual(oldPasswordTextField.value as? String, "••••••••", "The text field should be showing the placeholder before text is input.")
let newPasswordTextField1 = app.secureTextFields["newPasswordTextField1"]
XCTAssertTrue(newPasswordTextField1.exists, "The text field should be shown.")
XCTAssertEqual(newPasswordTextField1.value as? String, "••••••••", "The text field should be showing the placeholder before text is input.")
let newPasswordTextField2 = app.secureTextFields["newPasswordTextField2"]
XCTAssertTrue(newPasswordTextField2.exists, "The text field should be shown.")
XCTAssertEqual(newPasswordTextField2.value as? String, "••••••••", "The text field should be showing the placeholder before text is input.")
let submitButton = app.buttons["submitButton"]
XCTAssertTrue(submitButton.exists, "The submit button should be shown.")
XCTAssertFalse(submitButton.isEnabled, "The submit button should be disabled when not able to submit.")
let signoutAllDevicesToggle = app.switches["signoutAllDevicesToggle"]
XCTAssertTrue(signoutAllDevicesToggle.exists, "Sign out all devices toggle should exist")
XCTAssertTrue(signoutAllDevicesToggle.isOn, "Sign out all devices should be checked")
}
func verifyCanSubmit() {
XCTAssertTrue(app.staticTexts["titleLabel"].exists, "The title should be shown.")
let oldPasswordTextField = app.secureTextFields["oldPasswordTextField"]
XCTAssertTrue(oldPasswordTextField.exists, "The text field should be shown.")
XCTAssertEqual(oldPasswordTextField.value as? String, "••••••••", "The text field should be showing the placeholder before text is input.")
let newPasswordTextField1 = app.secureTextFields["newPasswordTextField1"]
XCTAssertTrue(newPasswordTextField1.exists, "The text field should be shown.")
XCTAssertEqual(newPasswordTextField1.value as? String, "••••••••", "The text field should be showing the placeholder before text is input.")
let newPasswordTextField2 = app.secureTextFields["newPasswordTextField2"]
XCTAssertTrue(newPasswordTextField2.exists, "The text field should be shown.")
XCTAssertEqual(newPasswordTextField2.value as? String, "••••••••", "The text field should be showing the placeholder before text is input.")
let submitButton = app.buttons["submitButton"]
XCTAssertTrue(submitButton.exists, "The submit button should be shown.")
XCTAssertTrue(submitButton.isEnabled, "The submit button should be enabled when able to submit.")
let signoutAllDevicesToggle = app.switches["signoutAllDevicesToggle"]
XCTAssertTrue(signoutAllDevicesToggle.exists, "Sign out all devices toggle should exist")
XCTAssertTrue(signoutAllDevicesToggle.isOn, "Sign out all devices should be checked")
}
func verifyCanSubmitAndSignoutAllDevicesChecked() {
XCTAssertTrue(app.staticTexts["titleLabel"].exists, "The title should be shown.")
let oldPasswordTextField = app.secureTextFields["oldPasswordTextField"]
XCTAssertTrue(oldPasswordTextField.exists, "The text field should be shown.")
XCTAssertEqual(oldPasswordTextField.value as? String, "••••••••", "The text field should be showing the placeholder before text is input.")
let newPasswordTextField1 = app.secureTextFields["newPasswordTextField1"]
XCTAssertTrue(newPasswordTextField1.exists, "The text field should be shown.")
XCTAssertEqual(newPasswordTextField1.value as? String, "••••••••", "The text field should be showing the placeholder before text is input.")
let newPasswordTextField2 = app.secureTextFields["newPasswordTextField2"]
XCTAssertTrue(newPasswordTextField2.exists, "The text field should be shown.")
XCTAssertEqual(newPasswordTextField2.value as? String, "••••••••", "The text field should be showing the placeholder before text is input.")
let submitButton = app.buttons["submitButton"]
XCTAssertTrue(submitButton.exists, "The submit button should be shown.")
XCTAssertTrue(submitButton.isEnabled, "The submit button should be enabled when able to submit.")
let signoutAllDevicesToggle = app.switches["signoutAllDevicesToggle"]
XCTAssertTrue(signoutAllDevicesToggle.exists, "Sign out all devices toggle should exist")
XCTAssertTrue(signoutAllDevicesToggle.isOn, "Sign out all devices should be checked")
}
}

View file

@ -0,0 +1,50 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import XCTest
@testable import RiotSwiftUI
class ChangePasswordViewModelTests: XCTestCase {
@MainActor func testEmptyState() async {
let viewModel = ChangePasswordViewModel()
let context = viewModel.context
// Given an empty view model
XCTAssert(context.oldPassword.isEmpty, "The view model should start with an empty old password.")
XCTAssert(context.newPassword1.isEmpty, "The view model should start with an empty new password 1.")
XCTAssert(context.newPassword2.isEmpty, "The view model should start with an empty new password 2.")
XCTAssertFalse(context.viewState.canSubmit, "The view model should not be able to submit.")
XCTAssertTrue(context.signoutAllDevices, "The view model should start with sign out of all devices checked.")
}
@MainActor func testValidState() async {
let viewModel = ChangePasswordViewModel(oldPassword: "12345678",
newPassword1: "87654321",
newPassword2: "87654321",
signoutAllDevices: false)
let context = viewModel.context
// Given a filled view model in valid state
XCTAssertFalse(context.oldPassword.isEmpty, "The view model should start with an empty old password.")
XCTAssertFalse(context.newPassword1.isEmpty, "The view model should start with an empty new password 1.")
XCTAssertFalse(context.newPassword2.isEmpty, "The view model should start with an empty new password 2.")
XCTAssertTrue(context.viewState.canSubmit, "The view model should be able to submit.")
XCTAssertFalse(context.signoutAllDevices, "Sign out of all devices should be unchecked.")
}
}

View file

@ -0,0 +1,136 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
struct ChangePasswordScreen: View {
// MARK: - Properties
// MARK: Private
@Environment(\.theme) private var theme
enum Field { case oldPassword, newPassword1, newPassword2 }
@State private var focusedField: Field?
// MARK: Public
@ObservedObject var viewModel: ChangePasswordViewModel.Context
// MARK: Views
var body: some View {
ScrollView {
VStack(spacing: 0) {
header
.padding(.top, 16)
.padding(.bottom, 36)
form
}
.readableFrame()
.padding(.horizontal, 16)
}
.background(theme.colors.background.ignoresSafeArea())
.alert(item: $viewModel.alertInfo) { $0.alert }
.accentColor(theme.colors.accent)
}
/// The title and icon at the top of the screen.
var header: some View {
VStack(spacing: 8) {
OnboardingIconImage(image: Asset.Images.authenticationPasswordIcon)
.padding(.bottom, 16)
Text(VectorL10n.settingsChangePassword)
.font(theme.fonts.title2B)
.multilineTextAlignment(.center)
.foregroundColor(theme.colors.primaryContent)
.accessibilityIdentifier("titleLabel")
}
}
/// The text fields and submit button.
var form: some View {
VStack(alignment: .leading, spacing: 12) {
RoundedBorderTextField(placeHolder: VectorL10n.settingsOldPassword,
text: $viewModel.oldPassword,
isFirstResponder: focusedField == .oldPassword,
configuration: UIKitTextInputConfiguration(returnKeyType: .next,
isSecureTextEntry: true),
onCommit: { focusedField = .newPassword1 })
.accessibilityIdentifier("oldPasswordTextField")
RoundedBorderTextField(placeHolder: VectorL10n.settingsNewPassword,
text: $viewModel.newPassword1,
isFirstResponder: focusedField == .newPassword1,
configuration: UIKitTextInputConfiguration(returnKeyType: .next,
isSecureTextEntry: true),
onCommit: { focusedField = .newPassword2 })
.accessibilityIdentifier("newPasswordTextField1")
RoundedBorderTextField(placeHolder: VectorL10n.settingsConfirmPassword,
text: $viewModel.newPassword2,
isFirstResponder: focusedField == .newPassword2,
configuration: UIKitTextInputConfiguration(returnKeyType: .done,
isSecureTextEntry: true),
onCommit: submit)
.accessibilityIdentifier("newPasswordTextField2")
HStack(alignment: .center, spacing: 8) {
Toggle(VectorL10n.authenticationChoosePasswordSignoutAllDevices, isOn: $viewModel.signoutAllDevices)
.toggleStyle(AuthenticationTermsToggleStyle())
.accessibilityIdentifier("signoutAllDevicesToggle")
Text(VectorL10n.authenticationChoosePasswordSignoutAllDevices)
.foregroundColor(theme.colors.secondaryContent)
}
.padding(.top, 8)
.padding(.bottom, 16)
.onTapGesture(perform: toggleSignoutAllDevices)
Button(action: submit) {
Text(VectorL10n.save)
}
.buttonStyle(PrimaryActionButtonStyle())
.disabled(!viewModel.viewState.canSubmit)
.accessibilityIdentifier("submitButton")
}
}
/// Sends the `submit` view action if viewModel.viewState.canSubmit.
func submit() {
guard viewModel.viewState.canSubmit else { return }
viewModel.send(viewAction: .submit)
}
/// Sends the `toggleSignoutAllDevices` view action.
func toggleSignoutAllDevices() {
viewModel.send(viewAction: .toggleSignoutAllDevices)
}
}
// MARK: - Previews
struct ChangePasswordScreen_Previews: PreviewProvider {
static let stateRenderer = MockChangePasswordScreenState.stateRenderer
static var previews: some View {
stateRenderer.screenGroup(addNavigation: true)
.navigationViewStyle(.stack)
stateRenderer.screenGroup(addNavigation: true)
.navigationViewStyle(.stack)
.theme(.dark).preferredColorScheme(.dark)
}
}