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 all 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
@@ -0,0 +1,48 @@
import SwiftUI

/// A custom view modifier to apply smooth transition effects and animations.
///
struct SmoothTransitionModifier<V: Equatable>: ViewModifier {
/// The animation to apply to the view.
let animation: Animation

/// The value that triggers the animation.
let value: V

func body(content: Content) -> some View {
content
.animation(animation, value: value)
.modifier(SmoothTransitionEffect())
}
}

/// A custom geometry effect that applies a smooth transition effect to a view.
/// This effect allows for animating the translation of a view along the X and Y axes.
struct SmoothTransitionEffect: GeometryEffect {
/// The animatable data representing the translation values for the X and Y axes.
var animatableData: AnimatablePair<CGFloat, CGFloat>

/// Initializes a new instance of the `SmoothTransitionEffect` with default translation values.
init() {
animatableData = AnimatablePair(0, 0)
}

/// Computes the projection transform for the given size, applying the translation effect.
///
/// - Parameter size: The size of the view to which the effect is applied.
/// - Returns: A `ProjectionTransform` representing the translation transformation.
func effectValue(size: CGSize) -> ProjectionTransform {
let translation = CGAffineTransform(translationX: animatableData.first, y: animatableData.second)
return ProjectionTransform(translation)
}
}

// MARK: - Extensions

extension View {
/// A view modifier that applies a smooth transition effect to the view.
///
func smoothTransition<V: Equatable>(animation: Animation, value: V) -> some View {
modifier(SmoothTransitionModifier(animation: animation, value: value))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ 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
onFrameChanged(id: step.id) { origin, size in
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,33 @@ 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. This is necessary to distinguish between multiple
/// views that might be using the same modifier, ensuring that the correct view's changes
/// are tracked and handled.
/// - perform: A closure called when the size or origin of the view changes. The closure receives
/// the new size and origin of the view as parameters.
/// - 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 (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(frame.origin, frame.size)
}
}
}
}
Expand All @@ -52,12 +61,15 @@ 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
/// The `ViewFrameKey` stores a dictionary that maps a view's identifier (as a `String`)
/// to the last received frame (`CGRect`) for that view. This allows tracking the size
/// and position of views within a SwiftUI hierarchy.
///
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.";
"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.";
"UseTheseOptionsToAdjustYourPasswordToMeetYourAccountWebsitesRequirements" = "Use these options to adjust your password to meet your account websiteโ€™s requirements.";
"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
@@ -0,0 +1,108 @@
// swiftlint:disable:this file_name
import SnapshotTesting
import SwiftUI
import ViewInspector
import XCTest

@testable import BitwardenShared

// MARK: - GuidedTourView+GeneratorTests

