diff --git a/ios/TheTin/Sources/Collection/TinListOptions.swift b/ios/TheTin/Sources/Collection/TinListOptions.swift new file mode 100644 index 0000000..587ef52 --- /dev/null +++ b/ios/TheTin/Sources/Collection/TinListOptions.swift @@ -0,0 +1,158 @@ +import Foundation + +/// How a divider's (or Everything's) cards are ordered and narrowed — #193: "I cannot find a way +/// to sort my Pokémon by type and that's essential to me." Pure over a card lookup so it's +/// testable without a catalog or a view; `GroupDetailView` supplies the lookups. + +/// Entry orderings offered by the View options menu. +enum TinSort: String, CaseIterable, Identifiable { + case newest = "Newest first", value = "Highest value", name = "A to Z" + /// Collector order: set by release date, then the printed number within it. + case setOldest = "Set order, oldest first", setNewest = "Set order, newest first" + case rarity = "Rarest first", hp = "Highest HP" + var id: String { rawValue } +} + +/// One value per facet; nil = any. Single-choice on purpose: "Psychic, Stage 1" is the question +/// people sort a binder by, and a menu of checkboxes per facet is a lot of menu for that. +struct TinFilter: Equatable { + var type: String? + var stage: String? + var weakness: String? + var rarity: String? + + var isActive: Bool { type != nil || stage != nil || weakness != nil || rarity != nil } + /// "Psychic · Stage 1" — what the summary line under the plaque says is narrowing the list. + var summary: String { + [type, stage, weakness.map { "Weak to \($0)" }, rarity].compactMap { $0 }.joined(separator: " · ") + } + + func matches(_ card: CardRecord?) -> Bool { + guard isActive else { return true } + guard let card else { return false } + if let type, !card.types.contains(type) { return false } + if let stage, Self.stage(of: card) != stage { return false } + if let weakness, !(card.detail?.weaknesses ?? []).contains(where: { $0.type == weakness }) { return false } + if let rarity, card.rarity != rarity { return false } + return true + } + + /// "Basic", "Stage 1", "VMAX"… for a Pokémon; "Trainer" / "Energy" for the rest, so one facet + /// also answers "just my Trainers". nil on catalogs older than the `detail` column. + static func stage(of card: CardRecord) -> String? { + switch card.detail?.category { + case "Trainer": return "Trainer" + case "Energy": return "Energy" + default: return card.detail?.stage + } + } + + /// The values each facet can take IN THIS LIST — a menu offering "Dragon" in a divider with no + /// Dragon cards would be a filter to an empty screen. + struct Options: Equatable { + var types: [String] = [] + var stages: [String] = [] + var weaknesses: [String] = [] + var rarities: [String] = [] + } + + static func options(for cards: [CardRecord]) -> Options { + var types = Set(), stages = Set(), weaknesses = Set(), rarities = Set() + for card in cards { + types.formUnion(card.types) + if let s = stage(of: card) { stages.insert(s) } + weaknesses.formUnion((card.detail?.weaknesses ?? []).map(\.type)) + if let r = card.rarity, !r.isEmpty { rarities.insert(r) } + } + return Options(types: types.sorted(), + stages: stages.sorted { stageOrder($0) < stageOrder($1) }, + weaknesses: weaknesses.sorted(), + rarities: rarities.sorted { (TinSorting.rarityRank($0), $0) < (TinSorting.rarityRank($1), $1) }) + } + + /// Evolution order, then the mechanics, then the non-Pokémon — not alphabetical, which would + /// put "Stage 2" after "BREAK" and "Basic" in the middle. + private static func stageOrder(_ s: String) -> (Int, String) { + switch s { + case "Basic": return (0, s) + case "Stage 1": return (1, s) + case "Stage 2": return (2, s) + case "Trainer": return (8, s) + case "Energy": return (9, s) + default: return (5, s) + } + } +} + +enum TinSorting { + /// - Parameters: + /// - card: the catalog record for an entry's card (cached by the caller). + /// - setDate: a set's release date, "yyyy-MM-dd" (sorts lexicographically). + /// - value: the entry's current value, the same figure its row shows. + static func sorted(_ entries: [CollectionEntry], by sort: TinSort, + card: (String) -> CardRecord?, setDate: (String) -> String?, + value: (CollectionEntry) -> Double?) -> [CollectionEntry] { + func name(_ e: CollectionEntry) -> String { card(e.cardId)?.name ?? e.cardId } + func byName(_ a: CollectionEntry, _ b: CollectionEntry) -> Bool { + let order = name(a).localizedStandardCompare(name(b)) + return order == .orderedSame ? a.id < b.id : order == .orderedAscending + } + /// Set release date, then printed number ("2" < "10" < "TG20"); undated sets last. + func collectorOrder(_ a: CollectionEntry, _ b: CollectionEntry, newestFirst: Bool) -> Bool { + let (ca, cb) = (card(a.cardId), card(b.cardId)) + let (da, db) = (ca.flatMap { setDate($0.setId) }, cb.flatMap { setDate($0.setId) }) + if da != db { + guard let da else { return false } + guard let db else { return true } + return newestFirst ? da > db : da < db + } + if ca?.setId != cb?.setId { return (ca?.setId ?? "") < (cb?.setId ?? "") } + let order = (ca?.number ?? "").localizedStandardCompare(cb?.number ?? "") + return order == .orderedSame ? byName(a, b) : order == .orderedAscending + } + + switch sort { + case .newest: + return entries.sorted { $0.addedAt == $1.addedAt ? $0.id < $1.id : $0.addedAt > $1.addedAt } + case .value: + // Precomputed: the comparator runs n log n times and a value walks a price ladder. + let values = Dictionary(entries.map { ($0.id, value($0) ?? -1) }, uniquingKeysWith: { a, _ in a }) + return entries.sorted { (values[$0.id] ?? -1, $1.id) > (values[$1.id] ?? -1, $0.id) } + case .name: + return entries.sorted(by: byName) + case .setOldest: + return entries.sorted { collectorOrder($0, $1, newestFirst: false) } + case .setNewest: + return entries.sorted { collectorOrder($0, $1, newestFirst: true) } + case .rarity: + return entries.sorted { + let (ra, rb) = (rarityRank(card($0.cardId)?.rarity), rarityRank(card($1.cardId)?.rarity)) + return ra == rb ? byName($0, $1) : ra > rb + } + case .hp: + // Trainers and Energy have no HP; they sink rather than reading as "0 HP". + return entries.sorted { + let (ha, hb) = (card($0.cardId)?.hp ?? -1, card($1.cardId)?.hp ?? -1) + return ha == hb ? byName($0, $1) : ha > hb + } + } + } + + /// Commonest 1 → rarest 10. The catalog's rarity strings span 28 years of naming ("Rare Holo", + /// "Double rare", "Special illustration rare"), so this ranks by keyword, most specific first, + /// and anything unrecognised that still says "rare" sits with plain Rare. + /// ponytail: a keyword ladder, not a table of every string — add a rung when a new era names + /// something this misplaces. + static func rarityRank(_ rarity: String?) -> Int { + let r = rarity?.lowercased() ?? "" + let ladder: [(String, Int)] = [ + ("special illustration", 9), ("hyper", 10), ("secret", 10), ("rainbow", 10), + ("shiny ultra", 8), ("ultra", 8), ("illustration", 7), ("full art", 7), + ("shiny", 6), ("double", 6), ("ace spec", 6), ("radiant", 6), ("amazing", 6), + ("prime", 6), ("legend", 6), ("break", 6), ("vmax", 6), ("vstar", 6), + ("holo ex", 6), ("holo gx", 6), ("holo v", 6), ("holo", 5), + ("promo", 3), ("uncommon", 2), ("common", 1), ("rare", 4), + ] + return ladder.first { r.contains($0.0) }?.1 ?? 0 + } +} diff --git a/ios/TheTin/Sources/UI/Collection/CollectionView.swift b/ios/TheTin/Sources/UI/Collection/CollectionView.swift index c04947d..a97fd5a 100644 --- a/ios/TheTin/Sources/UI/Collection/CollectionView.swift +++ b/ios/TheTin/Sources/UI/Collection/CollectionView.swift @@ -732,6 +732,9 @@ final class CardSearchIndex { /// cardId → record, including misses (a `nil` value is a cached "not in this catalog", so a /// missing card isn't re-queried on every pass). Double-optional subscript is deliberate. private var cards: [String: CardRecord?] = [:] + /// setId → release date ("yyyy-MM-dd"), misses cached the same way. Set-order sorting asks + /// for it inside a comparator, which runs n log n times. + private var releaseDates: [String: String?] = [:] /// The catalog record for a card, cached. `CollectionView.riffleCards` spreads up to seven /// cards per divider and re-ran this query for every one of them on every body pass — ~280 @@ -743,6 +746,13 @@ final class CardSearchIndex { return record } + func releaseDate(setId: String, store: CatalogStore) -> String? { + if let cached = releaseDates[setId] { return cached } + let date = (try? store.set(id: setId))?.releaseDate + releaseDates[setId] = date + return date + } + func name(for entry: CollectionEntry, store: CatalogStore) -> String { if let cached = names[entry.cardId] { return cached } let name = card(id: entry.cardId, store: store)?.name ?? entry.cardId @@ -777,6 +787,7 @@ final class CardSearchIndex { haystacks.removeAll() names.removeAll() cards.removeAll() + releaseDates.removeAll() } } diff --git a/ios/TheTin/Sources/UI/Collection/GroupDetailView.swift b/ios/TheTin/Sources/UI/Collection/GroupDetailView.swift index 6256d78..3830935 100644 --- a/ios/TheTin/Sources/UI/Collection/GroupDetailView.swift +++ b/ios/TheTin/Sources/UI/Collection/GroupDetailView.swift @@ -44,10 +44,12 @@ struct CollectionEntryRow: View { /// performance over time, then the cards themselves. The swipe deck (`GroupPagerView`) is the /// explicit "Flip through" mode. Searchable, so "do I own this?" is answerable from any list. struct GroupDetailView: View { - /// Entry orderings offered by the toolbar sort menu. - private enum EntrySort: String, CaseIterable, Identifiable { - case newest = "Newest first", value = "Highest value", name = "A to Z" + /// List rows, or a grid of card art (#193). Persisted, unlike the sort and filter: it's how + /// you like to look at cards, not a question you're asking this one divider. + private enum Layout: String, CaseIterable, Identifiable { + case list = "Show as list", grid = "Show as grid" var id: String { rawValue } + var symbol: String { self == .list ? "list.bullet" : "square.grid.2x2" } } /// What tapping a row does. A mode rather than a per-row chip: the chip had to be squeezed in @@ -76,7 +78,9 @@ struct GroupDetailView: View { @Bindable var model: CollectionModel let group: CardGroup? // nil = the whole tin ("Everything") let store: CatalogStore - @State private var sort: EntrySort = .newest + @State private var sort: TinSort = .newest + @State private var filter = TinFilter() + @AppStorage("tinLayout") private var layout: Layout = .list @State private var rowTap: RowTap = .details @State private var searchText = "" @State private var editingEntry: CollectionEntry? @@ -99,34 +103,8 @@ struct GroupDetailView: View { @State private var showingGone = false var body: some View { - List(selection: listSelection) { - if searchText.isEmpty { - if scope.isEmpty { - emptyState // instead of a "$0.00 · Priced 0 of 0" ledger for nothing - } else { - // `EditCardTip` can't anchor to the toolbar Menu it explains — `.popoverTip` on a - // `Menu` inside a `ToolbarItem` doesn't present (confirmed by direct test), and - // there's no lower control to point at instead. Inline at the top of the list, so - // it explains the menu above from below rather than pointing at it. - if !isSelecting { TipView(EditCardTip()) } - // While selecting, the cards ARE the screen: the plaque + performance chart push - // the first row below the fold, which reads as "there's nothing here to tick". - if !isSelecting { statsSection } - if let group { - entriesSection(sortedAll(model.entries(in: group.id)), header: nil, showDivider: false) - } else { - entriesSection(sortedAll(model.ungroupedEntries), header: "No divider", showDivider: false) - entriesSection(sortedAll(model.entries.filter { !$0.groupId.isEmpty }), - header: "Behind dividers", showDivider: true) - } - } - // Outside the empty branch on purpose: sell your last card and the stack IS - // empty of things you own, but the history of what was there is exactly what - // you'd have come looking for. - if !isSelecting { goneSection } - } else { - searchResults - } + Group { + if showsGrid { gridBody } else { listBody } } .searchable(text: $searchText, prompt: group == nil ? "Search by name, set, or number" : "Search this divider") // The title carries the selection state — the count is the feedback that ticking worked, @@ -188,6 +166,43 @@ struct GroupDetailView: View { } } + /// Grid only when there's a grid to show: selecting ticks rows in the list (a grid has no + /// selection), and search results are a list in either mode. + private var showsGrid: Bool { layout == .grid && !isSelecting && searchText.isEmpty && !scope.isEmpty } + + private var listBody: some View { + List(selection: listSelection) { + if searchText.isEmpty { + if scope.isEmpty { + emptyState // instead of a "$0.00 · Priced 0 of 0" ledger for nothing + } else { + // `EditCardTip` can't anchor to the toolbar Menu it explains — `.popoverTip` on a + // `Menu` inside a `ToolbarItem` doesn't present (confirmed by direct test), and + // there's no lower control to point at instead. Inline at the top of the list, so + // it explains the menu above from below rather than pointing at it. + if !isSelecting { TipView(EditCardTip()) } + // While selecting, the cards ARE the screen: the plaque + performance chart push + // the first row below the fold, which reads as "there's nothing here to tick". + if !isSelecting { statsSection } + if filter.isActive { Section { filterSummary } } + if let group { + entriesSection(sortedAll(visible(model.entries(in: group.id))), header: nil, showDivider: false) + } else { + entriesSection(sortedAll(visible(model.ungroupedEntries)), header: "No divider", showDivider: false) + entriesSection(sortedAll(visible(model.entries.filter { !$0.groupId.isEmpty })), + header: "Behind dividers", showDivider: true) + } + } + // Outside the empty branch on purpose: sell your last card and the stack IS + // empty of things you own, but the history of what was there is exactly what + // you'd have come looking for. + if !isSelecting { goneSection } + } else { + searchResults + } + } + } + private var scope: [CollectionEntry] { group.map { model.entries(in: $0.id) } ?? model.entries } @@ -221,7 +236,7 @@ struct GroupDetailView: View { // is. So the MENU stays live and the three actions carry their own disabled // state. Button(allSelected ? "Deselect All" : "Select All") { - selection = allSelected ? [] : Set(scope.map(\.id)) + selection = allSelected ? [] : Set(visibleScope.map(\.id)) } Divider() Button { choosingDestination = true } @@ -254,7 +269,11 @@ struct GroupDetailView: View { // apart by reading the options, which is why the mode's read "Tap opens …" // rather than the shorter "Details" / "Edit" a header would have qualified. Picker("Sort", selection: $sort) { - ForEach(EntrySort.allCases) { Text($0.rawValue).tag($0) } + ForEach(TinSort.allCases) { Text($0.rawValue).tag($0) } + } + filterMenu + Picker("Layout", selection: $layout) { + ForEach(Layout.allCases) { Label($0.rawValue, systemImage: $0.symbol).tag($0) } } Picker("Tapping a card", selection: $rowTap) { ForEach(RowTap.allCases) { Text($0.rawValue).tag($0) } @@ -264,9 +283,12 @@ struct GroupDetailView: View { // is already truncated to "Test di…" by the Print and Select items beside it, // so the "· Editing" suffix below is invisible on iPhone — it earns its keep // only where the divider name is short or the screen is an iPad. + // Filled while a filter is on, the same way edit mode fills the glyph: the list + // is narrowed and the button is where you'd go to undo it. Label("View options", systemImage: rowTap == .edit ? "pencil.circle.fill" - : "line.3.horizontal.decrease.circle") + : filter.isActive ? "line.3.horizontal.decrease.circle.fill" + : "line.3.horizontal.decrease.circle") } .accessibilityLabel(rowTap == .edit ? "View options, tapping a card opens edit" : "View options") @@ -310,6 +332,38 @@ struct GroupDetailView: View { } } + /// One submenu per facet, each offering only values present in this list. Built from `scope`, + /// not the filtered list, so picking Psychic doesn't erase every other type from the menu. + @ViewBuilder private var filterMenu: some View { + let options = TinFilter.options(for: scope.compactMap { card($0.cardId) }) + Menu { + facet("Type", any: "Any type", options.types, $filter.type) + facet("Stage", any: "Any stage", options.stages, $filter.stage) + facet("Weakness", any: "Any weakness", options.weaknesses, $filter.weakness) + facet("Rarity", any: "Any rarity", options.rarities, $filter.rarity) + if filter.isActive { + Divider() + Button("Clear filters", role: .destructive) { filter = TinFilter() } + } + } label: { + Label(filter.isActive ? "Filter: \(filter.summary)" : "Filter", + systemImage: "line.3.horizontal.decrease") + } + } + + /// `.menu` style, so the facet is a titled submenu ("Type ›") — an inline picker in a menu + /// renders its options with no label at all (see the note on the Sort picker above). + @ViewBuilder private func facet(_ title: String, any: String, _ values: [String], + _ selection: Binding) -> some View { + if !values.isEmpty { + Picker(title, selection: selection) { + Text(any).tag(String?.none) + ForEach(values, id: \.self) { Text($0).tag(Optional($0)) } + } + .pickerStyle(.menu) + } + } + /// "1 card" / "3 cards". Spelled out rather than `^[…](inflect: true)`: that markup only /// resolves where SwiftUI takes a LocalizedStringKey, and dialog titles / navigationTitle /// take a plain String — they render the raw markup instead. Matches how the rest of the @@ -317,7 +371,7 @@ struct GroupDetailView: View { private static func cardCount(_ n: Int) -> String { "\(n) \(n == 1 ? "card" : "cards")" } private var isSelecting: Bool { editMode == .active } - private var allSelected: Bool { !scope.isEmpty && selection.count == scope.count } + private var allSelected: Bool { !visibleScope.isEmpty && selection.count == visibleScope.count } /// The ticked rows, in the order they're listed. Drawn from `scope` (which excludes sold /// copies) rather than from the selection set, so a stale id left by a row that disappeared @@ -413,30 +467,51 @@ struct GroupDetailView: View { } } - /// The divider tab blown up into a title plaque (kept from the old pager summary), - /// then this stack's performance and the flip-through mode switch. + /// The divider tab blown up into a title plaque (kept from the old pager summary). Shared by + /// the list's stats section and the top of the grid. + private var plaque: some View { + let value = group.map { model.groupValue($0.id) } ?? model.tinValue + return VStack(spacing: 6) { + Text(title) + .font(.system(.largeTitle, design: .serif).italic().weight(.semibold)) + .multilineTextAlignment(.center) + Text(value.total, format: .currency(code: "USD")) + .font(.system(.title, design: .rounded).weight(.bold)) + .monospacedDigit() + .contentTransition(.numericText()) + Text("Priced \(value.pricedCards) of \(value.totalCards) \(value.totalCards == 1 ? "card" : "cards")") + .font(.footnote).foregroundStyle(.secondary) + if let asOf = try? store.priceAsOf() { AsOfLabel(date: asOf) } + } + .frame(maxWidth: .infinity) + .padding(.vertical, 24).padding(.horizontal) + .background(color.opacity(0.3), in: UnevenRoundedRectangle( + topLeadingRadius: 18, bottomLeadingRadius: 6, + bottomTrailingRadius: 6, topTrailingRadius: 18)) + } + + /// "Showing 12 of 240 · Psychic · Stage 1 — Clear". Always on screen while a filter is, so a + /// short list can't read as cards gone missing. + private var filterSummary: some View { + HStack(alignment: .firstTextBaseline, spacing: 8) { + VStack(alignment: .leading, spacing: 2) { + Text("Showing \(visibleScope.count) of \(Self.cardCount(scope.count))") + .font(.subheadline.weight(.medium)) + Text(filter.summary).font(.caption).foregroundStyle(.secondary) + } + Spacer() + Button("Clear") { filter = TinFilter() } + .buttonStyle(.bordered).controlSize(.small) + } + .accessibilityElement(children: .combine) + } + + /// Then this stack's performance and the flip-through mode switch. private var statsSection: some View { Section { - let value = group.map { model.groupValue($0.id) } ?? model.tinValue - VStack(spacing: 6) { - Text(title) - .font(.system(.largeTitle, design: .serif).italic().weight(.semibold)) - .multilineTextAlignment(.center) - Text(value.total, format: .currency(code: "USD")) - .font(.system(.title, design: .rounded).weight(.bold)) - .monospacedDigit() - .contentTransition(.numericText()) - Text("Priced \(value.pricedCards) of \(value.totalCards) \(value.totalCards == 1 ? "card" : "cards")") - .font(.footnote).foregroundStyle(.secondary) - if let asOf = try? store.priceAsOf() { AsOfLabel(date: asOf) } - } - .frame(maxWidth: .infinity) - .padding(.vertical, 24).padding(.horizontal) - .background(color.opacity(0.3), in: UnevenRoundedRectangle( - topLeadingRadius: 18, bottomLeadingRadius: 6, - bottomTrailingRadius: 6, topTrailingRadius: 18)) - .listRowBackground(Color.clear) - .listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) + plaque + .listRowBackground(Color.clear) + .listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) performanceRow // App-wide selector for the per-row change badges below — only when there's data. if hasDeltas { @@ -623,16 +698,69 @@ struct GroupDetailView: View { Button { printLabel(for: entry) } label: { Label("Label", systemImage: "qrcode") } .tint(.teal) } - .contextMenu { - Button { editingEntry = entry } label: { Label("Edit entry", systemImage: "pencil") } - // The one edit common enough to deserve skipping the form entirely: pulling a second - // identical card out of a pack cost a tap, a mode switch, a sheet, a stepper and Save. - Button { Task { await model.addCopy(entry) } } label: { - Label("Add another copy", systemImage: "plus") + .contextMenu { entryMenu(entry) } + } + + /// The long-press menu, shared by list rows and grid tiles. + @ViewBuilder private func entryMenu(_ entry: CollectionEntry) -> some View { + Button { editingEntry = entry } label: { Label("Edit entry", systemImage: "pencil") } + // The one edit common enough to deserve skipping the form entirely: pulling a second + // identical card out of a pack cost a tap, a mode switch, a sheet, a stepper and Save. + Button { Task { await model.addCopy(entry) } } label: { + Label("Add another copy", systemImage: "plus") + } + Button { printLabel(for: entry) } label: { Label("Print label", systemImage: "qrcode") } + Button { sellingEntry = entry } label: { Label("Sold or traded…", systemImage: "bag") } + } + + // MARK: grid (#193) + + private static let gridColumns = [GridItem(.adaptive(minimum: 104), spacing: 12, alignment: .top)] + + /// The cards as art, the way the set and Pokédex grids show them — a ScrollView, not the List: + /// a List row can't hold a grid of independent links. No swipe actions here, so everything + /// they offer is on the long press, and Select switches back to the list to tick rows. + private var gridBody: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 16) { + plaque + if filter.isActive { filterSummary } + if let group { + gridSection(sortedAll(visible(model.entries(in: group.id))), header: nil) + } else { + gridSection(sortedAll(visible(model.ungroupedEntries)), header: "No divider") + gridSection(sortedAll(visible(model.entries.filter { !$0.groupId.isEmpty })), + header: "Behind dividers") + } + } + .padding() + } + } + + @ViewBuilder private func gridSection(_ entries: [CollectionEntry], header: String?) -> some View { + if !entries.isEmpty { + if let header { + Text(header).font(.headline).padding(.top, 4) + } + LazyVGrid(columns: Self.gridColumns, spacing: 12) { + ForEach(entries) { entry in tile(entry) } + } + } + } + + @ViewBuilder private func tile(_ entry: CollectionEntry) -> some View { + let content = EntryTile(card: card(entry.cardId), entry: entry, + value: model.entryValue(entry), delta: deltaRecord(entry)) + Group { + if rowTap == .edit { + Button { editingEntry = entry } label: { content } + .accessibilityHint("Edit this entry") + } else { + NavigationLink(value: CardID(raw: entry.cardId, highlight: CardHighlight(entry: entry))) { content } } - Button { printLabel(for: entry) } label: { Label("Print label", systemImage: "qrcode") } - Button { sellingEntry = entry } label: { Label("Sold or traded…", systemImage: "bag") } } + .buttonStyle(.plain) + .contextMenu { entryMenu(entry) } } /// The delta matching what this entry actually is — resolved by the same ladder as its value @@ -642,23 +770,25 @@ struct GroupDetailView: View { } private func sortedAll(_ entries: [CollectionEntry]) -> [CollectionEntry] { - switch sort { - case .newest: - return entries.sorted { $0.addedAt > $1.addedAt } - case .value: - return GroupStats.sortedByValueDescending(entries: entries, prices: model.prices, - variantsByCard: model.variantsByCard, - conditionsByCard: model.conditionsByCard, - matrixByCard: model.matrixByCard, - gradedByPrintingByCard: model.gradedByPrintingByCard) - case .name: - return entries.sorted { - searchIndex.name(for: $0, store: store) - .localizedStandardCompare(searchIndex.name(for: $1, store: store)) == .orderedAscending - } - } + // `model.entryValue`, the figure the row prints — an entry the catalog can't price + // exactly says "no data" and sorts last, rather than sitting mid-list on a hidden estimate. + TinSorting.sorted(entries, by: sort, card: card, + setDate: { searchIndex.releaseDate(setId: $0, store: store) }, + value: model.entryValue) } + private func card(_ cardId: String) -> CardRecord? { searchIndex.card(id: cardId, store: store) } + + /// The entries the filter lets through. Search deliberately bypasses this: "do I own this?" + /// must not be answered "no" by a filter you forgot was on. + private func visible(_ entries: [CollectionEntry]) -> [CollectionEntry] { + filter.isActive ? entries.filter { filter.matches(card($0.cardId)) } : entries + } + + /// What Select All ticks and what the summary counts: the cards on screen, so "filter to + /// Psychic → Select All → Move to…" files exactly the Psychic ones (#193's whole point). + private var visibleScope: [CollectionEntry] { visible(scope) } + private func cardName(_ entry: CollectionEntry) -> String { searchIndex.name(for: entry, store: store) } @@ -670,3 +800,33 @@ struct GroupDetailView: View { app?.labelRequest = LabelPrintRequest(title: cardName(entry), entries: [entry]) } } + +/// One owned entry as a grid tile: art, a ×N badge when the row holds copies, name, value and +/// change — the list row's facts, stacked. +private struct EntryTile: View { + let card: CardRecord? + let entry: CollectionEntry + let value: Double? + let delta: DeltaRecord? + + var body: some View { + VStack(spacing: 4) { + CardImageView(card: card, quality: "low") + .overlay(alignment: .topTrailing) { + if entry.qty > 1 { + Text("×\(entry.qty)") + .font(.caption2.weight(.bold)).monospacedDigit() + .padding(.horizontal, 6).padding(.vertical, 2) + .background(.thinMaterial, in: Capsule()) + .padding(4) + } + } + Text(card?.name ?? entry.cardId).font(.caption).lineLimit(1) + HStack(spacing: 4) { + PriceLabel(value: value) + DeltaBadge(record: delta) + } + } + .accessibilityElement(children: .combine) + } +} diff --git a/ios/TheTin/Tests/TinListOptionsTests.swift b/ios/TheTin/Tests/TinListOptionsTests.swift new file mode 100644 index 0000000..8ee1a08 --- /dev/null +++ b/ios/TheTin/Tests/TinListOptionsTests.swift @@ -0,0 +1,122 @@ +import XCTest +@testable import TheTin + +/// #193: sorting and narrowing a divider by what's printed on the cards. +final class TinListOptionsTests: XCTestCase { + private func card(_ id: String, _ name: String, set: String = "s1", number: String = "1", + hp: Int? = nil, types: [String] = [], rarity: String? = nil, + category: String? = "Pokemon", stage: String? = nil, + weak: [String] = []) -> CardRecord { + CardRecord(id: id, setId: set, number: number, name: name, hp: hp, types: types, + rarity: rarity, artist: nil, imageBase: nil, imageUrl: nil, tcgplayerId: nil, + detail: CardDetail(category: category, stage: stage, + weaknesses: weak.map { CardTypeValue(type: $0, value: "×2") })) + } + + private func entry(_ id: String, _ cardId: String, added: TimeInterval = 0) -> CollectionEntry { + CollectionEntry(id: id, cardId: cardId, groupId: "", qty: 1, condition: "NM", grade: nil, + pricePaid: nil, acquiredAt: nil, acquiredFrom: nil, + addedAt: Date(timeIntervalSince1970: added)) + } + + private lazy var cards: [String: CardRecord] = Dictionary(uniqueKeysWithValues: [ + card("abra", "Abra", set: "base1", number: "43", hp: 30, types: ["Psychic"], rarity: "Common", + stage: "Basic", weak: ["Psychic"]), + card("kadabra", "Kadabra", set: "base1", number: "32", hp: 60, types: ["Psychic"], rarity: "Uncommon", + stage: "Stage 1", weak: ["Psychic"]), + card("charizard", "Charizard", set: "base1", number: "4", hp: 120, types: ["Fire"], rarity: "Rare Holo", + stage: "Stage 2", weak: ["Water"]), + card("mew", "Mew ex", set: "sv3pt5", number: "151", hp: 180, types: ["Psychic"], + rarity: "Double rare", stage: "Basic", weak: ["Darkness"]), + card("potion", "Potion", set: "sv1", number: "188", rarity: "Uncommon", category: "Trainer"), + card("pika-sir", "Pikachu", set: "sv3pt5", number: "173", hp: 60, types: ["Lightning"], + rarity: "Special illustration rare", stage: "Basic", weak: ["Fighting"]), + ].map { ($0.id, $0) }) + private let releases = ["base1": "1999-01-09", "sv1": "2023-03-31", "sv3pt5": "2023-09-22"] + + private func sorted(_ by: TinSort, _ entries: [CollectionEntry]? = nil) -> [String] { + let cards = self.cards, releases = self.releases + let list = entries ?? cards.keys.sorted().map { entry("e-\($0)", $0) } + return TinSorting.sorted(list, by: by, card: { cards[$0] }, setDate: { releases[$0] }, + value: { _ in nil }).map(\.cardId) + } + + func testSetOrderIsReleaseDateThenPrintedNumber() { + XCTAssertEqual(sorted(.setOldest), ["charizard", "kadabra", "abra", "potion", "mew", "pika-sir"]) + XCTAssertEqual(sorted(.setNewest).prefix(2), ["mew", "pika-sir"]) + } + + func testRarestFirst() { + XCTAssertEqual(sorted(.rarity).first, "pika-sir") + XCTAssertEqual(sorted(.rarity).last, "abra") + } + + /// No HP is not 0 HP — a Trainer sinks below every Pokémon. + func testHighestHPWithTrainersLast() { + XCTAssertEqual(sorted(.hp), ["mew", "charizard", "kadabra", "pika-sir", "abra", "potion"]) + } + + func testNewestFirstIsRecentlyAdded() { + let list = [entry("1", "abra", added: 10), entry("2", "mew", added: 30), entry("3", "potion", added: 20)] + XCTAssertEqual(sorted(.newest, list), ["mew", "potion", "abra"]) + } + + func testHighestValueSinksUnpricedRows() { + let list = [entry("1", "abra"), entry("2", "mew"), entry("3", "potion")] + let values: [String: Double] = ["1": 2, "2": 40] + let out = TinSorting.sorted(list, by: .value, card: { _ in nil }, setDate: { _ in nil }, + value: { values[$0.id] }) + XCTAssertEqual(out.map(\.id), ["2", "1", "3"]) + } + + func testRarityRankLadder() { + let ranked = ["Common", "Uncommon", "Promo", "Rare", "Rare Holo", "Double rare", + "Illustration rare", "Ultra Rare", "Special illustration rare", "Hyper rare"] + .map(TinSorting.rarityRank) + XCTAssertEqual(ranked, ranked.sorted(), "each rung outranks the one before it") + XCTAssertEqual(TinSorting.rarityRank("Rare Holo VMAX"), TinSorting.rarityRank("Double rare")) + XCTAssertEqual(TinSorting.rarityRank(nil), 0) + } + + // MARK: filter + + func testFilterByTypeAndStage() { + var f = TinFilter() + f.type = "Psychic" + XCTAssertEqual(cards.values.filter(f.matches).map(\.id).sorted(), ["abra", "kadabra", "mew"]) + f.stage = "Basic" + XCTAssertEqual(cards.values.filter(f.matches).map(\.id).sorted(), ["abra", "mew"]) + XCTAssertEqual(f.summary, "Psychic · Basic") + } + + func testTrainersAreAStage() { + var f = TinFilter() + f.stage = "Trainer" + XCTAssertEqual(cards.values.filter(f.matches).map(\.id), ["potion"]) + } + + func testFilterByWeakness() { + var f = TinFilter() + f.weakness = "Psychic" + XCTAssertEqual(cards.values.filter(f.matches).map(\.id).sorted(), ["abra", "kadabra"]) + XCTAssertEqual(f.summary, "Weak to Psychic") + } + + /// A card the catalog doesn't know can't satisfy a filter, but passes when none is on. + func testUnknownCardOnlyPassesAnInactiveFilter() { + XCTAssertTrue(TinFilter().matches(nil)) + var f = TinFilter() + f.rarity = "Common" + XCTAssertFalse(f.matches(nil)) + } + + /// Menus offer only what's in the list, stages in evolution order rather than A–Z. + func testOptionsComeFromTheListInAUsefulOrder() { + let o = TinFilter.options(for: Array(cards.values)) + XCTAssertEqual(o.types, ["Fire", "Lightning", "Psychic"]) + XCTAssertEqual(o.stages, ["Basic", "Stage 1", "Stage 2", "Trainer"]) + XCTAssertEqual(o.weaknesses, ["Darkness", "Fighting", "Psychic", "Water"]) + XCTAssertEqual(o.rarities.first, "Common") + XCTAssertEqual(o.rarities.last, "Special illustration rare") + } +}