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/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/Vault/ItemList/ItemList/ItemListAction.swift b/AuthenticatorShared/UI/Vault/ItemList/ItemList/ItemListAction.swift index e757e030..b6c44285 100644 --- a/AuthenticatorShared/UI/Vault/ItemList/ItemList/ItemListAction.swift +++ b/AuthenticatorShared/UI/Vault/ItemList/ItemList/ItemListAction.swift @@ -15,6 +15,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 b0228f0c..e4d6f2b3 100644 --- a/AuthenticatorShared/UI/Vault/ItemList/ItemList/ItemListEffect.swift +++ b/AuthenticatorShared/UI/Vault/ItemList/ItemList/ItemListEffect.swift @@ -18,6 +18,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 e4f20769..49316594 100644 --- a/AuthenticatorShared/UI/Vault/ItemList/ItemList/ItemListProcessor.swift +++ b/AuthenticatorShared/UI/Vault/ItemList/ItemList/ItemListProcessor.swift @@ -65,6 +65,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 2cbc02cc..0a716991 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. @@ -147,47 +193,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") -#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", + itemType: .totp( + model: ItemListTotpItem( + itemView: AuthenticatorItemView.fixture(), + totpCode: TOTPCodeModel( + code: "123456", + codeGenerationDate: Date(), + period: 30 + ) + ) + ) + ), + ], + searchText: "One" + ) + ) + ), + timeProvider: PreviewTimeProvider() + ) + }.previewDisplayName("1 Search Result") + + NavigationView { + ItemListView( + store: Store( + processor: StateProcessor( + state: ItemListState( + searchResults: [ ItemListItem( id: "One", name: "One", @@ -204,7 +381,7 @@ struct ItemListView: View { ), ItemListItem( id: "Two", - name: "Two", + name: "One Direction", itemType: .totp( model: ItemListTotpItem( itemView: AuthenticatorItemView.fixture(), @@ -216,13 +393,28 @@ struct ItemListView: View { ) ) ), - ] + ItemListItem( + id: "Three", + name: "One Song", + itemType: .totp( + model: ItemListTotpItem( + itemView: AuthenticatorItemView.fixture(), + totpCode: TOTPCodeModel( + code: "123456", + codeGenerationDate: Date(), + period: 30 + ) + ) + ) + ), + ], + 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