class GuidedTourViewGeneratorTests: BitwardenTestCase {
// MARK: Properties

var processor: MockProcessor<GuidedTourViewState, GuidedTourViewAction, Void>!
var subject: GuidedTourView!

// MARK: Setup & Teardown

override func setUp() {
super.setUp()
processor = MockProcessor(
state: GuidedTourViewState(currentIndex: 0, guidedTourStepStates: [
.generatorStep1,
.generatorStep2,
.generatorStep3,
.generatorStep4,
.generatorStep5,
.generatorStep6,
])
)
let store = Store(processor: processor)
subject = GuidedTourView(store: store)
}

override func tearDown() {
super.tearDown()
processor = nil
subject = nil
}

// MARK: Snapshot tests

/// Test the snapshot of the step 1 of the learn generator guided tour.
@MainActor
func test_snapshot_generatorStep1() {
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() {
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() {
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() {
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() {
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() {
processor.state.currentIndex = 5
processor.state.guidedTourStepStates[5].spotlightRegion = CGRect(x: 25, y: 160, width: 340, height: 60)
assertSnapshots(
of: subject,
as: [.defaultPortrait]
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
// swiftlint:disable:this file_name
import SnapshotTesting
import SwiftUI
import ViewInspector
import XCTest

@testable import BitwardenShared

// MARK: - GuidedTourView+LoginTests

class GuidedTourViewLoginTests: BitwardenTestCase {
// MARK: Properties

var processor: MockProcessor<GuidedTourViewState, GuidedTourViewAction, Void>!
var subject: GuidedTourView!

// MARK: Setup & Teardown

override func setUp() {
super.setUp()
processor = MockProcessor(
state: GuidedTourViewState(currentIndex: 0, guidedTourStepStates: [
.loginStep1,
.loginStep2,
.loginStep3,
])
)
let store = Store(processor: processor)
subject = GuidedTourView(store: store)
}

override func tearDown() {
super.tearDown()
processor = nil
subject = nil
}

// MARK: Tests

/// Tap the `back` button should dispatch the `backTapped` action.
@MainActor
func test_backButton_tap() async throws {
processor.state.currentIndex = 1
let button = try subject.inspect().find(button: Localizations.back)
try button.tap()
XCTAssertEqual(processor.dispatchedActions, [.backTapped])
}

/// Tapping the `done` button should dispatch the `doneTapped` action.
@MainActor
func test_doneButton_tap() async throws {
processor.state.currentIndex = 2
let button = try subject.inspect().find(button: Localizations.done)
try button.tap()
XCTAssertEqual(processor.dispatchedActions, [.doneTapped])
}

/// Tapping the dismiss button dispatches the `.dismissTapped` action.
@MainActor
func test_dismissButton_tap() async throws {
processor.state.currentIndex = 2
let button = try subject.inspect().find(button: Localizations.dismiss)
try button.tap()
XCTAssertEqual(processor.dispatchedActions, [.dismissTapped])
}

/// Tapping the `next` button should dispatch the `nextTapped` action.
@MainActor
func test_nextButton_tap() async throws {
let button = try subject.inspect().find(button: Localizations.next)
try button.tap()
XCTAssertEqual(processor.dispatchedActions, [.nextTapped])
}

// MARK: Snapshot tests

/// Test the snapshot of the step 1 of the learn new login guided tour.
@MainActor
func test_snapshot_loginStep1() {
processor.state.currentIndex = 0
processor.state.guidedTourStepStates[0].spotlightRegion = CGRect(x: 320, y: 470, width: 40, height: 40)
assertSnapshots(
of: subject,
as: [.defaultPortrait, .defaultPortraitDark]
)
}

/// Test the snapshot of the step 1 of the learn new login guided tour in landscape.
@MainActor
func test_snapshot_loginStep1_landscape() {
processor.state.currentIndex = 0
processor.state.guidedTourStepStates[0].spotlightRegion = CGRect(x: 650, y: 150, width: 40, height: 40)
assertSnapshots(
of: subject,
as: [.defaultLandscape]
)
}

/// Test the snapshot of the step 2 of the learn new login guided tour.
@MainActor
func test_snapshot_loginStep2() {
processor.state.currentIndex = 1
processor.state.guidedTourStepStates[1].spotlightRegion = CGRect(x: 40, y: 470, width: 320, height: 95)
assertSnapshots(
of: subject,
as: [.defaultPortrait, .defaultPortraitDark]
)
}

/// Test the snapshot of the step 2 of the learn new login guided tour in landscape.
@MainActor
func test_snapshot_loginStep2_landscape() {
processor.state.currentIndex = 1
processor.state.guidedTourStepStates[1].spotlightRegion = CGRect(x: 40, y: 60, width: 460, height: 95)
assertSnapshots(
of: subject,
as: [.defaultLandscape]
)
}

/// Test the snapshot of the step 3 of the learn new login guided tour.
@MainActor
func test_snapshot_loginStep3() {
processor.state.currentIndex = 2
processor.state.guidedTourStepStates[2].spotlightRegion = CGRect(x: 40, y: 500, width: 320, height: 90)
assertSnapshots(
of: subject,
as: [.defaultPortrait, .defaultPortraitDark]
)
}

/// Test the snapshot of the step 3 of the learn new login guided tour in landscape.
@MainActor
func test_snapshot_loginStep3_landscape() {
processor.state.currentIndex = 2
processor.state.guidedTourStepStates[2].spotlightRegion = CGRect(x: 40, y: 60, width: 460, height: 90)
assertSnapshots(
of: subject,
as: [.defaultLandscape]
)
}
}
Loading