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
221 changes: 220 additions & 1 deletion Sources/PokeTokenBar/Core/CompanionStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,28 @@ final class CompanionStore {
/// 희귀도별 포획 로그 개수(요약 헤더용) — 개체 수 기준. 도감(종 단위)은 dexSpecies 를 쓴다.
func dexCount(_ rarity: Rarity) -> Int { dexEntries.lazy.filter { $0.rarity == rarity }.count }

enum DexSortOption: String, CaseIterable, Identifiable, Sendable {
case numberAsc
case numberDesc
case nameAsc
case nameDesc
case rarityDesc

var id: String { rawValue }
}

enum CatchLogSortOption: String, CaseIterable, Identifiable, Sendable {
case recentFirst
case oldestFirst
case numberAsc
case numberDesc
case nameAsc
case nameDesc
case rarityDesc

var id: String { rawValue }
}

/// 도감 한 칸 — 메인 목록은 종별, 안농 상세 목록은 폼별로 중복 기록을 합친다.
/// **종 정보만 담는다** — 성격·획득 횟수처럼 개체에 딸린 것은 포획 로그가 개체 단위로 보여준다.
struct DexSpecies: Sendable {
Expand All @@ -397,6 +419,7 @@ final class CompanionStore {
/// 이 종이 현재 키우는 개체의 **현재 형태**인가. 지나온 진화 단계에는 서지 않는다.
let isRaising: Bool
var unownForm: UnownForm? = nil
var names: [String: String]? = nil
var hasNormal = false

/// Species IDs remain Pokédex numbers; selection also includes the Unown letter.
Expand Down Expand Up @@ -475,7 +498,203 @@ final class CompanionStore {
rarity: a.rarity,
isShiny: a.isShiny,
isRaising: key.speciesID == state.active?.currentID && (!groupUnownForms || key.unownForm == currentUnownForm),
unownForm: key.unownForm, hasNormal: a.hasNormal)
unownForm: key.unownForm,
names: a.names,
hasNormal: a.hasNormal)
}
}

/// Resolves the primary localized name of an individual final species for sorting and display.
func dexEntryName(_ entry: DexEntry) -> String {
let targetID = entry.finalID
if let stored = entry.names?[targetID], let localized = state.language.resolveName(stored) {
return localized
}
for id in entry.chainOrder.reversed() {
if let stored = entry.names?[id], let localized = state.language.resolveName(stored) {
return localized
}
}
return "#\(targetID)"
}

/// The one search rule shared by the Pokédex grid and the Catch Log, so the two screens cannot
/// drift apart. A query matches a Pokédex number (`25` or `#25`) or part of any stored name
/// in any language, ignoring case and diacritics ("flabebe" finds Flabébé).
struct DexSearchMatcher: Sendable {
private let query: String
private let number: Int?

/// `nil` for a blank query, which filters nothing out.
init?(_ rawQuery: String) {
let trimmed = rawQuery.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
query = trimmed
number = Int(trimmed.hasPrefix("#") ? String(trimmed.dropFirst()) : trimmed)
}

func matches(_ species: DexSpecies) -> Bool {
matches(ids: [species.id], names: [species.name] + (species.names.map { Array($0.values) } ?? []))
}

/// An individual matches through any species of its chain, like its evolution line in the log row.
func matches(_ entry: DexEntry) -> Bool {
matches(ids: [entry.baseID, entry.finalID] + entry.chainOrder,
names: entry.names?.values.flatMap(\.values) ?? [])
}

private func matches(ids: [Int], names: [String]) -> Bool {
if let number, ids.contains(number) { return true }
// Partial numbers keep working: "25" also finds #125, "#25" finds #25 and #250.
if ids.contains(where: { "#\($0)".contains(query) }) { return true }
return names.contains { $0.range(of: query, options: [.caseInsensitive, .diacriticInsensitive]) != nil }
}
}

/// Filters and sorts DexSpecies for the Pokédex grid.
func filteredDexSpecies(
query: String = "",
rarity: Rarity? = nil,
shinyOnly: Bool = false,
sort: DexSortOption = .numberAsc
) -> [DexSpecies] {
var result = dexSpecies
if let rarity {
result = result.filter { $0.rarity == rarity }
}
if shinyOnly {
result = result.filter { $0.isShiny }
}
if let matcher = DexSearchMatcher(query) {
result = result.filter { matcher.matches($0) }
}
return sortDexSpecies(result, by: sort)
}

