diff --git a/Vault/Sources/VaultFeed/Storage/VaultDataModel.swift b/Vault/Sources/VaultFeed/Storage/VaultDataModel.swift index 2800e58dd..6475d2bf8 100644 --- a/Vault/Sources/VaultFeed/Storage/VaultDataModel.swift +++ b/Vault/Sources/VaultFeed/Storage/VaultDataModel.swift @@ -42,6 +42,15 @@ public final class VaultDataModel { } } + /// Pluralized count of the items the feed is currently showing. + /// + /// Counts only what the feed displays: items hidden behind a search + /// passphrase are excluded by the store query and must stay that way, so + /// this never reveals that hidden items exist. + public var itemsCountDescription: String { + localized(key: "feedViewModel.searching.title.\(items.count)") + } + public var filteringByTagsDescription: String { localized(key: "feed.searching.filteringByTags.\(itemsFilteringByTags.count)") } diff --git a/Vault/Sources/VaultiOS/Views/Feed/VaultItemFeedView.swift b/Vault/Sources/VaultiOS/Views/Feed/VaultItemFeedView.swift index 92b799deb..bc8856f30 100644 --- a/Vault/Sources/VaultiOS/Views/Feed/VaultItemFeedView.swift +++ b/Vault/Sources/VaultiOS/Views/Feed/VaultItemFeedView.swift @@ -100,21 +100,22 @@ public struct VaultItemFeedView< ScrollView(.horizontal, showsIndicators: false) { HStack { ForEach(dataModel.allTags) { tag in + // `TagPillView` draws its own capsule — filled when + // selected, outlined when not — which reads far more + // clearly than tinting a bordered button both ways. + // The toggle keeps the button trait and selected state + // that a bare tap gesture would not expose. Toggle(isOn: filterBinding(for: tag)) { - Label { - Text(tag.name.isBlank ? "Tag" : tag.name) - } icon: { - TagIconView(iconName: tag.iconName) - } + TagPillView( + tag: tag, + isSelected: dataModel.itemsFilteringByTags.contains(tag.id), + ) } .id(tag) - .tint(tag.color.color) } } .toggleStyle(.button) - .buttonStyle(.bordered) - .buttonBorderShape(.capsule) - .controlSize(.small) + .buttonStyle(.plain) .font(.footnote) .padding(.horizontal) } @@ -161,6 +162,12 @@ public struct VaultItemFeedView< } } .frame(minHeight: 44) + // The status row carries its own surface; the tag pills above stay + // outside it, sitting directly on the content. + .padding(.vertical, 8) + .padding(.horizontal, 12) + .background(Color.primary.opacity(0.05)) + .clipShape(RoundedRectangle(cornerRadius: 12)) .transition(.move(edge: .bottom).combined(with: .opacity)) } @@ -173,25 +180,42 @@ public struct VaultItemFeedView< ) .lineLimit(1) } else { - let count = dataModel.items.count - let itemText = count == 1 ? "item" : "items" - let filterCount = dataModel.itemsFilteringByTags.count - HStack(spacing: 4) { Image(systemName: "key.horizontal") - Text("\(count) \(itemText)") + Text(dataModel.itemsCountDescription) - if filterCount > 0 { + if let filterDescription { Text("•") Image(systemName: "tag.fill") .font(.caption) - Text("\(filterCount)") + Text(filterDescription) } } .lineLimit(1) } } + /// Describes the active tag filters, or `nil` when none are applied. + /// + /// The filter pills scroll horizontally, so an active tag can sit + /// off-screen; naming it keeps that state visible. Only a single name + /// fits beside the item count and the Clear/Edit buttons, so past one + /// filter this falls back to the bare count. + private var filterDescription: String? { + let activeIDs = dataModel.itemsFilteringByTags + guard activeIDs.isNotEmpty else { return nil } + guard activeIDs.count == 1, let activeID = activeIDs.first else { + return "\(activeIDs.count)" + } + + // A filter whose tag is no longer in `allTags` has no name to show. + guard let tag = dataModel.allTags.first(where: { $0.id == activeID }) else { + return "\(activeIDs.count)" + } + + return tag.name.isBlank ? "Tag" : tag.name + } + private func filterBinding(for tag: VaultItemTag) -> Binding { Binding { dataModel.itemsFilteringByTags.contains(tag.id) diff --git a/Vault/Tests/VaultiOSAutofillTests/__Snapshots__/VaultAutofillCodeSelectorViewSnapshotTests/layout.1.png b/Vault/Tests/VaultiOSAutofillTests/__Snapshots__/VaultAutofillCodeSelectorViewSnapshotTests/layout.1.png index 5de347746..976fb687e 100644 Binary files a/Vault/Tests/VaultiOSAutofillTests/__Snapshots__/VaultAutofillCodeSelectorViewSnapshotTests/layout.1.png and b/Vault/Tests/VaultiOSAutofillTests/__Snapshots__/VaultAutofillCodeSelectorViewSnapshotTests/layout.1.png differ diff --git a/Vault/Tests/VaultiOSTests/VaultItemFeedViewSnapshotTests.swift b/Vault/Tests/VaultiOSTests/VaultItemFeedViewSnapshotTests.swift index 089d3fe29..c4eda88dd 100644 --- a/Vault/Tests/VaultiOSTests/VaultItemFeedViewSnapshotTests.swift +++ b/Vault/Tests/VaultiOSTests/VaultItemFeedViewSnapshotTests.swift @@ -243,6 +243,64 @@ final class VaultItemFeedViewSnapshotTests { assertSnapshot(of: sut, as: .image) } + /// A single active filter is named, so a tag scrolled out of the pill row + /// is still identifiable from the status label. + @Test + func unifiedBar_singleFilterIsNamed() async { + let store = VaultStoreStub() + let tagStore = VaultTagStoreStub() + let tag1Id = Identifier() + tagStore.retrieveTagsHandler = { + [ + VaultItemTag(id: tag1Id, name: "work"), + VaultItemTag(id: .init(), name: "personal", color: .tagDefault), + VaultItemTag(id: .init(), name: "archive", color: .gray), + ] + } + store.retrieveHandler = { _ in + .init(items: [uniqueVaultItem(), uniqueVaultItem()]) + } + let dataModel = anyVaultDataModel(vaultStore: store, vaultTagStore: tagStore) + await dataModel.reloadData() + + let sut = makeSUT(dataModel: dataModel) + .framedForTest() + + dataModel.itemsFilteringByTags = [tag1Id] + + assertSnapshot(of: sut, as: .image) + } + + /// Past one active filter the names would only truncate, so the label + /// falls back to the pluralized count. + @Test + func unifiedBar_multipleFiltersFallBackToCount() async { + let store = VaultStoreStub() + let tagStore = VaultTagStoreStub() + let tag1Id = Identifier() + let tag2Id = Identifier() + let tag3Id = Identifier() + tagStore.retrieveTagsHandler = { + [ + VaultItemTag(id: tag1Id, name: "work"), + VaultItemTag(id: tag2Id, name: "personal", color: .tagDefault), + VaultItemTag(id: tag3Id, name: "archive", color: .gray), + ] + } + store.retrieveHandler = { _ in + .init(items: [uniqueVaultItem(), uniqueVaultItem()]) + } + let dataModel = anyVaultDataModel(vaultStore: store, vaultTagStore: tagStore) + await dataModel.reloadData() + + let sut = makeSUT(dataModel: dataModel) + .framedForTest() + + dataModel.itemsFilteringByTags = [tag1Id, tag2Id, tag3Id] + + assertSnapshot(of: sut, as: .image) + } + @Test func unifiedBar_narrowWidth_buttonsDoNotWrap() async { let store = VaultStoreStub() diff --git a/Vault/Tests/VaultiOSTests/__Snapshots__/VaultItemFeedViewSnapshotTests/layout_multipleCodesAtMediumSize.1.png b/Vault/Tests/VaultiOSTests/__Snapshots__/VaultItemFeedViewSnapshotTests/layout_multipleCodesAtMediumSize.1.png index 3bafea35d..520ff3333 100644 Binary files a/Vault/Tests/VaultiOSTests/__Snapshots__/VaultItemFeedViewSnapshotTests/layout_multipleCodesAtMediumSize.1.png and b/Vault/Tests/VaultiOSTests/__Snapshots__/VaultItemFeedViewSnapshotTests/layout_multipleCodesAtMediumSize.1.png differ diff --git a/Vault/Tests/VaultiOSTests/__Snapshots__/VaultItemFeedViewSnapshotTests/layout_noCodes.1.png b/Vault/Tests/VaultiOSTests/__Snapshots__/VaultItemFeedViewSnapshotTests/layout_noCodes.1.png index 3b80c1b7c..873301050 100644 Binary files a/Vault/Tests/VaultiOSTests/__Snapshots__/VaultItemFeedViewSnapshotTests/layout_noCodes.1.png and b/Vault/Tests/VaultiOSTests/__Snapshots__/VaultItemFeedViewSnapshotTests/layout_noCodes.1.png differ diff --git a/Vault/Tests/VaultiOSTests/__Snapshots__/VaultItemFeedViewSnapshotTests/layout_singleCodeAtMediumSize.1.png b/Vault/Tests/VaultiOSTests/__Snapshots__/VaultItemFeedViewSnapshotTests/layout_singleCodeAtMediumSize.1.png index c9ac66beb..6f5e83e08 100644 Binary files a/Vault/Tests/VaultiOSTests/__Snapshots__/VaultItemFeedViewSnapshotTests/layout_singleCodeAtMediumSize.1.png and b/Vault/Tests/VaultiOSTests/__Snapshots__/VaultItemFeedViewSnapshotTests/layout_singleCodeAtMediumSize.1.png differ diff --git a/Vault/Tests/VaultiOSTests/__Snapshots__/VaultItemFeedViewSnapshotTests/searchBar_includesTagsIfTheyExistInTheVaultStore.1.png b/Vault/Tests/VaultiOSTests/__Snapshots__/VaultItemFeedViewSnapshotTests/searchBar_includesTagsIfTheyExistInTheVaultStore.1.png index 5df3718e4..b0e60a88c 100644 Binary files a/Vault/Tests/VaultiOSTests/__Snapshots__/VaultItemFeedViewSnapshotTests/searchBar_includesTagsIfTheyExistInTheVaultStore.1.png and b/Vault/Tests/VaultiOSTests/__Snapshots__/VaultItemFeedViewSnapshotTests/searchBar_includesTagsIfTheyExistInTheVaultStore.1.png differ diff --git a/Vault/Tests/VaultiOSTests/__Snapshots__/VaultItemFeedViewSnapshotTests/searchBar_tagsBeingFiltered.1.png b/Vault/Tests/VaultiOSTests/__Snapshots__/VaultItemFeedViewSnapshotTests/searchBar_tagsBeingFiltered.1.png index a37c4a13b..ed6bdb040 100644 Binary files a/Vault/Tests/VaultiOSTests/__Snapshots__/VaultItemFeedViewSnapshotTests/searchBar_tagsBeingFiltered.1.png and b/Vault/Tests/VaultiOSTests/__Snapshots__/VaultItemFeedViewSnapshotTests/searchBar_tagsBeingFiltered.1.png differ diff --git a/Vault/Tests/VaultiOSTests/__Snapshots__/VaultItemFeedViewSnapshotTests/unifiedBar_clearButtonVisible.1.png b/Vault/Tests/VaultiOSTests/__Snapshots__/VaultItemFeedViewSnapshotTests/unifiedBar_clearButtonVisible.1.png index 64aa40b27..7f36c1ec1 100644 Binary files a/Vault/Tests/VaultiOSTests/__Snapshots__/VaultItemFeedViewSnapshotTests/unifiedBar_clearButtonVisible.1.png and b/Vault/Tests/VaultiOSTests/__Snapshots__/VaultItemFeedViewSnapshotTests/unifiedBar_clearButtonVisible.1.png differ diff --git a/Vault/Tests/VaultiOSTests/__Snapshots__/VaultItemFeedViewSnapshotTests/unifiedBar_multipleFiltersFallBackToCount.1.png b/Vault/Tests/VaultiOSTests/__Snapshots__/VaultItemFeedViewSnapshotTests/unifiedBar_multipleFiltersFallBackToCount.1.png new file mode 100644 index 000000000..bcd68f5ea Binary files /dev/null and b/Vault/Tests/VaultiOSTests/__Snapshots__/VaultItemFeedViewSnapshotTests/unifiedBar_multipleFiltersFallBackToCount.1.png differ diff --git a/Vault/Tests/VaultiOSTests/__Snapshots__/VaultItemFeedViewSnapshotTests/unifiedBar_multipleTagsFiltered.1.png b/Vault/Tests/VaultiOSTests/__Snapshots__/VaultItemFeedViewSnapshotTests/unifiedBar_multipleTagsFiltered.1.png index 3dde4147e..57a771a72 100644 Binary files a/Vault/Tests/VaultiOSTests/__Snapshots__/VaultItemFeedViewSnapshotTests/unifiedBar_multipleTagsFiltered.1.png and b/Vault/Tests/VaultiOSTests/__Snapshots__/VaultItemFeedViewSnapshotTests/unifiedBar_multipleTagsFiltered.1.png differ diff --git a/Vault/Tests/VaultiOSTests/__Snapshots__/VaultItemFeedViewSnapshotTests/unifiedBar_narrowWidth_buttonsDoNotWrap.1.png b/Vault/Tests/VaultiOSTests/__Snapshots__/VaultItemFeedViewSnapshotTests/unifiedBar_narrowWidth_buttonsDoNotWrap.1.png index 86f9c9c48..49dfcf697 100644 Binary files a/Vault/Tests/VaultiOSTests/__Snapshots__/VaultItemFeedViewSnapshotTests/unifiedBar_narrowWidth_buttonsDoNotWrap.1.png and b/Vault/Tests/VaultiOSTests/__Snapshots__/VaultItemFeedViewSnapshotTests/unifiedBar_narrowWidth_buttonsDoNotWrap.1.png differ diff --git a/Vault/Tests/VaultiOSTests/__Snapshots__/VaultItemFeedViewSnapshotTests/unifiedBar_searchingWithNoResults.1.png b/Vault/Tests/VaultiOSTests/__Snapshots__/VaultItemFeedViewSnapshotTests/unifiedBar_searchingWithNoResults.1.png index 3b80c1b7c..873301050 100644 Binary files a/Vault/Tests/VaultiOSTests/__Snapshots__/VaultItemFeedViewSnapshotTests/unifiedBar_searchingWithNoResults.1.png and b/Vault/Tests/VaultiOSTests/__Snapshots__/VaultItemFeedViewSnapshotTests/unifiedBar_searchingWithNoResults.1.png differ diff --git a/Vault/Tests/VaultiOSTests/__Snapshots__/VaultItemFeedViewSnapshotTests/unifiedBar_searchingWithResults.1.png b/Vault/Tests/VaultiOSTests/__Snapshots__/VaultItemFeedViewSnapshotTests/unifiedBar_searchingWithResults.1.png index 7ebd5cec3..a46935f97 100644 Binary files a/Vault/Tests/VaultiOSTests/__Snapshots__/VaultItemFeedViewSnapshotTests/unifiedBar_searchingWithResults.1.png and b/Vault/Tests/VaultiOSTests/__Snapshots__/VaultItemFeedViewSnapshotTests/unifiedBar_searchingWithResults.1.png differ diff --git a/Vault/Tests/VaultiOSTests/__Snapshots__/VaultItemFeedViewSnapshotTests/unifiedBar_singleFilterIsNamed.1.png b/Vault/Tests/VaultiOSTests/__Snapshots__/VaultItemFeedViewSnapshotTests/unifiedBar_singleFilterIsNamed.1.png new file mode 100644 index 000000000..4afad1e55 Binary files /dev/null and b/Vault/Tests/VaultiOSTests/__Snapshots__/VaultItemFeedViewSnapshotTests/unifiedBar_singleFilterIsNamed.1.png differ diff --git a/Vault/Tests/VaultiOSTests/__Snapshots__/VaultItemFeedViewSnapshotTests/unifiedBar_tagFilteringInEditMode.1.png b/Vault/Tests/VaultiOSTests/__Snapshots__/VaultItemFeedViewSnapshotTests/unifiedBar_tagFilteringInEditMode.1.png index d1361db66..eed2d4c84 100644 Binary files a/Vault/Tests/VaultiOSTests/__Snapshots__/VaultItemFeedViewSnapshotTests/unifiedBar_tagFilteringInEditMode.1.png and b/Vault/Tests/VaultiOSTests/__Snapshots__/VaultItemFeedViewSnapshotTests/unifiedBar_tagFilteringInEditMode.1.png differ diff --git a/Vault/Tests/VaultiOSTests/__Snapshots__/VaultItemFeedViewSnapshotTests/viewState_toggleEditingMode.editing.png b/Vault/Tests/VaultiOSTests/__Snapshots__/VaultItemFeedViewSnapshotTests/viewState_toggleEditingMode.editing.png index b0d7010b5..ec399e601 100644 Binary files a/Vault/Tests/VaultiOSTests/__Snapshots__/VaultItemFeedViewSnapshotTests/viewState_toggleEditingMode.editing.png and b/Vault/Tests/VaultiOSTests/__Snapshots__/VaultItemFeedViewSnapshotTests/viewState_toggleEditingMode.editing.png differ diff --git a/Vault/Tests/VaultiOSTests/__Snapshots__/VaultItemFeedViewSnapshotTests/viewState_toggleEditingMode.notEditing.png b/Vault/Tests/VaultiOSTests/__Snapshots__/VaultItemFeedViewSnapshotTests/viewState_toggleEditingMode.notEditing.png index c9ac66beb..6f5e83e08 100644 Binary files a/Vault/Tests/VaultiOSTests/__Snapshots__/VaultItemFeedViewSnapshotTests/viewState_toggleEditingMode.notEditing.png and b/Vault/Tests/VaultiOSTests/__Snapshots__/VaultItemFeedViewSnapshotTests/viewState_toggleEditingMode.notEditing.png differ diff --git a/Vault/Tests/VaultiOSTests/__Snapshots__/VaultMainNavigationViewSnapshotTests/layout.1.png b/Vault/Tests/VaultiOSTests/__Snapshots__/VaultMainNavigationViewSnapshotTests/layout.1.png index a57189696..1977c9754 100644 Binary files a/Vault/Tests/VaultiOSTests/__Snapshots__/VaultMainNavigationViewSnapshotTests/layout.1.png and b/Vault/Tests/VaultiOSTests/__Snapshots__/VaultMainNavigationViewSnapshotTests/layout.1.png differ