diff --git a/.github/workflows/scan.yml b/.github/workflows/scan.yml index cc680a4c..3613604b 100644 --- a/.github/workflows/scan.yml +++ b/.github/workflows/scan.yml @@ -31,7 +31,7 @@ jobs: ref: ${{ github.event.pull_request.head.sha }} - name: Scan with Checkmarx - uses: checkmarx/ast-github-action@749fec53e0db0f6404a97e2e0807c3e80e3583a7 #2.0.23 + uses: checkmarx/ast-github-action@8a59a15b86b4e2f35b974222d1f516eedfe2d585 # 2.0.24 env: INCREMENTAL: "${{ contains(github.event_name, 'pull_request') && '--sast-incremental' || '' }}" with: diff --git a/Authenticator/Application/Preview Content/Preview Assets.xcassets/Contents.json b/Authenticator/Application/Preview Content/Preview Assets.xcassets/Contents.json deleted file mode 100644 index 73c00596..00000000 --- a/Authenticator/Application/Preview Content/Preview Assets.xcassets/Contents.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Authenticator/Application/Services/ErrorReporter/CrashlyticsErrorReporter.swift b/Authenticator/Application/Services/ErrorReporter/CrashlyticsErrorReporter.swift new file mode 100644 index 00000000..7fb924d2 --- /dev/null +++ b/Authenticator/Application/Services/ErrorReporter/CrashlyticsErrorReporter.swift @@ -0,0 +1,33 @@ +import AuthenticatorShared +import FirebaseCore +import FirebaseCrashlytics + +/// An `ErrorReporter` that logs non-fatal errors to Crashlytics for investigation. +/// +final class CrashlyticsErrorReporter: ErrorReporter { + // MARK: ErrorReporter Properties + + var isEnabled: Bool { + get { Crashlytics.crashlytics().isCrashlyticsCollectionEnabled() } + set { + Crashlytics.crashlytics().setCrashlyticsCollectionEnabled(newValue) + } + } + + // MARK: Initialization + + /// Initialize the `CrashlyticsErrorReporter`. + /// + init() { + FirebaseApp.configure() + } + + // MARK: ErrorReporter + + func log(error: Error) { + // Don't log networking related errors to Crashlytics. + guard !error.isNetworkingError else { return } + + Crashlytics.crashlytics().record(error: error) + } +} diff --git a/AuthenticatorShared/Core/Platform/Services/StateService.swift b/AuthenticatorShared/Core/Platform/Services/StateService.swift index 1e269301..2516685e 100644 --- a/AuthenticatorShared/Core/Platform/Services/StateService.swift +++ b/AuthenticatorShared/Core/Platform/Services/StateService.swift @@ -29,6 +29,10 @@ protocol StateService: AnyObject { /// func getShowWebIcons() async -> Bool + /// Whether the user has seen the welcome tutorial. + /// + var hasSeenWelcomeTutorial: Bool { get set } + /// Sets the app theme. /// /// - Parameter appTheme: The new app theme. @@ -77,6 +81,11 @@ actor DefaultStateService: StateService { set { appSettingsStore.appLocale = newValue.value } } + nonisolated var hasSeenWelcomeTutorial: Bool { + get { appSettingsStore.hasSeenWelcomeTutorial } + set { appSettingsStore.hasSeenWelcomeTutorial = newValue } + } + // MARK: Private Properties /// The service that persists app settings. diff --git a/AuthenticatorShared/Core/Platform/Services/Stores/AppSettingsStore.swift b/AuthenticatorShared/Core/Platform/Services/Stores/AppSettingsStore.swift index 3c7bd850..91bbb4ab 100644 --- a/AuthenticatorShared/Core/Platform/Services/Stores/AppSettingsStore.swift +++ b/AuthenticatorShared/Core/Platform/Services/Stores/AppSettingsStore.swift @@ -18,6 +18,9 @@ protocol AppSettingsStore: AnyObject { /// Whether to disable the website icons. var disableWebIcons: Bool { get set } + /// Whether the user has seen the welcome tutorial. + var hasSeenWelcomeTutorial: Bool { get set } + /// The user ID for the local user var localUserId: String { get } @@ -162,6 +165,7 @@ extension DefaultAppSettingsStore: AppSettingsStore { case appTheme case clearClipboardValue(userId: String) case disableWebIcons + case hasSeenWelcomeTutorial case migrationVersion /// Returns the key used to store the data under for retrieving it later. @@ -178,6 +182,8 @@ extension DefaultAppSettingsStore: AppSettingsStore { key = "clearClipboard_\(userId)" case .disableWebIcons: key = "disableFavicon" + case .hasSeenWelcomeTutorial: + key = "hasSeenWelcomeTutorial" case .migrationVersion: key = "migrationVersion" } @@ -205,6 +211,11 @@ extension DefaultAppSettingsStore: AppSettingsStore { set { store(newValue, for: .disableWebIcons) } } + var hasSeenWelcomeTutorial: Bool { + get { fetch(for: .hasSeenWelcomeTutorial) } + set { store(newValue, for: .hasSeenWelcomeTutorial) } + } + func clearClipboardValue(userId: String) -> ClearClipboardValue { if let rawValue: Int = fetch(for: .clearClipboardValue(userId: userId)), let value = ClearClipboardValue(rawValue: rawValue) { diff --git a/AuthenticatorShared/Core/Vault/Models/Domain/Fixtures/ItemListItem+Fixtures.swift b/AuthenticatorShared/Core/Vault/Models/Domain/Fixtures/ItemListItem+Fixtures.swift index 89512573..2b71cb3c 100644 --- a/AuthenticatorShared/Core/Vault/Models/Domain/Fixtures/ItemListItem+Fixtures.swift +++ b/AuthenticatorShared/Core/Vault/Models/Domain/Fixtures/ItemListItem+Fixtures.swift @@ -7,7 +7,7 @@ extension ItemListItem { static func fixture( id: String = "123", name: String = "Name", - totp: ItemListTotpItem + totp: ItemListTotpItem = .fixture() ) -> ItemListItem { ItemListItem( id: id, diff --git a/AuthenticatorShared/Core/Vault/Repositories/AuthenticatorItemRepository.swift b/AuthenticatorShared/Core/Vault/Repositories/AuthenticatorItemRepository.swift index f204866e..346f64a3 100644 --- a/AuthenticatorShared/Core/Vault/Repositories/AuthenticatorItemRepository.swift +++ b/AuthenticatorShared/Core/Vault/Repositories/AuthenticatorItemRepository.swift @@ -62,6 +62,16 @@ protocol AuthenticatorItemRepository: AnyObject { /// - Returns: A publisher for the list of a user's items /// func itemListPublisher() async throws -> AsyncThrowingPublisher> + + /// A publisher for searching a user's cipher objects based on the specified search text and filter type. + /// + /// - Parameters: + /// - searchText: The search text to filter the cipher list. + /// - Returns: A publisher searching for the user's ciphers. + /// + func searchItemListPublisher( + searchText: String + ) async throws -> AsyncThrowingPublisher> } // MARK: - DefaultAuthenticatorItemRepository @@ -115,6 +125,38 @@ class DefaultAuthenticatorItemRepository { ), ] } + + /// A publisher for searching a user's items based on the specified search text and filter type. + /// + /// - Parameters: + /// - searchText: The search text to filter the item list. + /// - Returns: A publisher searching for the user's ciphers. + /// + private func searchPublisher( + searchText: String + ) async throws -> AnyPublisher<[AuthenticatorItemView], Error> { + let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + .folding(options: .diacriticInsensitive, locale: .current) + + return try await authenticatorItemService.authenticatorItemsPublisher() + .asyncTryMap { items -> [AuthenticatorItemView] in + var matchedItems: [AuthenticatorItem] = [] + + items.forEach { item in + if item.name.lowercased() + .folding(options: .diacriticInsensitive, locale: nil) + .contains(query) { + matchedItems.append(item) + } + } + + return try await matchedItems.asyncMap { item in + try await self.cryptographyService.decrypt(item) + } + .sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } + }.eraseToAnyPublisher() + } } extension DefaultAuthenticatorItemRepository: AuthenticatorItemRepository { @@ -147,6 +189,8 @@ extension DefaultAuthenticatorItemRepository: AuthenticatorItemRepository { try await authenticatorItemService.updateAuthenticatorItem(item) } + // MARK: Publishers + func authenticatorItemDetailsPublisher( id: String ) async throws -> AsyncThrowingPublisher> { @@ -167,4 +211,16 @@ extension DefaultAuthenticatorItemRepository: AuthenticatorItemRepository { .eraseToAnyPublisher() .values } + + func searchItemListPublisher( + searchText: String + ) async throws -> AsyncThrowingPublisher> { + try await searchPublisher( + searchText: searchText + ).asyncTryMap { items in + items.compactMap(ItemListItem.init) + } + .eraseToAnyPublisher() + .values + } } diff --git a/AuthenticatorShared/Core/Vault/Repositories/TestHelpers/MockAuthenticatorItemRepository.swift b/AuthenticatorShared/Core/Vault/Repositories/TestHelpers/MockAuthenticatorItemRepository.swift index bc3247d3..940214de 100644 --- a/AuthenticatorShared/Core/Vault/Repositories/TestHelpers/MockAuthenticatorItemRepository.swift +++ b/AuthenticatorShared/Core/Vault/Repositories/TestHelpers/MockAuthenticatorItemRepository.swift @@ -25,6 +25,8 @@ class MockAuthenticatorItemRepository: AuthenticatorItemRepository { var authenticatorItemDetailsSubject = CurrentValueSubject(nil) var itemListSubject = CurrentValueSubject<[ItemListSection], Error>([]) + var searchItemListSubject = CurrentValueSubject<[ItemListItem], Error>([]) + var updateAuthenticatorItemItems = [AuthenticatorItemView]() var updateAuthenticatorItemResult: Result = .success(()) @@ -68,4 +70,10 @@ class MockAuthenticatorItemRepository: AuthenticatorItemRepository { updateAuthenticatorItemItems.append(authenticatorItem) try updateAuthenticatorItemResult.get() } + + func searchItemListPublisher( + searchText: String + ) async throws -> AsyncThrowingPublisher> { + searchItemListSubject.eraseToAnyPublisher().values + } } diff --git a/AuthenticatorShared/UI/Platform/Application/AppCoordinator.swift b/AuthenticatorShared/UI/Platform/Application/AppCoordinator.swift index 68c828df..2dcad290 100644 --- a/AuthenticatorShared/UI/Platform/Application/AppCoordinator.swift +++ b/AuthenticatorShared/UI/Platform/Application/AppCoordinator.swift @@ -12,6 +12,7 @@ class AppCoordinator: Coordinator, HasRootNavigator { /// The types of modules used by this coordinator. typealias Module = ItemListModule & TabModule + & TutorialModule // MARK: Private Properties @@ -61,14 +62,14 @@ class AppCoordinator: Coordinator, HasRootNavigator { switch event { case .didStart: showTab(route: .itemList(.list)) -// showItemList(route: .list) + if (!services.stateService.hasSeenWelcomeTutorial) { + showTutorial() + } } } func navigate(to route: AppRoute, context _: AnyObject?) { switch route { - case .onboarding: - break case let .tab(tabRoute): showTab(route: tabRoute) } @@ -81,25 +82,6 @@ class AppCoordinator: Coordinator, HasRootNavigator { // MARK: Private Methods - /// Shows the Item List screen. - /// - /// - Parameter route: The item list route to show. - /// - private func showItemList(route: ItemListRoute) { - if let coordinator = childCoordinator as? AnyCoordinator { - coordinator.navigate(to: route) - } else { - let stackNavigator = UINavigationController() - let coordinator = module.makeItemListCoordinator( - stackNavigator: stackNavigator - ) - coordinator.start() - coordinator.navigate(to: .list) - childCoordinator = coordinator - rootNavigator?.show(child: stackNavigator) - } - } - /// Shows the tab route. /// /// - Parameter route: The tab route to show. @@ -120,4 +102,17 @@ class AppCoordinator: Coordinator, HasRootNavigator { childCoordinator = coordinator } } + + /// Shows the welcome tutorial. + /// + private func showTutorial() { + let navigationController = UINavigationController() + let coordinator = module.makeTutorialCoordinator( + stackNavigator: navigationController + ) + coordinator.start() + + navigationController.modalPresentationStyle = .overFullScreen + rootNavigator?.rootViewController?.present(navigationController, animated: false) + } } diff --git a/AuthenticatorShared/UI/Platform/Application/Support/Images.xcassets/Images/qr_illustration.imageset/Contents.json b/AuthenticatorShared/UI/Platform/Application/Support/Images.xcassets/Images/qr_illustration.imageset/Contents.json new file mode 100644 index 00000000..ac6fe905 --- /dev/null +++ b/AuthenticatorShared/UI/Platform/Application/Support/Images.xcassets/Images/qr_illustration.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "light.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/AuthenticatorShared/UI/Platform/Application/Support/Images.xcassets/Images/qr_illustration.imageset/light.pdf b/AuthenticatorShared/UI/Platform/Application/Support/Images.xcassets/Images/qr_illustration.imageset/light.pdf new file mode 100644 index 00000000..3ca9fe95 Binary files /dev/null and b/AuthenticatorShared/UI/Platform/Application/Support/Images.xcassets/Images/qr_illustration.imageset/light.pdf differ diff --git a/AuthenticatorShared/UI/Platform/Application/Support/Images.xcassets/Images/recovery_codes.imageset/Contents.json b/AuthenticatorShared/UI/Platform/Application/Support/Images.xcassets/Images/recovery_codes.imageset/Contents.json new file mode 100644 index 00000000..dd4f8418 --- /dev/null +++ b/AuthenticatorShared/UI/Platform/Application/Support/Images.xcassets/Images/recovery_codes.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "recovery-codes.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/AuthenticatorShared/UI/Platform/Application/Support/Images.xcassets/Images/recovery_codes.imageset/recovery-codes.pdf b/AuthenticatorShared/UI/Platform/Application/Support/Images.xcassets/Images/recovery_codes.imageset/recovery-codes.pdf new file mode 100644 index 00000000..a47cb69d Binary files /dev/null and b/AuthenticatorShared/UI/Platform/Application/Support/Images.xcassets/Images/recovery_codes.imageset/recovery-codes.pdf differ diff --git a/AuthenticatorShared/UI/Platform/Application/Support/Images.xcassets/Images/unique_codes.imageset/Contents.json b/AuthenticatorShared/UI/Platform/Application/Support/Images.xcassets/Images/unique_codes.imageset/Contents.json new file mode 100644 index 00000000..2185cb3a --- /dev/null +++ b/AuthenticatorShared/UI/Platform/Application/Support/Images.xcassets/Images/unique_codes.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "unique_codes.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/AuthenticatorShared/UI/Platform/Application/Support/Images.xcassets/Images/unique_codes.imageset/unique_codes.pdf b/AuthenticatorShared/UI/Platform/Application/Support/Images.xcassets/Images/unique_codes.imageset/unique_codes.pdf new file mode 100644 index 00000000..8210fae5 Binary files /dev/null and b/AuthenticatorShared/UI/Platform/Application/Support/Images.xcassets/Images/unique_codes.imageset/unique_codes.pdf differ diff --git a/AuthenticatorShared/UI/Platform/Application/Support/Localizations/en.lproj/Localizable.strings b/AuthenticatorShared/UI/Platform/Application/Support/Localizations/en.lproj/Localizable.strings index 223c29d0..bfef61f2 100644 --- a/AuthenticatorShared/UI/Platform/Application/Support/Localizations/en.lproj/Localizable.strings +++ b/AuthenticatorShared/UI/Platform/Application/Support/Localizations/en.lproj/Localizable.strings @@ -890,3 +890,13 @@ "Algorithm" = "Algorithm"; "RefreshPeriod" = "Refresh period"; "NumberOfDigits" = "Number of digits"; +"BitwardenAuthenticator" = "Bitwarden Authenticator"; +"Skip" = "Skip"; +"SecureYourAssetsWithBitwardenAuthenticator" = "Secure your accounts with Bitwarden Authenticator"; +"GetVerificationCodesForAllYourAccounts" = "Get verification codes for all your accounts using 2-step verification."; +"UseYourDeviceCameraToScanCodes" = "Use your device camera to scan codes"; +"ScanTheQRCodeInYourSettings" = "Scan the QR code in your 2-step verification settings for any account."; +"SignInUsingUniqueCodes" = "Sign in using unique codes"; +"WhenUsingTwoStepVerification" = "When using 2-step verification, you’ll enter your username and password and a code generated in this app."; +"GetStarted" = "Get started"; +"LaunchTutorial" = "Launch tutorial"; diff --git a/AuthenticatorShared/UI/Platform/Application/Utilities/AppRoute.swift b/AuthenticatorShared/UI/Platform/Application/Utilities/AppRoute.swift index 0cefbce1..32f45bdd 100644 --- a/AuthenticatorShared/UI/Platform/Application/Utilities/AppRoute.swift +++ b/AuthenticatorShared/UI/Platform/Application/Utilities/AppRoute.swift @@ -1,9 +1,6 @@ /// A top level route from the initial screen of the app to anywhere in the app. /// public enum AppRoute: Equatable { - /// A route to the onboarding experience. - case onboarding - /// A route to the tab interface. case tab(TabRoute) } diff --git a/AuthenticatorShared/UI/Platform/Application/Views/SearchNoResultsView.swift b/AuthenticatorShared/UI/Platform/Application/Views/SearchNoResultsView.swift new file mode 100644 index 00000000..76fe7eb2 --- /dev/null +++ b/AuthenticatorShared/UI/Platform/Application/Views/SearchNoResultsView.swift @@ -0,0 +1,70 @@ +import SwiftUI + +// MARK: - SearchNoResultsView + +/// A view that displays the no search results image and text. +/// +struct SearchNoResultsView: View { + // MARK: Properties + + /// An optional view to display at the top of the scroll view above the no results image and text. + var headerView: Content? + + // MARK: View + + var body: some View { + GeometryReader { reader in + ScrollView { + VStack(spacing: 0) { + if let headerView { + headerView + } + + VStack(spacing: 35) { + Image(decorative: Asset.Images.magnifyingGlass) + .resizable() + .frame(width: 74, height: 74) + .foregroundColor(Asset.Colors.textSecondary.swiftUIColor) + + Text(Localizations.thereAreNoItemsThatMatchTheSearch) + .multilineTextAlignment(.center) + .styleGuide(.callout) + .foregroundColor(Asset.Colors.textPrimary.swiftUIColor) + } + .accessibilityIdentifier("NoSearchResultsLabel") + .frame(maxWidth: .infinity, minHeight: reader.size.height, maxHeight: .infinity) + } + } + } + .background(Color(asset: Asset.Colors.backgroundSecondary)) + } + + // MARK: Initialization + + /// Initialize a `SearchNoResultsView`. + /// + init() where Content == EmptyView { + headerView = nil + } + + /// Initialize a `SearchNoResultsView` with a header view. + /// + /// - Parameter headerView: An optional view to display at the top of the scroll view above the + /// no results image and text. + /// + init(headerView: () -> Content) { + self.headerView = headerView() + } +} + +// MARK: - Previews + +#Preview("No Results") { + SearchNoResultsView() +} + +#Preview("No Results With Header") { + SearchNoResultsView { + Text("Optional header text!") + } +} diff --git a/AuthenticatorShared/UI/Platform/Settings/Settings/About/AboutAction.swift b/AuthenticatorShared/UI/Platform/Settings/Settings/About/AboutAction.swift index 550a3d4c..4ac6c7c7 100644 --- a/AuthenticatorShared/UI/Platform/Settings/Settings/About/AboutAction.swift +++ b/AuthenticatorShared/UI/Platform/Settings/Settings/About/AboutAction.swift @@ -33,6 +33,9 @@ enum AboutAction: Equatable { /// The submit crash logs toggle value changed. case toggleSubmitCrashLogs(Bool) + /// The tutorial button was tapped + case tutorialTapped + /// The version was tapped. case versionTapped diff --git a/AuthenticatorShared/UI/Platform/Settings/Settings/About/AboutProcessor.swift b/AuthenticatorShared/UI/Platform/Settings/Settings/About/AboutProcessor.swift index fe30c3e8..2c13c771 100644 --- a/AuthenticatorShared/UI/Platform/Settings/Settings/About/AboutProcessor.swift +++ b/AuthenticatorShared/UI/Platform/Settings/Settings/About/AboutProcessor.swift @@ -73,6 +73,8 @@ final class AboutProcessor: StateProcessor { case let .toggleSubmitCrashLogs(isOn): state.isSubmitCrashLogsToggleOn = isOn services.errorReporter.isEnabled = isOn + case .tutorialTapped: + coordinator.navigate(to: .tutorial) case .versionTapped: handleVersionTapped() case .webVaultTapped: diff --git a/AuthenticatorShared/UI/Platform/Settings/Settings/About/AboutView.swift b/AuthenticatorShared/UI/Platform/Settings/Settings/About/AboutView.swift index 1e4bb75f..c8d450f3 100644 --- a/AuthenticatorShared/UI/Platform/Settings/Settings/About/AboutView.swift +++ b/AuthenticatorShared/UI/Platform/Settings/Settings/About/AboutView.swift @@ -70,6 +70,10 @@ struct AboutView: View { externalLinkRow(Localizations.giveFeedback, action: .giveFeedbackTapped) + SettingsListItem(Localizations.launchTutorial) { + store.send(.tutorialTapped) + } + SettingsListItem(store.state.version, hasDivider: false) { store.send(.versionTapped) } trailingContent: { diff --git a/AuthenticatorShared/UI/Platform/Settings/SettingsCoordinator.swift b/AuthenticatorShared/UI/Platform/Settings/SettingsCoordinator.swift index 7afedf8c..74587bcd 100644 --- a/AuthenticatorShared/UI/Platform/Settings/SettingsCoordinator.swift +++ b/AuthenticatorShared/UI/Platform/Settings/SettingsCoordinator.swift @@ -9,7 +9,7 @@ final class SettingsCoordinator: Coordinator, HasStackNavigator { // MARK: Types /// The module types required by this coordinator for creating child coordinators. - typealias Module = DefaultAppModule + typealias Module = TutorialModule typealias Services = HasErrorReporter & HasPasteboardService @@ -65,6 +65,8 @@ final class SettingsCoordinator: Coordinator, HasStackNavigator { showSelectLanguage(currentLanguage: currentLanguage, delegate: context as? SelectLanguageDelegate) case .settings: showSettings() + case .tutorial: + showTutorial() } } @@ -128,4 +130,15 @@ final class SettingsCoordinator: Coordinator, HasStackNavigator { let view = SettingsView(store: Store(processor: processor)) stackNavigator?.push(view) } + + /// Shows the welcome tutorial. + /// + private func showTutorial() { + let navigationController = UINavigationController() + let coordinator = module.makeTutorialCoordinator( + stackNavigator: navigationController + ) + coordinator.start() + stackNavigator?.present(navigationController, overFullscreen: true) + } } diff --git a/AuthenticatorShared/UI/Platform/Settings/Settings/SettingsEvent.swift b/AuthenticatorShared/UI/Platform/Settings/SettingsEvent.swift similarity index 100% rename from AuthenticatorShared/UI/Platform/Settings/Settings/SettingsEvent.swift rename to AuthenticatorShared/UI/Platform/Settings/SettingsEvent.swift diff --git a/AuthenticatorShared/UI/Platform/Settings/SettingsRoute.swift b/AuthenticatorShared/UI/Platform/Settings/SettingsRoute.swift index 76a16637..6d69fcd3 100644 --- a/AuthenticatorShared/UI/Platform/Settings/SettingsRoute.swift +++ b/AuthenticatorShared/UI/Platform/Settings/SettingsRoute.swift @@ -21,4 +21,7 @@ public enum SettingsRoute: Equatable, Hashable { /// A route to the settings screen. case settings + + /// A route to show the tutorial. + case tutorial } diff --git a/AuthenticatorShared/UI/Platform/Tutorial/Tutorial/TutorialAction.swift b/AuthenticatorShared/UI/Platform/Tutorial/Tutorial/TutorialAction.swift new file mode 100644 index 00000000..3e76ce3b --- /dev/null +++ b/AuthenticatorShared/UI/Platform/Tutorial/Tutorial/TutorialAction.swift @@ -0,0 +1,14 @@ +// MARK: - TutorialAction + +/// Synchronous actions processed by a `TutorialProcessor`. +/// +enum TutorialAction: Equatable { + /// The user tapped the continue button. + case continueTapped + + /// The user changed the page. + case pageChanged(TutorialPage) + + /// The user tapped the skip button. + case skipTapped +} diff --git a/AuthenticatorShared/UI/Platform/Tutorial/Tutorial/TutorialEffect.swift b/AuthenticatorShared/UI/Platform/Tutorial/Tutorial/TutorialEffect.swift new file mode 100644 index 00000000..3f0a901f --- /dev/null +++ b/AuthenticatorShared/UI/Platform/Tutorial/Tutorial/TutorialEffect.swift @@ -0,0 +1,5 @@ +// MARK: - TutorialEffect + +/// Asynchronous effects processed by a `TutorialProcessor` +/// +enum TutorialEffect: Equatable {} diff --git a/AuthenticatorShared/UI/Platform/Tutorial/Tutorial/TutorialProcessor.swift b/AuthenticatorShared/UI/Platform/Tutorial/Tutorial/TutorialProcessor.swift new file mode 100644 index 00000000..f44e1c34 --- /dev/null +++ b/AuthenticatorShared/UI/Platform/Tutorial/Tutorial/TutorialProcessor.swift @@ -0,0 +1,50 @@ +// MARK: - TutorialProcessor + +/// The processer used to manage state and handle actions for the tutorial screen. +/// +final class TutorialProcessor: StateProcessor { + // MARK: Types + + typealias Services = HasErrorReporter + + // MARK: Private Properties + + /// The `Coordinator` that handles navigation, generally a `TutorialCoordinator`. + private let coordinator: AnyCoordinator + + // MARK: Initialization + + /// Creates a new `TutorialProcessor`. + /// + /// - Parameters: + /// - coordinator: The `Coordinator` that handles navigation. + /// - state: The initial state of the processor. + /// + init( + coordinator: AnyCoordinator, + state: TutorialState + ) { + self.coordinator = coordinator + super.init(state: state) + } + + // MARK: Methods + + override func receive(_ action: TutorialAction) { + switch action { + case .continueTapped: + switch state.page { + case .intro: + state.page = .qrScanner + case .qrScanner: + state.page = .uniqueCodes + case .uniqueCodes: + coordinator.navigate(to: .dismiss) + } + case let .pageChanged(page): + state.page = page + case .skipTapped: + coordinator.navigate(to: .dismiss) + } + } +} diff --git a/AuthenticatorShared/UI/Platform/Tutorial/Tutorial/TutorialState.swift b/AuthenticatorShared/UI/Platform/Tutorial/Tutorial/TutorialState.swift new file mode 100644 index 00000000..27031922 --- /dev/null +++ b/AuthenticatorShared/UI/Platform/Tutorial/Tutorial/TutorialState.swift @@ -0,0 +1,36 @@ +// MARK: - TutorialState + +/// An object that defines the current state of a `TutorialView`. +/// +struct TutorialState: Equatable { + // MARK: Properties + + /// The text to use on the continue button + var continueButtonText: String { + switch page { + case .intro, .qrScanner: + Localizations.continue + case .uniqueCodes: + Localizations.getStarted + } + } + + /// If the current page is the last page + var isLastPage: Bool { + switch page { + case .intro, .qrScanner: + false + case .uniqueCodes: + true + } + } + + /// The current page number + var page: TutorialPage = .intro +} + +enum TutorialPage: Equatable { + case intro + case qrScanner + case uniqueCodes +} diff --git a/AuthenticatorShared/UI/Platform/Tutorial/Tutorial/TutorialView.swift b/AuthenticatorShared/UI/Platform/Tutorial/Tutorial/TutorialView.swift new file mode 100644 index 00000000..d3f0bac3 --- /dev/null +++ b/AuthenticatorShared/UI/Platform/Tutorial/Tutorial/TutorialView.swift @@ -0,0 +1,115 @@ +import SwiftUI + +// MARK: - TutorialView + +/// A view containing the tutorial screens +/// +struct TutorialView: View { + // MARK: Properties + + /// The `Store` for this view. + @ObservedObject var store: Store + + // MARK: View + + var body: some View { + content + .navigationBar(title: Localizations.bitwardenAuthenticator, titleDisplayMode: .inline) + } + + // MARK: Private Properties + + private var content: some View { + VStack(spacing: 24) { + Spacer() + TabView( + selection: store.binding( + get: \.page, + send: TutorialAction.pageChanged + ) + ) { + intoSlide.tag(TutorialPage.intro) + qrScannerSlide.tag(TutorialPage.qrScanner) + uniqueCodesSlide.tag(TutorialPage.uniqueCodes) + } + .tabViewStyle(.page(indexDisplayMode: .always)) + .indexViewStyle(.page(backgroundDisplayMode: .always)) + .padding(.top, 16) + .animation(.default, value: store.state.page) + .transition(.slide) + + Button(store.state.continueButtonText) { + store.send(.continueTapped) + } + .buttonStyle(.primary()) + + Button { + store.send(.skipTapped) + } label: { + Text(Localizations.skip) + .foregroundColor(Asset.Colors.primaryBitwarden.swiftUIColor) + } + .buttonStyle(InlineButtonStyle()) + .hidden(store.state.isLastPage) + } + .padding(16) + .background(Asset.Colors.backgroundSecondary.swiftUIColor.ignoresSafeArea()) + } + + private var intoSlide: some View { + VStack(spacing: 24) { + Asset.Images.recoveryCodes.swiftUIImage + .frame(height: 140) + + Text(Localizations.secureYourAssetsWithBitwardenAuthenticator) + .styleGuide(.title2) + + Text(Localizations.getVerificationCodesForAllYourAccounts) + + Spacer() + } + .multilineTextAlignment(.center) + } + + private var qrScannerSlide: some View { + VStack(spacing: 24) { + Asset.Images.qrIllustration.swiftUIImage + .frame(height: 140) + + Text(Localizations.useYourDeviceCameraToScanCodes) + .styleGuide(.title2) + + Text(Localizations.scanTheQRCodeInYourSettings) + + Spacer() + } + .multilineTextAlignment(.center) + } + + private var uniqueCodesSlide: some View { + VStack(spacing: 24) { + Asset.Images.uniqueCodes.swiftUIImage + .frame(height: 140) + + Text(Localizations.signInUsingUniqueCodes) + .styleGuide(.title2) + + Text(Localizations.whenUsingTwoStepVerification) + + Spacer() + } + .multilineTextAlignment(.center) + } +} + +#Preview("Tutorial") { + NavigationView { + TutorialView( + store: Store( + processor: StateProcessor( + state: TutorialState() + ) + ) + ) + } +} diff --git a/AuthenticatorShared/UI/Platform/Tutorial/TutorialCoordinator.swift b/AuthenticatorShared/UI/Platform/Tutorial/TutorialCoordinator.swift new file mode 100644 index 00000000..80e370c0 --- /dev/null +++ b/AuthenticatorShared/UI/Platform/Tutorial/TutorialCoordinator.swift @@ -0,0 +1,78 @@ +import SwiftUI + +// MARK: - TutorialCoordinator + +/// A coordinator that manages navigation in the tutorial. +/// +final class TutorialCoordinator: Coordinator, HasStackNavigator { + // MARK: Types + + /// The module types required for creating child coordinators. + typealias Module = DefaultAppModule + + typealias Services = HasErrorReporter + & HasStateService + + // MARK: Private Properties + + /// The module used to create child coordinators. + private let module: Module + + /// The services used + private let services: Services + + // MARK: Properties + + /// The stack navigator + private(set) weak var stackNavigator: StackNavigator? + + // Initialization + + /// Creates a new `TutorialCoordinator` + /// + /// - Parameters: + /// - module: The module used to create child coordinators. + /// - services: The services used by this coordinator. + /// - stackNavigator: The stack navigator that is managed by this coordinator. + /// + init( + module: Module, + services: Services, + stackNavigator: StackNavigator + ) { + self.module = module + self.services = services + self.stackNavigator = stackNavigator + } + + // MARK: Methods + + func handleEvent(_ event: TutorialEvent, context: AnyObject?) async {} + + func navigate(to route: TutorialRoute, context: AnyObject?) { + switch route { + case .dismiss: + services.stateService.hasSeenWelcomeTutorial = true + stackNavigator?.dismiss() + case .tutorial: + showTutorial() + } + } + + func start() { + navigate(to: .tutorial) + } + + // MARK: Private Methods + + /// Shows the tutorial. + /// + private func showTutorial() { + let processor = TutorialProcessor( + coordinator: asAnyCoordinator(), + state: TutorialState() + ) + let view = TutorialView(store: Store(processor: processor)) + stackNavigator?.push(view) + } +} diff --git a/AuthenticatorShared/UI/Platform/Tutorial/TutorialEvent.swift b/AuthenticatorShared/UI/Platform/Tutorial/TutorialEvent.swift new file mode 100644 index 00000000..0dbd12f1 --- /dev/null +++ b/AuthenticatorShared/UI/Platform/Tutorial/TutorialEvent.swift @@ -0,0 +1,5 @@ +// MARK: - TutorialEvent + +/// An event handled by the `TutorialCoordinator` +/// +enum TutorialEvent: Equatable {} diff --git a/AuthenticatorShared/UI/Platform/Tutorial/TutorialModule.swift b/AuthenticatorShared/UI/Platform/Tutorial/TutorialModule.swift new file mode 100644 index 00000000..e896baef --- /dev/null +++ b/AuthenticatorShared/UI/Platform/Tutorial/TutorialModule.swift @@ -0,0 +1,26 @@ +// MARK: - TutorialModule + +/// An object that builds tutorial coordinators +/// +@MainActor +protocol TutorialModule { + /// Initializes a coordinator for navigating between `TutorialRoute` objects + /// + /// - Parameters: + /// - stackNavigator: The stack navigator + /// - Returns: A coordinator + /// + func makeTutorialCoordinator( + stackNavigator: StackNavigator + ) -> AnyCoordinator +} + +extension DefaultAppModule: TutorialModule { + func makeTutorialCoordinator(stackNavigator: StackNavigator) -> AnyCoordinator { + TutorialCoordinator( + module: self, + services: services, + stackNavigator: stackNavigator + ).asAnyCoordinator() + } +} diff --git a/AuthenticatorShared/UI/Platform/Tutorial/TutorialRoute.swift b/AuthenticatorShared/UI/Platform/Tutorial/TutorialRoute.swift new file mode 100644 index 00000000..6c24e1ed --- /dev/null +++ b/AuthenticatorShared/UI/Platform/Tutorial/TutorialRoute.swift @@ -0,0 +1,11 @@ +import Foundation + +/// A route to a specific screen in the tutorial. +/// +public enum TutorialRoute: Equatable, Hashable { + /// A route that dismisses the tutorial. + case dismiss + + /// A route to the tutorial. + case tutorial +} diff --git a/AuthenticatorShared/UI/Vault/ItemList/ItemList/ItemListAction.swift b/AuthenticatorShared/UI/Vault/ItemList/ItemList/ItemListAction.swift index d44a153e..cfa7d441 100644 --- a/AuthenticatorShared/UI/Vault/ItemList/ItemList/ItemListAction.swift +++ b/AuthenticatorShared/UI/Vault/ItemList/ItemList/ItemListAction.swift @@ -23,6 +23,12 @@ enum ItemListAction: Equatable { /// case itemPressed(_ item: ItemListItem) + /// The user has started or stopped searching. + case searchStateChanged(isSearching: Bool) + + /// The text in the search bar was changed. + case searchTextChanged(String) + /// The toast was shown or hidden. case toastShown(Toast?) } diff --git a/AuthenticatorShared/UI/Vault/ItemList/ItemList/ItemListEffect.swift b/AuthenticatorShared/UI/Vault/ItemList/ItemList/ItemListEffect.swift index 58159b00..2f7be7af 100644 --- a/AuthenticatorShared/UI/Vault/ItemList/ItemList/ItemListEffect.swift +++ b/AuthenticatorShared/UI/Vault/ItemList/ItemList/ItemListEffect.swift @@ -22,6 +22,9 @@ enum ItemListEffect: Equatable { /// The refresh control was triggered. case refresh + /// Searches based on the keyword. + case search(String) + /// Stream the vault list for the user. case streamItemList } diff --git a/AuthenticatorShared/UI/Vault/ItemList/ItemList/ItemListProcessor.swift b/AuthenticatorShared/UI/Vault/ItemList/ItemList/ItemListProcessor.swift index a3b99472..152590d4 100644 --- a/AuthenticatorShared/UI/Vault/ItemList/ItemList/ItemListProcessor.swift +++ b/AuthenticatorShared/UI/Vault/ItemList/ItemList/ItemListProcessor.swift @@ -73,6 +73,8 @@ final class ItemListProcessor: StateProcessor [ItemListItem] { + guard !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return [] + } + do { + let result = try await services.authenticatorItemRepository.searchItemListPublisher( + searchText: searchText + ) + for try await items in result { + return items + } + } catch { + services.errorReporter.log(error: error) + } + return [] + } + /// Stream the items list. private func streamItemList() async { do { diff --git a/AuthenticatorShared/UI/Vault/ItemList/ItemList/ItemListState.swift b/AuthenticatorShared/UI/Vault/ItemList/ItemList/ItemListState.swift index a408b4a3..d15434b8 100644 --- a/AuthenticatorShared/UI/Vault/ItemList/ItemList/ItemListState.swift +++ b/AuthenticatorShared/UI/Vault/ItemList/ItemList/ItemListState.swift @@ -17,6 +17,12 @@ struct ItemListState: Equatable { /// The current loading state. var loadingState: LoadingState<[ItemListItem]> = .loading(nil) + /// An array of results matching the `searchText`. + var searchResults = [ItemListItem]() + + /// The text that the user is currently searching for. + var searchText = "" + /// Whether to show the add item button in the view. var showAddItemButton: Bool { // Don't show if there is data. diff --git a/AuthenticatorShared/UI/Vault/ItemList/ItemList/ItemListView.swift b/AuthenticatorShared/UI/Vault/ItemList/ItemList/ItemListView.swift index dd39fa75..7cd5a7de 100644 --- a/AuthenticatorShared/UI/Vault/ItemList/ItemList/ItemListView.swift +++ b/AuthenticatorShared/UI/Vault/ItemList/ItemList/ItemListView.swift @@ -1,11 +1,16 @@ +// swiftlint:disable file_length + import SwiftUI -// MARK: - ItemListView +// MARK: - SearchableItemListView /// A view that displays the items in a single vault group. -struct ItemListView: View { +private struct SearchableItemListView: View { // MARK: Properties + /// A flag indicating if the search bar is focused. + @Environment(\.isSearching) private var isSearching + /// An object used to open urls from this view. @Environment(\.openURL) private var openURL @@ -18,24 +23,42 @@ struct ItemListView: View { // MARK: View var body: some View { - content - .navigationTitle(Localizations.verificationCodes) - .navigationBarTitleDisplayMode(.inline) - .background(Asset.Colors.backgroundSecondary.swiftUIColor.ignoresSafeArea()) - .toolbar { - addToolbarItem(hidden: !store.state.showAddToolbarItem) { - Task { - await store.perform(.addItemPressed) - } - } - } - .task { - await store.perform(.appeared) - } - .toast(store.binding( - get: \.toast, - send: ItemListAction.toastShown - )) + // A ZStack with hidden children is used here so that opening and closing the + // search interface does not reset the scroll position for the main vault + // view, as would happen if we used an `if else` block here. + // + // Additionally, we cannot use an `.overlay()` on the main vault view to contain + // the search interface since VoiceOver still reads the elements below the overlay, + // which is not ideal. + + ZStack { + let isSearching = isSearching + || !store.state.searchText.isEmpty + || !store.state.searchResults.isEmpty + + content + .hidden(isSearching) + + search + .hidden(!isSearching) + } + .background(Asset.Colors.backgroundSecondary.swiftUIColor.ignoresSafeArea()) + .toast(store.binding( + get: \.toast, + send: ItemListAction.toastShown + )) + .onChange(of: isSearching) { newValue in + store.send(.searchStateChanged(isSearching: newValue)) + } + .toast(store.binding( + get: \.toast, + send: ItemListAction.toastShown + )) + .animation(.default, value: isSearching) + .toast(store.binding( + get: \.toast, + send: ItemListAction.toastShown + )) } // MARK: Private @@ -70,10 +93,8 @@ struct ItemListView: View { .foregroundColor(Asset.Colors.textPrimary.swiftUIColor) if store.state.showAddItemButton { - Button(Localizations.addCode) { - Task { - await store.perform(.addItemPressed) - } + AsyncButton(Localizations.addCode) { + await store.perform(.addItemPressed) } .buttonStyle(.primary()) } @@ -86,6 +107,31 @@ struct ItemListView: View { } } + /// A view that displays the search interface, including search results, an empty search + /// interface, and a message indicating that no results were found. + @ViewBuilder private var search: some View { + if store.state.searchText.isEmpty || !store.state.searchResults.isEmpty { + ScrollView { + LazyVStack(spacing: 0) { + ForEach(store.state.searchResults) { item in + Button { + store.send(.itemPressed(item)) + } label: { + vaultItemRow( + for: item, + isLastInSection: store.state.searchResults.last == item + ) + .background(Asset.Colors.backgroundPrimary.swiftUIColor) + } + .accessibilityIdentifier("ItemCell") + } + } + } + } else { + SearchNoResultsView() + } + } + // MARK: Private Methods /// A view that displays a list of the sections within this vault group. @@ -182,47 +228,178 @@ struct ItemListView: View { } } +// MARK: - ItemListView + +/// The main view of the item list +struct ItemListView: View { + // MARK: Properties + + /// The `Store` for this view. + @ObservedObject var store: Store + + /// The `TimeProvider` used to calculate TOTP expiration. + var timeProvider: any TimeProvider + + var body: some View { + ZStack { + SearchableItemListView( + store: store, + timeProvider: timeProvider + ) + .searchable( + text: store.binding( + get: \.searchText, + send: ItemListAction.searchTextChanged + ), + placement: .navigationBarDrawer(displayMode: .always), + prompt: Localizations.search + ) + .task(id: store.state.searchText) { + await store.perform(.search(store.state.searchText)) + } + .refreshable { + await store.perform(.refresh) + } + } + .navigationTitle(Localizations.verificationCodes) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + addToolbarItem(hidden: !store.state.showAddToolbarItem) { + Task { + await store.perform(.addItemPressed) + } + } + } + .task { + await store.perform(.appeared) + } + } +} + // MARK: Previews #if DEBUG -#Preview("Loading") { - NavigationView { - ItemListView( - store: Store( - processor: StateProcessor( - state: ItemListState( - loadingState: .loading(nil) +struct ItemListView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + ItemListView( + store: Store( + processor: StateProcessor( + state: ItemListState( + loadingState: .loading(nil) + ) ) - ) - ), - timeProvider: PreviewTimeProvider() - ) - } -} + ), + timeProvider: PreviewTimeProvider() + ) + }.previewDisplayName("Loading") -#Preview("Empty") { - NavigationView { - ItemListView( - store: Store( - processor: StateProcessor( - state: ItemListState( - loadingState: .data([]) + NavigationView { + ItemListView( + store: Store( + processor: StateProcessor( + state: ItemListState( + loadingState: .data([]) + ) ) - ) - ), - timeProvider: PreviewTimeProvider() - ) - } -} + ), + timeProvider: PreviewTimeProvider() + ) + }.previewDisplayName("Empty") + + NavigationView { + ItemListView( + store: Store( + processor: StateProcessor( + state: ItemListState( + loadingState: .data( + [ + ItemListItem( + id: "One", + name: "One", + itemType: .totp( + model: ItemListTotpItem( + itemView: AuthenticatorItemView.fixture(), + totpCode: TOTPCodeModel( + code: "123456", + codeGenerationDate: Date(), + period: 30 + ) + ) + ) + ), + ItemListItem( + id: "Two", + name: "Two", + itemType: .totp( + model: ItemListTotpItem( + itemView: AuthenticatorItemView.fixture(), + totpCode: TOTPCodeModel( + code: "123456", + codeGenerationDate: Date(), + period: 30 + ) + ) + ) + ), + ] + ) + ) + ) + ), + timeProvider: PreviewTimeProvider() + ) + }.previewDisplayName("Items") + + NavigationView { + ItemListView( + store: Store( + processor: StateProcessor( + state: ItemListState( + searchResults: [], + searchText: "Example" + ) + ) + ), + timeProvider: PreviewTimeProvider() + ) + }.previewDisplayName("0 Search Results") + + NavigationView { + ItemListView( + store: Store( + processor: StateProcessor( + state: ItemListState( + searchResults: [ + ItemListItem( + id: "One", + name: "One", + itemType: .totp( + model: ItemListTotpItem( + itemView: AuthenticatorItemView.fixture(), + totpCode: TOTPCodeModel( + code: "123456", + codeGenerationDate: Date(), + period: 30 + ) + ) + ) + ), + ], + searchText: "One" + ) + ) + ), + timeProvider: PreviewTimeProvider() + ) + }.previewDisplayName("1 Search Result") -#Preview("Items") { - NavigationView { - ItemListView( - store: Store( - processor: StateProcessor( - state: ItemListState( - loadingState: .data( - [ + NavigationView { + ItemListView( + store: Store( + processor: StateProcessor( + state: ItemListState( + searchResults: [ ItemListItem( id: "One", name: "One", @@ -239,7 +416,21 @@ struct ItemListView: View { ), ItemListItem( id: "Two", - name: "Two", + name: "One Direction", + itemType: .totp( + model: ItemListTotpItem( + itemView: AuthenticatorItemView.fixture(), + totpCode: TOTPCodeModel( + code: "123456", + codeGenerationDate: Date(), + period: 30 + ) + ) + ) + ), + ItemListItem( + id: "Three", + name: "One Song", itemType: .totp( model: ItemListTotpItem( itemView: AuthenticatorItemView.fixture(), @@ -251,13 +442,14 @@ struct ItemListView: View { ) ) ), - ] + ], + searchText: "One" ) ) - ) - ), - timeProvider: PreviewTimeProvider() - ) + ), + timeProvider: PreviewTimeProvider() + ) + }.previewDisplayName("3 Search Results") } } #endif diff --git a/AuthenticatorShared/UI/Vault/ItemList/ItemList/ItemListViewTests.swift b/AuthenticatorShared/UI/Vault/ItemList/ItemList/ItemListViewTests.swift new file mode 100644 index 00000000..5387a558 --- /dev/null +++ b/AuthenticatorShared/UI/Vault/ItemList/ItemList/ItemListViewTests.swift @@ -0,0 +1,50 @@ +import SnapshotTesting +import SwiftUI +import ViewInspector +import XCTest + +@testable import AuthenticatorShared + +// MARK: - ItemListViewTests + +class ItemListViewTests: AuthenticatorTestCase { + // MARK: Properties + + var processor: MockProcessor! + var subject: ItemListView! + var timeProvider: MockTimeProvider! + + // MARK: Setup & Teardown + + override func setUp() { + super.setUp() + + let state = ItemListState() + processor = MockProcessor(state: state) + timeProvider = MockTimeProvider(.mockTime(Date(year: 2023, month: 12, day: 31))) + subject = ItemListView( + store: Store(processor: processor), + timeProvider: timeProvider + ) + } + + override func tearDown() { + super.tearDown() + + processor = nil + subject = nil + timeProvider = nil + } + + // MARK: Tests + + /// Test a snapshot of the ItemListView previews. + func test_snapshot_ItemListView_previews() { + for preview in ItemListView_Previews._allPreviews { + assertSnapshots( + matching: preview.content, + as: [.defaultPortrait] + ) + } + } +} diff --git a/AuthenticatorShared/UI/Vault/ItemList/ItemList/__Snapshots__/ItemListViewTests/test_snapshot_ItemListView_previews.1.png b/AuthenticatorShared/UI/Vault/ItemList/ItemList/__Snapshots__/ItemListViewTests/test_snapshot_ItemListView_previews.1.png new file mode 100644 index 00000000..9e300701 Binary files /dev/null and b/AuthenticatorShared/UI/Vault/ItemList/ItemList/__Snapshots__/ItemListViewTests/test_snapshot_ItemListView_previews.1.png differ diff --git a/AuthenticatorShared/UI/Vault/ItemList/ItemList/__Snapshots__/ItemListViewTests/test_snapshot_ItemListView_previews.2.png b/AuthenticatorShared/UI/Vault/ItemList/ItemList/__Snapshots__/ItemListViewTests/test_snapshot_ItemListView_previews.2.png new file mode 100644 index 00000000..1866271c Binary files /dev/null and b/AuthenticatorShared/UI/Vault/ItemList/ItemList/__Snapshots__/ItemListViewTests/test_snapshot_ItemListView_previews.2.png differ diff --git a/AuthenticatorShared/UI/Vault/ItemList/ItemList/__Snapshots__/ItemListViewTests/test_snapshot_ItemListView_previews.3.png b/AuthenticatorShared/UI/Vault/ItemList/ItemList/__Snapshots__/ItemListViewTests/test_snapshot_ItemListView_previews.3.png new file mode 100644 index 00000000..a76f418f Binary files /dev/null and b/AuthenticatorShared/UI/Vault/ItemList/ItemList/__Snapshots__/ItemListViewTests/test_snapshot_ItemListView_previews.3.png differ diff --git a/AuthenticatorShared/UI/Vault/ItemList/ItemList/__Snapshots__/ItemListViewTests/test_snapshot_ItemListView_previews.4.png b/AuthenticatorShared/UI/Vault/ItemList/ItemList/__Snapshots__/ItemListViewTests/test_snapshot_ItemListView_previews.4.png new file mode 100644 index 00000000..91d1ee97 Binary files /dev/null and b/AuthenticatorShared/UI/Vault/ItemList/ItemList/__Snapshots__/ItemListViewTests/test_snapshot_ItemListView_previews.4.png differ diff --git a/AuthenticatorShared/UI/Vault/ItemList/ItemList/__Snapshots__/ItemListViewTests/test_snapshot_ItemListView_previews.5.png b/AuthenticatorShared/UI/Vault/ItemList/ItemList/__Snapshots__/ItemListViewTests/test_snapshot_ItemListView_previews.5.png new file mode 100644 index 00000000..9f0a6f36 Binary files /dev/null and b/AuthenticatorShared/UI/Vault/ItemList/ItemList/__Snapshots__/ItemListViewTests/test_snapshot_ItemListView_previews.5.png differ diff --git a/AuthenticatorShared/UI/Vault/ItemList/ItemList/__Snapshots__/ItemListViewTests/test_snapshot_ItemListView_previews.6.png b/AuthenticatorShared/UI/Vault/ItemList/ItemList/__Snapshots__/ItemListViewTests/test_snapshot_ItemListView_previews.6.png new file mode 100644 index 00000000..f82b8290 Binary files /dev/null and b/AuthenticatorShared/UI/Vault/ItemList/ItemList/__Snapshots__/ItemListViewTests/test_snapshot_ItemListView_previews.6.png differ diff --git a/Mintfile b/Mintfile index 24a7ef09..dbd12f9c 100644 --- a/Mintfile +++ b/Mintfile @@ -1,5 +1,5 @@ mono0926/LicensePlist@3.25.1 -nicklockwood/SwiftFormat@0.53.6 +nicklockwood/SwiftFormat@0.53.7 SwiftGen/SwiftGen@6.6.3 realm/SwiftLint@0.54.0 yonaskolb/xcodegen@2.40.1