/// Sorts DexSpecies by the given option.
func sortDexSpecies(_ species: [DexSpecies], by sort: DexSortOption) -> [DexSpecies] {
switch sort {
case .numberAsc:
return species.sorted { $0.id < $1.id }
case .numberDesc:
return species.sorted { $0.id > $1.id }
case .nameAsc:
return species.sorted {
let cmp = $0.name.localizedCompare($1.name)
return cmp == .orderedAscending ? true : (cmp == .orderedSame ? $0.id < $1.id : false)
}
case .nameDesc:
return species.sorted {
let cmp = $0.name.localizedCompare($1.name)
return cmp == .orderedDescending ? true : (cmp == .orderedSame ? $0.id < $1.id : false)
}
case .rarityDesc:
return species.sorted {
if $0.rarity.sortRank != $1.rarity.sortRank {
return $0.rarity.sortRank > $1.rarity.sortRank
}
return $0.id < $1.id
}
}
}

/// Filters and sorts DexEntry records for the Catch Log.
func filteredDexEntries(
query: String = "",
rarity: Rarity? = nil,
shinyOnly: Bool = false,
sort: CatchLogSortOption = .recentFirst
) -> [DexEntry] {
var result = dexEntries
if let rarity {
result = result.filter { $0.rarity == rarity }
}
if shinyOnly {
result = result.filter { $0.isShiny }
}
if let matcher = DexSearchMatcher(query) {
result = result.filter { matcher.matches($0) }
}
return sortDexEntries(result, by: sort)
}

/// Sorts DexEntry records by the given option.
func sortDexEntries(_ entries: [DexEntry], by sort: CatchLogSortOption) -> [DexEntry] {
switch sort {
case .recentFirst:
let active = entries.first(where: { isActiveDexEntry($0) })
let graduated = entries.filter { !isActiveDexEntry($0) }.sorted {
let d0 = $0.caughtAt ?? .distantPast
let d1 = $1.caughtAt ?? .distantPast
if d0 != d1 { return d0 > d1 }
return $0.id < $1.id
}
return (active.map { [$0] } ?? []) + graduated
case .oldestFirst:
let active = entries.first(where: { isActiveDexEntry($0) })
let graduated = entries.filter { !isActiveDexEntry($0) }.sorted {
let d0 = $0.caughtAt ?? .distantPast
let d1 = $1.caughtAt ?? .distantPast
if d0 != d1 { return d0 < d1 }
return $0.id < $1.id
}
return graduated + (active.map { [$0] } ?? [])
case .numberAsc:
return entries.sorted {
if $0.finalID != $1.finalID {
return $0.finalID < $1.finalID
}
let d0 = $0.caughtAt ?? .distantPast
let d1 = $1.caughtAt ?? .distantPast
if d0 != d1 { return d0 > d1 }
return $0.id < $1.id
}
case .numberDesc:
return entries.sorted {
if $0.finalID != $1.finalID {
return $0.finalID > $1.finalID
}
let d0 = $0.caughtAt ?? .distantPast
let d1 = $1.caughtAt ?? .distantPast
if d0 != d1 { return d0 > d1 }
return $0.id < $1.id
}
case .nameAsc:
return entries.sorted {
let name1 = dexEntryName($0)
let name2 = dexEntryName($1)
let cmp = name1.localizedCompare(name2)
if cmp != .orderedSame {
return cmp == .orderedAscending
}
let d0 = $0.caughtAt ?? .distantPast
let d1 = $1.caughtAt ?? .distantPast
if d0 != d1 { return d0 > d1 }
return $0.id < $1.id
}
case .nameDesc:
return entries.sorted {
let name1 = dexEntryName($0)
let name2 = dexEntryName($1)
let cmp = name1.localizedCompare(name2)
if cmp != .orderedSame {
return cmp == .orderedDescending
}
let d0 = $0.caughtAt ?? .distantPast
let d1 = $1.caughtAt ?? .distantPast
if d0 != d1 { return d0 > d1 }
return $0.id < $1.id
}
case .rarityDesc:
return entries.sorted {
if $0.rarity.sortRank != $1.rarity.sortRank {
return $0.rarity.sortRank > $1.rarity.sortRank
}
let d0 = $0.caughtAt ?? .distantPast
let d1 = $1.caughtAt ?? .distantPast
if d0 != d1 { return d0 > d1 }
return $0.id < $1.id
}
}
}

