Skip to content
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
9 changes: 9 additions & 0 deletions Vault/Sources/VaultFeed/Storage/VaultDataModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
}
Expand Down
56 changes: 40 additions & 16 deletions Vault/Sources/VaultiOS/Views/Feed/VaultItemFeedView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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))
}

Expand All @@ -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<Bool> {
Binding {
dataModel.itemsFilteringByTags.contains(tag.id)
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
58 changes: 58 additions & 0 deletions Vault/Tests/VaultiOSTests/VaultItemFeedViewSnapshotTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<VaultItemTag>()
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<VaultItemTag>()
let tag2Id = Identifier<VaultItemTag>()
let tag3Id = Identifier<VaultItemTag>()
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()
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.