Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weโ€™ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

PM-16142: Guided Tour for Generator screen #1301

Merged
merged 14 commits into from
Feb 3, 2025
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,11 @@ extension View {
/// - Returns: A copy of the view with the guided tour step modifier applied.
///
func guidedTourStep(_ step: GuidedTourStep, perform: @escaping (CGRect) -> Void) -> some View {
onFrameChanged { origin, size in
perform(CGRect(origin: origin, size: size))
let identifier = step.id
return onFrameChanged(id: identifier) { id, origin, size in
if identifier == "\(id)" {
ezimet-livefront marked this conversation as resolved.
Show resolved Hide resolved
perform(CGRect(origin: origin, size: size))
}
}
.id(step)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,24 +18,30 @@ extension View {

/// A view modifier that calculates the origin and size of the containing view.
///
/// - Parameter perform: A closure called when the size or origin of the view changes.
/// - Parameters:
/// - id: A unique identifier for the view.
ezimet-livefront marked this conversation as resolved.
Show resolved Hide resolved
/// - perform: A closure called when the size or origin of the view changes.
/// - Returns: A copy of the view with the sizing and origin modifier applied.
///
func onFrameChanged(perform: @escaping (CGPoint, CGSize) -> Void) -> some View {
func onFrameChanged(id: String, perform: @escaping (String, CGPoint, CGSize) -> Void) -> some View {
background(
GeometryReader { geometry in
Color.clear
.preference(
key: ViewFrameKey.self,
value: CGRect(
origin: geometry.frame(in: .global).origin,
size: geometry.size
)
value: [
id: CGRect(
origin: geometry.frame(in: .global).origin,
size: geometry.size
),
]
)
}
)
.onPreferenceChange(ViewFrameKey.self) { value in
perform(value.origin, value.size)
if let frame = value[id] {
perform(id, frame.origin, frame.size)
}
}
}
}
Expand All @@ -52,12 +58,11 @@ private struct ViewSizeKey: PreferenceKey {

/// A `PreferenceKey` used to calculate the size and origin of a view.
///
private struct ViewFrameKey: PreferenceKey {
static var defaultValue = CGRect.zero
struct ViewFrameKey: PreferenceKey {
static var defaultValue: [String: CGRect] = [:]
ezimet-livefront marked this conversation as resolved.
Show resolved Hide resolved

static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
if nextValue() != defaultValue {
value = nextValue()
}
static func reduce(value: inout [String: CGRect], nextValue: () -> [String: CGRect]) {
let newValue = nextValue().filter { $0.value.size != .zero }
value.merge(newValue) { _, new in new }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@
"UseFingerprintToUnlock" = "Use fingerprint to unlock";
"UseThisButtonToGenerateANewUniquePassword" = "Use this button to generate a new unique password.";
"YouWillOnlyNeedToSetUpAnAuthenticatorKeyDescriptionLong" = "You'll only need to set up Authenticator Key for logins that require two-factor authentication with a code. The key will continuously generate six-digit codes you can use to log in.";
"UseTheGeneratorToCreateASecurePasswordPassphrasesAndUsernames" = "Use the generator to create secure passwords, passphrases and usernames. ";
ezimet-livefront marked this conversation as resolved.
Show resolved Hide resolved
"PassphrasesAreOftenEasierToRememberDescriptionLong" = "Passphrases are often easier to remember and type than random passwords. They can be helpful when you need to log into accounts where autofill is not available, like a streaming service on your TV.";
"UniqueUsernamesAddAnExtraLayerOfSecurityAndCanHelpPreventHackersFromFindingYourAccounts" = "Unique usernames add an extra layer of security and can help prevent hackers from finding your accounts.";
"UseTheseOptionsToAdjustYourPasswordToYourAccountsRequirements" = "Use these options to adjust your password to your accountโ€™s requirements. ";
ezimet-livefront marked this conversation as resolved.
Show resolved Hide resolved
ezimet-livefront marked this conversation as resolved.
Show resolved Hide resolved
"AfterYouSaveYourNewPasswordToBitwardenDontForgetToUpdateItOnYourAccountWebsite" = "After you save your new password to Bitwarden, donโ€™t forget to update it on your account website.";
"YouMustAddAWebAddressToUseAutofillToAccessThisAccount" = "You must add a web address to use autofill to access this account.";
"Username" = "Username";
"ValidationFieldRequired" = "The %1$@ field is required.";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,4 +77,9 @@ enum GuidedTourStep: Int, Equatable {

/// The sixth step of the guided tour.
case step6

/// The identifier of the step.
var id: String {
"\(rawValue)"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@
VStack(alignment: .leading) {
cardContent()
.frame(maxWidth: cardMaxWidth)
.onFrameChanged { _, size in
.onFrameChanged(id: "card") { _, _, size in
cardSize = size
}
}
Expand Down Expand Up @@ -101,6 +101,12 @@
}
viewSize = geometry.size
}
.onChange(of: store.state.currentStepState) { _ in
opacity = 0
withAnimation(.easeInOut(duration: UI.duration(0.7))) {
opacity = 1
}
}

Check warning on line 109 in BitwardenShared/UI/Platform/Application/Views/GuidedTourView/GuidedTourView.swift

View check run for this annotation

Codecov / codecov/patch

BitwardenShared/UI/Platform/Application/Views/GuidedTourView/GuidedTourView.swift#L105-L109

Added lines #L105 - L109 were not covered by tests
ezimet-livefront marked this conversation as resolved.
Show resolved Hide resolved
}
}

Expand Down Expand Up @@ -194,7 +200,7 @@

// If there is enough space to show the card as center of the arrow,
// calculate the offset to show the card as center of the arrow.
if viewSize.width - arrowOffset + arrowSize.width > cardSize.width / 2,
if viewSize.width - arrowOffset + arrowSize.width/2 > cardSize.width / 2,
arrowOffset > cardSize.width / 2 {
return (arrowOffset + arrowSize.width / 2 + cardSize.width / 2)
- (cardLeadingPadding + cardSize.width)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,22 @@ class GuidedTourViewTests: BitwardenTestCase {
subject = nil
}

@MainActor
func setupSubjectForGenerator() {
processor = MockProcessor(
state: GuidedTourViewState(currentIndex: 0, guidedTourStepStates: [
.generatorStep1,
.generatorStep2,
.generatorStep3,
.generatorStep4,
.generatorStep5,
.generatorStep6,
])
)
let store = Store(processor: processor)
subject = GuidedTourView(store: store)
}
ezimet-livefront marked this conversation as resolved.
Show resolved Hide resolved

// MARK: Tests

/// Tap the `back` button should dispatch the `backTapped` action.
Expand Down Expand Up @@ -71,6 +87,80 @@ class GuidedTourViewTests: BitwardenTestCase {
XCTAssertEqual(processor.dispatchedActions, [.nextTapped])
}

// MARK: Snapshot tests

/// Test the snapshot of the step 1 of the learn generator guided tour.
@MainActor
func test_snapshot_generatorStep1() {
setupSubjectForGenerator()
processor.state.currentIndex = 0
processor.state.guidedTourStepStates[0].spotlightRegion = CGRect(x: 25, y: 80, width: 340, height: 40)
assertSnapshots(
of: subject,
as: [.defaultPortrait]
)
}

/// Test the snapshot of the step 2 of the learn generator guided tour.
@MainActor
func test_snapshot_generatorStep2() {
setupSubjectForGenerator()
processor.state.currentIndex = 1
processor.state.guidedTourStepStates[1].spotlightRegion = CGRect(x: 25, y: 80, width: 340, height: 40)
assertSnapshots(
of: subject,
as: [.defaultPortrait]
)
}

/// Test the snapshot of the step 3 of the learn generator guided tour.
@MainActor
func test_snapshot_generatorStep3() {
setupSubjectForGenerator()
processor.state.currentIndex = 2
processor.state.guidedTourStepStates[2].spotlightRegion = CGRect(x: 25, y: 80, width: 340, height: 40)
assertSnapshots(
of: subject,
as: [.defaultPortrait]
)
}

/// Test the snapshot of the step 4 of the learn generator guided tour.
@MainActor
func test_snapshot_generatorStep4() {
setupSubjectForGenerator()
processor.state.currentIndex = 3
processor.state.guidedTourStepStates[3].spotlightRegion = CGRect(x: 25, y: 300, width: 340, height: 400)
assertSnapshots(
of: subject,
as: [.defaultPortrait]
)
}

/// Test the snapshot of the step 5 of the learn generator guided tour.
@MainActor
func test_snapshot_generatorStep5() {
setupSubjectForGenerator()
processor.state.currentIndex = 4
processor.state.guidedTourStepStates[4].spotlightRegion = CGRect(x: 300, y: 160, width: 40, height: 40)
assertSnapshots(
of: subject,
as: [.defaultPortrait]
)
}

/// Test the snapshot of the step 6 of the learn generator guided tour.
@MainActor
func test_snapshot_generatorStep6() {
setupSubjectForGenerator()
processor.state.currentIndex = 5
processor.state.guidedTourStepStates[5].spotlightRegion = CGRect(x: 25, y: 160, width: 340, height: 60)
assertSnapshots(
of: subject,
as: [.defaultPortrait]
)
}

/// Test the snapshot of the step 1 of the learn new login guided tour.
@MainActor
func test_snapshot_loginStep1() {
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ enum GeneratorAction: Equatable {
/// The generator type was changed.
case generatorTypeChanged(GeneratorType)

/// A guided tour view action was triggered.
case guidedTourViewAction(GuidedTourViewAction)

/// The refresh generated value button was pressed.
case refreshGeneratedValue

Expand Down Expand Up @@ -74,6 +77,7 @@ extension GeneratorAction {
return keyPath == nil
case .copyGeneratedValue,
.dismissPressed,
.guidedTourViewAction,
.selectButtonPressed,
.showPasswordHistory,
.sliderEditingChanged,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,8 @@ final class GeneratorProcessor: StateProcessor<GeneratorState, GeneratorAction,
state.usernameState.forwardedEmailService = forwardedEmailService
case let .usernameGeneratorTypeChanged(usernameGeneratorType):
state.usernameState.usernameGeneratorType = usernameGeneratorType
case let .guidedTourViewAction(action):
state.guidedTourViewState.updateStateForGuidedTourViewAction(action)
}

if let generateValueBehavior {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -492,6 +492,61 @@ class GeneratorProcessorTests: BitwardenTestCase { // swiftlint:disable:this typ
XCTAssertEqual(subject.state.generatorType, .username)
}

/// `receive(_:)` with `.guidedTourViewAction(.backTapped)` updates the guided tour state to the previous step.
@MainActor
func test_receive_guidedTourViewAction_backTapped() {
subject.state.guidedTourViewState.currentIndex = 1

subject.receive(.guidedTourViewAction(.backTapped))
XCTAssertEqual(subject.state.guidedTourViewState.currentIndex, 0)
}

/// `receive(_:)` with `.guidedTourViewAction(.nextTapped)` updates the guided tour state to the next step.
@MainActor
func test_receive_guidedTourViewAction_nextTapped() {
subject.state.guidedTourViewState.currentIndex = 0

subject.receive(.guidedTourViewAction(.nextTapped))
XCTAssertEqual(subject.state.guidedTourViewState.currentIndex, 1)
}

/// `receive(_:)` with `.guidedTourViewAction(.doneTapped)` completes the guided tour.
@MainActor
func test_receive_doneTapped() {
subject.receive(.guidedTourViewAction(.doneTapped))
XCTAssertFalse(subject.state.guidedTourViewState.showGuidedTour)
}

/// `receive(_:)` with `.guidedTourViewAction(.dismissTapped)` dismisses the guided tour.
@MainActor
func test_receive_guidedTourViewAction_dismissTapped() {
subject.receive(.guidedTourViewAction(.dismissTapped))
XCTAssertFalse(subject.state.guidedTourViewState.showGuidedTour)
}

/// `receive(_:)` with `.guidedTourViewAction(.didRenderViewToSpotlight)` updates the spotlight region.
@MainActor
func test_receive_guidedTourViewAction_didRenderViewToSpotlight() {
let frame = CGRect(x: 10, y: 10, width: 100, height: 100)
subject.state.guidedTourViewState.currentIndex = 0

subject.receive(.guidedTourViewAction(.didRenderViewToSpotlight(frame: frame, step: .step1)))
XCTAssertEqual(subject.state.guidedTourViewState.currentStepState.spotlightRegion, frame)
}

/// `receive(_:)` with `.guidedTourViewAction(.toggleGuidedTourVisibilityChanged)`
/// updates the visibility of the guided tour.
@MainActor
func test_receive_guidedTourViewAction_toggleGuidedTourVisibilityChanged() {
subject.state.guidedTourViewState.showGuidedTour = false

subject.receive(.guidedTourViewAction(.toggleGuidedTourVisibilityChanged(true)))
XCTAssertTrue(subject.state.guidedTourViewState.showGuidedTour)

subject.receive(.guidedTourViewAction(.toggleGuidedTourVisibilityChanged(false)))
XCTAssertFalse(subject.state.guidedTourViewState.showGuidedTour)
}

/// `receive(_:)` with `.refreshGeneratedValue` generates a new passphrase.
@MainActor
func test_receive_refreshGeneratedValue_passphrase() throws {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,17 @@ struct GeneratorState: Equatable {
/// The generated value (password, passphrase or username).
var generatedValue: String = ""

var guidedTourViewState = GuidedTourViewState(
ezimet-livefront marked this conversation as resolved.
Show resolved Hide resolved
guidedTourStepStates: [
.generatorStep1,
.generatorStep2,
.generatorStep3,
.generatorStep4,
.generatorStep5,
.generatorStep6,
]
)

/// Whether there's a password generation policy in effect.
var isPolicyInEffect = false

Expand Down Expand Up @@ -486,3 +497,44 @@ extension GeneratorState {
return groups
}
}

/// extension for `GuidedTourStepState` to provide states for learn new login guided tour.
extension GuidedTourStepState {
/// The first step of the learn new login guided tour.
static let generatorStep1 = GuidedTourStepState(
arrowHorizontalPosition: .left,
spotlightShape: .rectangle(cornerRadius: 25),
title: Localizations.useTheGeneratorToCreateASecurePasswordPassphrasesAndUsernames
)

/// The first step of the learn new login guided tour.
ezimet-livefront marked this conversation as resolved.
Show resolved Hide resolved
static let generatorStep2 = GuidedTourStepState(
arrowHorizontalPosition: .center,
spotlightShape: .rectangle(cornerRadius: 25),
title: Localizations.passphrasesAreOftenEasierToRememberDescriptionLong
)

static let generatorStep3 = GuidedTourStepState(
arrowHorizontalPosition: .right,
spotlightShape: .rectangle(cornerRadius: 25),
title: Localizations.uniqueUsernamesAddAnExtraLayerOfSecurityAndCanHelpPreventHackersFromFindingYourAccounts
)

static let generatorStep4 = GuidedTourStepState(
arrowHorizontalPosition: .center,
spotlightShape: .rectangle(cornerRadius: 8),
title: Localizations.useTheseOptionsToAdjustYourPasswordToYourAccountsRequirements
)

static let generatorStep5 = GuidedTourStepState(
arrowHorizontalPosition: .center,
spotlightShape: .circle,
title: Localizations.useThisButtonToGenerateANewUniquePassword
)

static let generatorStep6 = GuidedTourStepState(
arrowHorizontalPosition: .center,
spotlightShape: .rectangle(cornerRadius: 8),
title: Localizations.afterYouSaveYourNewPasswordToBitwardenDontForgetToUpdateItOnYourAccountWebsite
)
ezimet-livefront marked this conversation as resolved.
Show resolved Hide resolved
}
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,19 @@ class GeneratorStateTests: BitwardenTestCase { // swiftlint:disable:this type_bo
}
}

/// `guidedTourViewState` returns the initial state of the guided tour.
func test_guidedTourViewState_initialState() {
let subject = GeneratorState()
XCTAssertEqual(subject.guidedTourViewState.currentIndex, 0)
XCTAssertEqual(subject.guidedTourViewState.guidedTourStepStates.count, 6)
XCTAssertEqual(subject.guidedTourViewState.guidedTourStepStates[0], .generatorStep1)
XCTAssertEqual(subject.guidedTourViewState.guidedTourStepStates[1], .generatorStep2)
XCTAssertEqual(subject.guidedTourViewState.guidedTourStepStates[2], .generatorStep3)
XCTAssertEqual(subject.guidedTourViewState.guidedTourStepStates[3], .generatorStep4)
XCTAssertEqual(subject.guidedTourViewState.guidedTourStepStates[4], .generatorStep5)
XCTAssertEqual(subject.guidedTourViewState.guidedTourStepStates[5], .generatorStep6)
}

/// `isGeneratorTypeDisabled(_:)` returns whether a generator type is disabled when the
/// override is enabled and the default type is used.
func test_isGeneratorTypeDisabled_policy_overrideDefaultType() {
Expand Down
Loading