Expand Down
32 changes: 32 additions & 0 deletions Sources/PokeTokenBar/Core/Localization.swift
Original file line number Diff line number Diff line change
Expand Up @@ -998,6 +998,38 @@ struct L {
var dexFilterHint: String { t("탭하면 이 희귀도만 보기 · 다시 탭하면 전체", "Tap to show only this rarity · tap again to clear", "タップでこの希少度のみ表示・再タップで全体", "Toca para ver solo esta rareza · toca de nuevo para ver todo", "Touche pour n'afficher que cette rareté · touche à nouveau pour tout afficher", "Toque para ver só esta raridade · toque de novo para ver tudo", "Tippe, um nur diese Seltenheit zu sehen · tippe erneut für alle") }
/// 도감 칸의 ✨ 를 읽어주는 명사 — 이모지는 스크린리더가 일관되게 읽지 못한다.
var dexShinyLabel: String { t("이로치", "Shiny", "色違い", "Variocolor", "Chromatique", "Shiny", "Schillernd") }
var dexSearchPlaceholder: String { t("이름 또는 #번호 검색…", "Search name or #…", "名前または#番号で検索…", "Buscar por nombre o #…", "Rechercher par nom ou #…", "Buscar por nome ou #…", "Nach Name oder # suchen…") }
var sortTitle: String { t("정렬", "Sort", "並び替え", "Ordenar", "Trier", "Ordenar", "Sortieren") }
var sortDexNumberAsc: String { t("도감 번호 (낮은 순)", "Number (Low to High)", "図鑑番号(昇順)", "Nº de Pokédex (asc.)", "N° de Pokédex (croissant)", "Nº da Pokédex (crescente)", "Nummer (aufsteigend)") }
var sortDexNumberDesc: String { t("도감 번호 (높은 순)", "Number (High to Low)", "図鑑番号(降順)", "Nº de Pokédex (desc.)", "N° de Pokédex (décroissant)", "Nº da Pokédex (decrescente)", "Nummer (absteigend)") }
var sortNameAsc: String { t("이름 (가나다·A-Z)", "Name (A–Z)", "名前(五十音・A-Z)", "Nombre (A–Z)", "Nom (A–Z)", "Nome (A–Z)", "Name (A–Z)") }
var sortNameDesc: String { t("이름 (역순)", "Name (Z–A)", "名前(逆順)", "Nombre (Z–A)", "Nom (Z–A)", "Nome (Z–A)", "Name (Z–A)") }
var sortRarity: String { t("희귀도 순", "Rarity (High to Low)", "希少度順", "Rareza (mayor a menor)", "Rareté (décroissante)", "Raridade (maior a menor)", "Seltenheit (absteigend)") }
var sortDateDesc: String { t("최근 잡은 순", "Most Recent", "最近捕獲", "Más recientes", "Plus récents", "Mais recentes", "Neueste zuerst") }
var sortDateAsc: String { t("오래된 순", "Oldest First", "古い順", "Más antiguos", "Plus anciens", "Mais antigos", "Älteste zuerst") }
var filterShinyOnly: String { t("이로치만", "Shiny only", "色違いのみ", "Solo variocolor", "Chromatique uniquement", "Apenas Shiny", "Nur schillernde") }
var noSearchResults: String { t("검색 결과가 없어요", "No Pokémon found", "見つかりませんでした", "No se encontraron Pokémon", "Aucun Pokémon trouvé", "Nenhum Pokémon encontrado", "Keine Pokémon gefunden") }
var clearFilters: String { t("필터 초기화", "Reset filters", "フィルターを解除", "Restablecer filtros", "Réinitialiser les filtres", "Redefinir filtros", "Filter zurücksetzen") }
func label(for option: CompanionStore.DexSortOption) -> String {
switch option {
case .numberAsc: return sortDexNumberAsc
case .numberDesc: return sortDexNumberDesc
case .nameAsc: return sortNameAsc
case .nameDesc: return sortNameDesc
case .rarityDesc: return sortRarity
}
}
func label(for option: CompanionStore.CatchLogSortOption) -> String {
switch option {
case .recentFirst: return sortDateDesc
case .oldestFirst: return sortDateAsc
case .numberAsc: return sortDexNumberAsc
case .numberDesc: return sortDexNumberDesc
case .nameAsc: return sortNameAsc
case .nameDesc: return sortNameDesc
case .rarityDesc: return sortRarity
}
}
var dexNormalLabel: String { t("일반", "Normal", "通常", "Normal", "Normal", "Normal", "Normal") }
var dexAppearance: String { t("모습", "Appearance", "姿", "Aspecto", "Apparence", "Aparência", "Aussehen") }
var dexAppearancePreview: String { t("수집한 모습 · 종 정보", "Collected appearance · species reference", "収集した姿・種情報", "Aspecto coleccionado · datos de especie", "Apparence collectionnée · données de l’espèce", "Aparência coletada · dados da espécie", "Gesammeltes Aussehen · Speziesdaten") }
Expand Down
Loading
Loading