diff --git a/README.ja.md b/README.ja.md index 359a0a8b..3104a866 100644 --- a/README.ja.md +++ b/README.ja.md @@ -158,6 +158,7 @@ Pi は複数のモデルを一つのセッションログに流すことがあ - **低電力モード** — メニューバーのアニメーションはバッテリー優先の品質で継続します。 - **再接続時の更新** — ネットワークが復帰すると使用量と上限を更新します。 +- **トレーナーカード** — コレクションタブの両面カード。表はトレーナー名・番号、累計トークン、図鑑数、色違い、卒業、重複、初捕獲、裏は6匹のチームです。自分で選ぶまでチームは希少な個体と色違いで自動的に埋まり、カードからでも捕獲ログの右クリックメニューからでも選べます。画像としてコピー、PNG として保存でき、色違いの相棒ならホロ枠が付きます。ネットワークに出るものはなく、トークン数はトグルで隠せます。 - **代表ポケモン** — 図鑑で所有している種を選び、育成中のポケモンとは別にメニューバーと任意のフローティングペットへ固定。固定中はメニューバーがタマゴ・孵化・進化を追わなくなりますが、育成の進行は Home で引き続き確認できます。 - **アニメーション品質** — メニューバーのスプライトとフローティングペットの滑らかさを選べます(バッテリー優先/標準/滑らか)。常に表示される2つの表面が同じ設定を共有します。既定の「バッテリー優先」はこの設定が入る前と同じフレームレートで、「標準」「滑らか」はバッテリーを多く使います(実測アイドル CPU 約1.8%/約5.1%)。 - **インタラクティブなフローティングペット** — ホバーで今日の使用量、クリックでメイン画面、右クリックでメニュー、上限アラートは吹き出しで表示。 diff --git a/README.ko.md b/README.ko.md index 78f18963..cc0fe395 100644 --- a/README.ko.md +++ b/README.ko.md @@ -158,6 +158,7 @@ Pi는 모델 여러 개를 세션 로그 하나로 흘려보낼 수 있습니다 - **저전력 모드** — 메뉴바 애니메이션을 배터리 절약 품질로 계속 표시합니다. - **재연결 시 새로고침** — 네트워크가 복구되면 사용량과 한도를 새로고침합니다. +- **트레이너 카드** — 컬렉션 탭의 양면 카드. 앞면은 트레이너 이름·번호, 누적 토큰, 도감 수, 이로치, 졸업, 중복, 첫 포획이고 뒷면은 6마리 팀입니다. 직접 고르기 전까지 팀은 가장 희귀하고 이로치인 개체로 저절로 채워지며, 카드나 포획 로그의 우클릭 메뉴에서 고를 수 있습니다. 이미지로 복사하거나 PNG로 저장할 수 있고, 이로치 파트너면 홀로 테두리가 붙습니다. 네트워크로 나가는 것은 없고, 토큰 수는 토글로 가릴 수 있습니다. - **대표 포켓몬** — 도감에서 보유한 종을 골라 현재 키우는 포켓몬과 별개로 메뉴바와 선택적 플로팅 펫에 고정. 고정 중에는 메뉴바가 알·부화·진화를 따라가지 않지만, 육성 과정은 Home에서 계속 확인할 수 있습니다. - **애니메이션 품질** — 메뉴바 스프라이트와 플로팅 펫이 얼마나 부드럽게 움직일지 고릅니다(배터리 절약 / 기본 / 부드럽게). 상시 표시되는 두 표면이 같은 설정을 씁니다. 기본값 "배터리 절약"은 이 설정이 생기기 전과 같은 프레임 레이트이고, "기본"·"부드럽게"는 배터리를 더 씁니다(실측 유휴 CPU 약 1.8% / 약 5.1%). - **인터랙티브 플로팅 펫** — 호버로 오늘 사용량, 클릭으로 메인 창, 우클릭 메뉴, 한도 알림은 말풍선으로 표시. diff --git a/README.md b/README.md index 2efad8cb..a39ac0fc 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,7 @@ Reset countdowns include the clock time, and the colored percentage stays aligne - **Low Power Mode** — menu-bar animation continues at Power saver quality. - **Reconnect refresh** — usage and limits refresh when the network comes back. +- **Trainer card** — a two-sided card in the Collection tab: your trainer name and number, lifetime tokens, Pokédex count, shinies, graduations, duplicates and first catch on the front, a team of six on the back. The team fills itself with your rarest and shiniest until you pick one, from the card or the catch log's right-click menu. Copy it as an image or save it as a PNG; a shiny companion gets a holo frame. Nothing leaves your Mac, and a toggle hides the token count. - **Representative Pokémon** — pin any owned Pokédex species to the menu bar and optional floating pet, independently of the companion you're raising. While pinned, the menu bar stops following egg, hatch, and evolution changes; raising progress remains visible on Home. - **Animation quality** — pick how smoothly the menu bar sprite and the floating pet animate (Power saver / Balanced / Smooth). Both always-visible surfaces share the setting. Power saver is the default and keeps the frame rate the app used before this setting existed; Balanced and Smooth trade battery for smoothness (measured idle CPU ~1.8% and ~5.1%). - **Interactive floating pet** — hover for today's usage, click to open the main window, right-click for a menu, and show limit alerts as speech bubbles. diff --git a/Sources/PokeTokenBar/Core/CompanionModel.swift b/Sources/PokeTokenBar/Core/CompanionModel.swift index 4977d8b2..a84a69b0 100644 --- a/Sources/PokeTokenBar/Core/CompanionModel.swift +++ b/Sources/PokeTokenBar/Core/CompanionModel.swift @@ -59,6 +59,7 @@ enum Rarity: String, Codable, Sendable { /// 유일한 소비자는 프리미엄 알의 보증 관문(`hatch` 의 `line.rarity.sortRank < tier.sortRank`) — /// 뽑힌 등급이 산 보증보다 낮은지 판정한다. 순서가 뒤집히면 고급/희귀 알이 조용히 낮은 등급을 /// 통과시키므로 `testSortRankOrdersRarityAscendingByValue` 가 순서를 고정한다. + /// The trainer card's automatic team also ranks by it, rarest graduates first. var sortRank: Int { switch self { case .common: return 0 @@ -655,6 +656,18 @@ struct CompanionState: Codable, Sendable { var candyGrantTier: [String: Int] = [:] // 사탕 지급 첫 실행 시드 완료 — 업데이트 직후 이미 100%였던 창의 소급 지급 차단. var candyFeatureSeeded = false + /// Trainer card team in slot order, as individual ids (`DexEntry.id`, or the raised Pokémon's + /// `profile.instanceID`, which its graduated entry keeps). nil = never picked one. + /// + /// Kept while `teamIsAutomatic` is on: going back to the automatic team must not throw away a + /// hand-picked line-up, or one stray click would wipe six deliberate choices with no undo. + var teamEntryIDs: [String]? = nil + /// true = the card picks the team itself and `teamEntryIDs` is only the stash to come back to. + var teamIsAutomatic = true + /// Random number printed on the trainer card, assigned the first time the card is opened. + var trainerID: Int? = nil + /// Optional name printed on the trainer card. Empty = no name row. + var trainerName = "" init() {} @@ -694,6 +707,11 @@ struct CompanionState: Codable, Sendable { inventory = c.lenient([String: Int].self, forKey: .inventory, default: [:]) candyGrantTier = c.lenient([String: Int].self, forKey: .candyGrantTier, default: [:]) candyFeatureSeeded = c.lenient(Bool.self, forKey: .candyFeatureSeeded, default: false) + teamEntryIDs = c.lenientOptional([String].self, forKey: .teamEntryIDs) + // Saves written before the stash existed only had the list: having one meant it was shown. + teamIsAutomatic = c.lenientOptional(Bool.self, forKey: .teamIsAutomatic) ?? (teamEntryIDs == nil) + trainerID = c.lenientOptional(Int.self, forKey: .trainerID) + trainerName = c.lenient(String.self, forKey: .trainerName, default: "") } /// 졸업 기록 또는 현재 개체가 실제로 도달한 단계에 이 종이 포함되는가. @@ -754,6 +772,23 @@ struct CompanionState: Codable, Sendable { representativeUnownForm = nil } } + + /// The team only holds individuals the save still has: a released Pokémon leaves it, and so do + /// duplicates or ids a hand-edited save made up. + mutating func reconcileTeamSelection() { + guard let picked = teamEntryIDs else { return } + var owned = Set(dex.lazy.filter { !$0.isReleased }.map(\.id)) + if let raised = active?.profile?.instanceID { owned.insert(raised) } + var seen = Set() + teamEntryIDs = Array(picked.filter { owned.contains($0) && seen.insert($0).inserted } + .prefix(TrainerCard.teamSize)) + } +} + +enum TrainerCard { + static let teamSize = 6 + static let nameLimit = 16 + static let idRange = 10_000...99_999 } // NOTE: 부화 후보는 더 이상 하드코딩하지 않는다 — CompanionStore.chooseBase() 가 diff --git a/Sources/PokeTokenBar/Core/CompanionStore.swift b/Sources/PokeTokenBar/Core/CompanionStore.swift index 5cc470fd..04eefc3f 100644 --- a/Sources/PokeTokenBar/Core/CompanionStore.swift +++ b/Sources/PokeTokenBar/Core/CompanionStore.swift @@ -93,6 +93,7 @@ final class CompanionStore { defaults.set(shopDifficulty, forKey: "shopDifficulty") migratePokemonProfilesIfNeeded() refreshRepresentativeSubject() + refreshTeamKeySet() if state.active != nil { displayState = .idle } } @@ -804,6 +805,7 @@ final class CompanionStore { eventUntil = clock().addingTimeInterval(6) state.active = nil state.reconcileRepresentativeSelection() // 졸업 체인이 dex 로 옮겨져 선택은 정상적으로 유지된다 + state.reconcileTeamSelection() activeGeneration += 1 currentLine = nil state.eggUsage = 0 // 새 알은 처음부터 인큐베이션 @@ -1040,6 +1042,7 @@ final class CompanionStore { // 놓아준 종도 이제 dex 에 있으므로 대표 선택은 유지된다. 손상 상태 파일 등으로 정말 보유가 // 끊긴 경우만 자동 추적으로 복귀한다. state.reconcileRepresentativeSelection() + state.reconcileTeamSelection() // a released Pokémon leaves the team activeGeneration += 1 currentLine = nil state.eggUsage = 0 // 새 알은 처음부터 인큐베이션(재부화에 5M 필요) @@ -1701,6 +1704,194 @@ final class CompanionStore { completedGrowth + min(SaveTransfer.maxTokenValue, max(0, currentStageUsage))) } + // MARK: Trainer card + + struct TrainerCardStats: Equatable, Sendable { + let lifetimeTokens: Int + let speciesCount: Int + let speciesTotal: Int + let shinyCount: Int + let graduatedCount: Int + /// Individuals from a line already in the log — what a second Pikachu adds to the collection. + let duplicateCount: Int + let firstCatch: Date? + let rarityCounts: [Rarity: Int] + } + + /// Catch log counts, the raised Pokémon included, next to the Pokédex species count. + /// + /// Individuals you let go are out of every individual count: they are gone, so crediting the + /// card with a released shiny — or calling it a duplicate — would count what you no longer have. + /// The Pokédex line is the exception, and not the card's call: `dexSpecies` folds **owned + /// species** whatever became of the individual, so a released Pokémon keeps its cells there. + var trainerCardStats: TrainerCardStats { + let kept = dexEntries.filter { !$0.isReleased } + // Hatching only draws bases in the animated range, but a chain can evolve past it (Sylveon + // from Eevee). Those extras are kept out of the count so it never goes over the total. + let counted = PokemonAssets.animatedSpeciesIDs + return TrainerCardStats( + lifetimeTokens: state.usedSinceInstall, + speciesCount: dexSpecies.lazy.filter { counted.contains($0.id) }.count, + speciesTotal: counted.count, + shinyCount: kept.lazy.filter(\.isShiny).count, + graduatedCount: state.dex.lazy.filter { !$0.isReleased }.count, + duplicateCount: kept.count - Set(kept.map(\.baseID)).count, + firstCatch: kept.compactMap(\.caughtAt).min(), + rarityCounts: Dictionary(grouping: kept, by: \.rarity).mapValues(\.count)) + } + + /// Who the card puts in the frame: the pinned species, or the raised Pokémon when none is + /// pinned. Pinning is a species choice, so the stage and nature of an individual are dropped + /// for the rarity — the card would otherwise print the raised Pokémon's details under another + /// species' sprite. + struct TrainerCardSubject: Equatable, Sendable { + let speciesID: Int? + let isShiny: Bool + let name: String + let detail: String + var unownForm: UnownForm? = nil + } + + var cardSubject: TrainerCardSubject { + let subject = representativeSubject + guard let pinned = state.representativeSpeciesID else { + let detail = ([stageText] + [currentNature?.name(state.language)].compactMap { $0 }) + .filter { !$0.isEmpty }.joined(separator: " · ") + return TrainerCardSubject(speciesID: subject.speciesID, isShiny: subject.isShiny, + name: displayName, detail: detail, + unownForm: subject.unownForm) + } + // The main Pokédex aggregates the Unown forms, so the pinned letter only reaches the card + // through the representative subject: without it the frame prints another letter's sprite. + let species = dexSpecies.first { $0.id == pinned } + return TrainerCardSubject(speciesID: pinned, isShiny: subject.isShiny, + name: UnownForm.displayName(species?.name ?? "#\(pinned)", + speciesID: pinned, + form: subject.unownForm), + detail: species.map { l.rarityLabel($0.rarity) } ?? "", + unownForm: subject.unownForm) + } + + var trainerID: Int? { state.trainerID } + var trainerName: String { state.trainerName } + + /// Assigned on first use, so a save that never opens the card is left as it was. + func ensureTrainerID() { + guard state.trainerID == nil else { return } + state.trainerID = Int.random(in: TrainerCard.idRange) + save() + } + + func setTrainerName(_ name: String) { + let trimmed = String(name.trimmingCharacters(in: .whitespacesAndNewlines).prefix(TrainerCard.nameLimit)) + guard trimmed != state.trainerName else { return } + state.trainerName = trimmed + save() + } + + /// A catch log row's team id. The raised Pokémon uses its profile id, which its graduated entry + /// keeps, so it stays in the team after graduating. Released individuals cannot join. + func teamKey(for entry: DexEntry) -> String? { + guard !entry.isReleased else { return nil } + return isActiveDexEntry(entry) ? state.active?.profile?.instanceID : entry.id + } + + /// Everyone who may join, in catch log order. + var teamCandidates: [DexEntry] { dexEntriesSorted.filter { teamKey(for: $0) != nil } } + + var isTeamPicked: Bool { !state.teamIsAutomatic } + + /// Team membership for the catch log badges, folded once at the save boundary like + /// `representativeSubject`. Every row body asks whether it is in the team, and the automatic + /// team sorts the whole log to answer, so asking per row would sort it once per visible row. + private(set) var teamKeySet: Set = [] + + private func refreshTeamKeySet() { + let next = Set(trainerTeamKeys) + if teamKeySet != next { teamKeySet = next } + } + + /// The card's team in slot order. Until the user edits it, it is the rarest graduates with + /// shinies first, so the card needs no setup. + var trainerTeam: [DexEntry] { + let candidates = teamCandidates + guard !state.teamIsAutomatic else { + return Array(candidates.filter { !isActiveDexEntry($0) } + .sorted(by: Self.automaticTeamOrder).prefix(TrainerCard.teamSize)) + } + let picked = state.teamEntryIDs ?? [] + var byKey: [String: DexEntry] = [:] + for entry in candidates { if let key = teamKey(for: entry) { byKey[key] = entry } } + return picked.compactMap { byKey[$0] } + } + + private static func automaticTeamOrder(_ a: DexEntry, _ b: DexEntry) -> Bool { + if a.isShiny != b.isShiny { return a.isShiny } + if a.rarity != b.rarity { return a.rarity.sortRank > b.rarity.sortRank } + return (a.caughtAt ?? .distantPast) > (b.caughtAt ?? .distantPast) + } + + var trainerTeamKeys: [String] { trainerTeam.compactMap(teamKey(for:)) } + + func isInTeam(_ entry: DexEntry) -> Bool { + guard let key = teamKey(for: entry) else { return false } + return teamKeySet.contains(key) + } + + var isTeamFull: Bool { teamKeySet.count >= TrainerCard.teamSize } + + /// Adds an individual, or puts it in `slot` in place of the member there. Every edit starts from + /// the team as shown, so the first one keeps the automatic picks where they were. + @discardableResult + func addToTeam(_ entry: DexEntry, slot: Int? = nil) -> Bool { + guard let key = teamKey(for: entry) else { return false } + var keys = trainerTeamKeys + guard !keys.contains(key) else { return false } + if let slot, keys.indices.contains(slot) { + keys[slot] = key + } else { + guard keys.count < TrainerCard.teamSize else { return false } + keys.append(key) + } + storeTeam(keys) + return true + } + + func removeFromTeam(_ entry: DexEntry) { + guard let key = teamKey(for: entry) else { return } + var keys = trainerTeamKeys + guard let index = keys.firstIndex(of: key) else { return } + keys.remove(at: index) + storeTeam(keys) + } + + func moveInTeam(_ entry: DexEntry, by offset: Int) { + var keys = trainerTeamKeys + guard let key = teamKey(for: entry), let from = keys.firstIndex(of: key), + keys.indices.contains(from + offset) else { return } + keys.swapAt(from, from + offset) + storeTeam(keys) + } + + /// Switches the team between automatic and picked. Turning it off the first time keeps the + /// automatic line-up as a starting point; afterwards both directions come back to what was + /// last shown, so the switch is never destructive. + func setAutomaticTeam(_ automatic: Bool) { + guard automatic != state.teamIsAutomatic else { return } + let shown = trainerTeamKeys + state.teamIsAutomatic = automatic + if !automatic, state.teamEntryIDs == nil { state.teamEntryIDs = shown } + state.reconcileTeamSelection() + save() + } + + private func storeTeam(_ keys: [String]) { + state.teamEntryIDs = keys + state.teamIsAutomatic = false // any edit is a pick + state.reconcileTeamSelection() + save() + } + // MARK: 영속 private func load() { guard let data = try? Data(contentsOf: fileURL) else { return } // 파일 없음 = 신규 설치 @@ -1720,6 +1911,7 @@ final class CompanionStore { } private func save() { refreshRepresentativeSubject() + refreshTeamKeySet() guard let data = try? JSONEncoder().encode(state) else { return } try? data.write(to: fileURL, options: .atomic) // 부분 쓰기 손상 방지(펫 상태) } diff --git a/Sources/PokeTokenBar/Core/Localization.swift b/Sources/PokeTokenBar/Core/Localization.swift index be0e78c3..e7ceab80 100644 --- a/Sources/PokeTokenBar/Core/Localization.swift +++ b/Sources/PokeTokenBar/Core/Localization.swift @@ -727,6 +727,40 @@ 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") } + // MARK: Trainer card + var trainerCardTitle: String { t("트레이너 카드", "Trainer Card", "トレーナーカード", "Tarjeta de entrenador", "Carte de Dresseur", "Cartão de Treinador", "Trainerpass") } + func trainerIDLabel(_ id: String) -> String { t("ID No. \(id)", "ID No. \(id)", "IDNo. \(id)", "N.º ID \(id)", "N° ID \(id)", "N.º ID \(id)", "ID-Nr. \(id)") } + var trainerNameLabel: String { t("이름", "Name", "なまえ", "Nombre", "Nom", "Nome", "Name") } + var trainerNamePlaceholder: String { t("트레이너 이름 (선택)", "Trainer name (optional)", "トレーナー名(任意)", "Nombre de entrenador (opcional)", "Nom de dresseur (facultatif)", "Nome de treinador (opcional)", "Trainername (optional)") } + var trainerTokensLabel: String { t("토큰", "Tokens", "トークン", "Tokens", "Tokens", "Tokens", "Tokens") } + var trainerGraduatedLabel: String { t("졸업", "Graduated", "卒業", "Graduados", "Diplômés", "Formados", "Abgeschlossen") } + var trainerDuplicatesLabel: String { t("중복", "Duplicates", "重複", "Duplicados", "Doublons", "Duplicados", "Doppelte") } + var trainerFirstCatchLabel: String { t("첫 포획", "First catch", "初捕獲", "Primera captura", "Première capture", "Primeira captura", "Erster Fang") } + var trainerTeamTitle: String { t("팀", "Team", "チーム", "Equipo", "Équipe", "Equipe", "Team") } + var trainerTeamAuto: String { t("자동", "Auto", "自動", "Automático", "Auto", "Automático", "Automatisch") } + var trainerTeamPicked: String { t("직접 고름", "Picked by you", "自分で選択", "Elegido por ti", "Choisie par toi", "Escolhida por você", "Selbst gewählt") } + var trainerTeamLead: String { t("선두", "Lead", "先頭", "Líder", "En tête", "Líder", "Anführer") } + var trainerTeamAdd: String { t("팀에 추가", "Add to team", "チームに追加", "Añadir al equipo", "Ajouter à l'équipe", "Adicionar à equipe", "Zum Team hinzufügen") } + var trainerTeamRemove: String { t("팀에서 빼기", "Remove from team", "チームから外す", "Quitar del equipo", "Retirer de l'équipe", "Remover da equipe", "Aus dem Team entfernen") } + var trainerTeamReplace: String { t("교체…", "Replace…", "入れ替え…", "Reemplazar…", "Remplacer…", "Substituir…", "Ersetzen…") } + var trainerTeamMoveLeft: String { t("왼쪽으로 이동", "Move left", "左へ移動", "Mover a la izquierda", "Déplacer à gauche", "Mover para a esquerda", "Nach links") } + var trainerTeamMoveRight: String { t("오른쪽으로 이동", "Move right", "右へ移動", "Mover a la derecha", "Déplacer à droite", "Mover para a direita", "Nach rechts") } + var trainerTeamFull: String { t("팀이 가득 찼어요 (6마리)", "The team is full (6)", "チームは満員です(6匹)", "El equipo está completo (6)", "L'équipe est complète (6)", "A equipe está completa (6)", "Das Team ist voll (6)") } + var trainerTeamUseAuto: String { t("자동 팀", "Automatic team", "自動チーム", "Equipo automático", "Équipe automatique", "Equipe automática", "Automatisches Team") } + var trainerTeamAutoHelp: String { t("직접 고른 팀은 그대로 보관돼요 — 끄면 다시 나타나요.", + "Your picked team is kept and comes back when you turn this off.", + "自分で選んだチームは保存され、オフにすると戻ります。", + "Tu equipo elegido se guarda y vuelve al desactivar esto.", + "Ton équipe choisie est gardée, elle revient si tu décoches.", + "Sua equipe escolhida fica guardada e volta ao desmarcar.", + "Dein gewähltes Team bleibt gespeichert und kommt zurück.") } + var trainerTeamPickTitle: String { t("포켓몬 고르기", "Pick a Pokémon", "ポケモンを選ぶ", "Elige un Pokémon", "Choisis un Pokémon", "Escolha um Pokémon", "Wähle ein Pokémon") } + var trainerTeamNoCandidates: String { t("팀에 넣을 수 있는 포켓몬이 없어요.", "No Pokémon left to add.", "追加できるポケモンがいません。", "No quedan Pokémon por añadir.", "Plus aucun Pokémon à ajouter.", "Não há mais Pokémon para adicionar.", "Keine Pokémon mehr zum Hinzufügen.") } + var trainerCardShowTokens: String { t("토큰 표시", "Show tokens", "トークンを表示", "Mostrar tokens", "Afficher les tokens", "Mostrar tokens", "Tokens anzeigen") } + var trainerCardCopy: String { t("이미지 복사", "Copy image", "画像をコピー", "Copiar imagen", "Copier l'image", "Copiar imagem", "Bild kopieren") } + var trainerCardCopied: String { t("복사했어요", "Copied", "コピーしました", "Copiada", "Copiée", "Copiada", "Kopiert") } + var trainerCardSave: String { t("저장…", "Save…", "保存…", "Guardar…", "Enregistrer…", "Salvar…", "Sichern…") } + var trainerCardCopyMenu: String { t("트레이너 카드 복사", "Copy Trainer Card", "トレーナーカードをコピー", "Copiar tarjeta de entrenador", "Copier la carte de dresseur", "Copiar cartão de treinador", "Trainerpass kopieren") } // MARK: Pokémon 상세 var loadingPokemonDetails: String { t("포켓몬 정보를 불러오는 중…", "Loading Pokémon details…", "ポケモン情報を読み込み中…", "Cargando detalles del Pokémon…", "Chargement des détails du Pokémon…", "Carregando detalhes do Pokémon…", "Pokémon-Details werden geladen…") } var pokemonDetailsUnavailable: String { t("포켓몬 정보를 불러오지 못했어요.", "Pokémon details could not be loaded.", "ポケモン情報を読み込めませんでした。", "No se pudieron cargar los detalles.", "Impossible de charger les détails.", "Não foi possível carregar os detalhes.", "Pokémon-Details konnten nicht geladen werden.") } diff --git a/Sources/PokeTokenBar/Core/SaveTransfer.swift b/Sources/PokeTokenBar/Core/SaveTransfer.swift index df0bf394..261614af 100644 --- a/Sources/PokeTokenBar/Core/SaveTransfer.swift +++ b/Sources/PokeTokenBar/Core/SaveTransfer.swift @@ -171,6 +171,9 @@ enum SaveTransfer { } for index in s.dex.indices { s.dex[index].profile?.sanitize() } s.reconcileRepresentativeSelection() + s.reconcileTeamSelection() + if let id = s.trainerID, !TrainerCard.idRange.contains(id) { s.trainerID = nil } + s.trainerName = String(s.trainerName.prefix(TrainerCard.nameLimit)) return s } diff --git a/Sources/PokeTokenBar/PokeTokenBarApp.swift b/Sources/PokeTokenBar/PokeTokenBarApp.swift index 1bff0bbe..d5b00e1e 100644 --- a/Sources/PokeTokenBar/PokeTokenBarApp.swift +++ b/Sources/PokeTokenBar/PokeTokenBarApp.swift @@ -87,7 +87,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { floatingPet = FloatingPetController( store: store, companion: companion, onOpenPopover: { [weak self] in self?.openPopover() }, - onHide: { [weak self] in self?.store.floatingPetEnabled = false } + onHide: { [weak self] in self?.store.floatingPetEnabled = false }, + onCopyCard: { [weak self] in + guard let companion = self?.companion else { return } + Task { [weak self] in + // The menu closes on click, so the pet itself says the card is on the clipboard. + guard await TrainerCardExport.copy(store: companion) else { return } + self?.floatingPet?.flashCallout(companion.l.trainerCardCopied) + } + } ) // 데스크톱 플로팅 펫(옵트인) Task { await updater.check() } // 기동 시 1회 업데이트 확인 diff --git a/Sources/PokeTokenBar/UI/CompanionView.swift b/Sources/PokeTokenBar/UI/CompanionView.swift index 30141696..2eb1b660 100644 --- a/Sources/PokeTokenBar/UI/CompanionView.swift +++ b/Sources/PokeTokenBar/UI/CompanionView.swift @@ -808,14 +808,27 @@ struct CollectionView: View { @Bindable var nav = navigation if store.dexEntries.isEmpty { emptyState // 둘 다 비어 있으니 세그먼트를 그리지 않는다 + } else if nav.showingTrainerCard { + TrainerCardScreen(store: store) { nav.showingTrainerCard = false } + .frame(height: Self.contentHeight) } else { VStack(alignment: .leading, spacing: 8) { - Picker("", selection: $nav.showingCollectionLog) { - Text(store.l.dexTitle).tag(false) - Text(store.l.catchLogTitle).tag(true) + HStack(spacing: 6) { + Picker("", selection: $nav.showingCollectionLog) { + Text(store.l.dexTitle).tag(false) + Text(store.l.catchLogTitle).tag(true) + } + .pickerStyle(.segmented) + .labelsHidden() + .frame(maxWidth: .infinity) + // An icon keeps both segments wide enough for the longest catch log label. + Button { nav.showingTrainerCard = true } label: { + Image(systemName: "person.text.rectangle") + } + .buttonStyle(.bordered) + .help(store.l.trainerCardTitle) + .accessibilityLabel(store.l.trainerCardTitle) } - .pickerStyle(.segmented) - .labelsHidden() if nav.showingCollectionLog { catchLog } else { DexGridView(store: store) } } .frame(height: Self.contentHeight) @@ -1465,6 +1478,8 @@ private struct DexEntryRow: View { /// 카드 안쪽 여백. 진화 라인이 쓸 수 있는 폭 계산과 단일 소스를 공유한다. private static let cardPadding: CGFloat = 8 + private var isInTeam: Bool { store.isInTeam(entry) } + var body: some View { // 저장분 우선(즉시·언어대응), 없으면 async 로 채운 resolved 사용. let names = store.dexStoredChainNames(entry) ?? (resolved.isEmpty ? nil : resolved) @@ -1497,6 +1512,14 @@ private struct DexEntryRow: View { Text("✨").font(.system(size: 10)) .accessibilityLabel(store.l.dexShinyLabel) } + if isInTeam { + Text(store.l.trainerTeamTitle.uppercased()) + .font(.system(size: 8, weight: .bold)) + .padding(.horizontal, 5).padding(.vertical, 1) + .background(Color.accentColor.opacity(0.14)) + .foregroundStyle(Color.accentColor) + .clipShape(Capsule()) + } Spacer() if let nature = entry.nature { Text(nature.name(store.language)) @@ -1514,6 +1537,18 @@ private struct DexEntryRow: View { .padding(Self.cardPadding) .background(Color.secondary.opacity(0.06)) .clipShape(RoundedRectangle(cornerRadius: 10)) + .contextMenu { + if store.teamKey(for: entry) != nil { + if isInTeam { + Button(store.l.trainerTeamRemove) { store.removeFromTeam(entry) } + } else { + Button(store.isTeamFull ? store.l.trainerTeamFull : store.l.trainerTeamAdd) { + store.addToTeam(entry) + } + .disabled(store.isTeamFull) + } + } + } .task(id: "\(entry.id)-\(store.language.rawValue)") { resolved = await store.dexResolveChainNames(entry) } diff --git a/Sources/PokeTokenBar/UI/FloatingPetPanel.swift b/Sources/PokeTokenBar/UI/FloatingPetPanel.swift index 4820e39c..de3f658a 100644 --- a/Sources/PokeTokenBar/UI/FloatingPetPanel.swift +++ b/Sources/PokeTokenBar/UI/FloatingPetPanel.swift @@ -13,6 +13,8 @@ final class FloatingPetController: NSObject, NSWindowDelegate { private let defaults: UserDefaults private var panel: NSPanel? private var hoverPanel: NSPanel? + private var isHovering = false + private var calloutFlash: Task? private var displayAwake = true private var builtAnimated: Bool? private var powerObserver: NSObjectProtocol? @@ -76,14 +78,17 @@ final class FloatingPetController: NSObject, NSWindowDelegate { private var onOpenPopover: (() -> Void)? private var onHide: (() -> Void)? + private var onCopyCard: (() -> Void)? init(store: UsageStore, companion: CompanionStore, defaults: UserDefaults = .standard, - onOpenPopover: (() -> Void)? = nil, onHide: (() -> Void)? = nil) { + onOpenPopover: (() -> Void)? = nil, onHide: (() -> Void)? = nil, + onCopyCard: (() -> Void)? = nil) { self.store = store self.companion = companion self.defaults = defaults self.onOpenPopover = onOpenPopover self.onHide = onHide + self.onCopyCard = onCopyCard super.init() observeSettings() observePowerState() @@ -213,8 +218,10 @@ final class FloatingPetController: NSObject, NSWindowDelegate { FloatingPetView(animated: wantAnimated).environment(store).environment(companion))) hosting.onOpenPopover = onOpenPopover hosting.onHide = onHide + hosting.onCopyCard = onCopyCard hosting.languageProvider = { [weak self] in self?.companion.language ?? .systemDefault } hosting.onHoverChange = { [weak self] hovering in + self?.isHovering = hovering if hovering { self?.showHoverCallout() } else { self?.hideHoverCallout() } } p.contentView = hosting @@ -246,11 +253,25 @@ final class FloatingPetController: NSObject, NSWindowDelegate { l: L(companion.language)) } - private func showHoverCallout() { + /// Confirms an action the pet's menu triggered — the menu closes with nothing else to show. + /// Reuses the hover callout rather than the limit bubble: the bubble resizes the panel and is + /// reserved for quota alerts. + func flashCallout(_ text: String) { + guard panel?.isVisible == true else { return } + calloutFlash?.cancel() + showHoverCallout(text: text) + calloutFlash = Task { [weak self] in + try? await Task.sleep(for: .seconds(1.5)) + guard !Task.isCancelled, let self else { return } + if isHovering { showHoverCallout() } else { hideHoverCallout() } + } + } + + private func showHoverCallout(text: String? = nil) { guard let pet = panel, pet.isVisible else { return } // Don't cover an active limit bubble — the speech bubble is the priority surface. if store.currentBubbleAlert != nil { hideHoverCallout(); return } - let text = currentHoverText() + let text = text ?? currentHoverText() let appearance = NSApp.effectiveAppearance let colors = Self.hoverCalloutColors(for: appearance) let label = NSTextField(labelWithString: text) @@ -284,6 +305,7 @@ final class FloatingPetController: NSObject, NSWindowDelegate { } private func hideHoverCallout() { + calloutFlash?.cancel() hoverPanel?.orderOut(nil) hoverPanel?.contentView = nil } @@ -360,6 +382,7 @@ final class FloatingPetController: NSObject, NSWindowDelegate { final class PetHostingView: NSHostingView { var onOpenPopover: (() -> Void)? var onHide: (() -> Void)? + var onCopyCard: (() -> Void)? var onHoverChange: ((Bool) -> Void)? var languageProvider: () -> AppLanguage = { .systemDefault } @@ -435,11 +458,16 @@ final class PetHostingView: NSHostingView { action: #selector(handleHide(_:)), keyEquivalent: "") hide.target = self hide.isEnabled = true + let copyCard = menu.addItem(withTitle: l.trainerCardCopyMenu, + action: #selector(handleCopyCard(_:)), keyEquivalent: "") + copyCard.target = self + copyCard.isEnabled = onCopyCard != nil NSMenu.popUpContextMenu(menu, with: event, for: self) } @objc func handleOpen(_ sender: Any?) { onOpenPopover?() } @objc func handleHide(_ sender: Any?) { onHide?() } + @objc func handleCopyCard(_ sender: Any?) { onCopyCard?() } } @MainActor diff --git a/Sources/PokeTokenBar/UI/PopoverView.swift b/Sources/PokeTokenBar/UI/PopoverView.swift index f1d43ce5..432a42c1 100644 --- a/Sources/PokeTokenBar/UI/PopoverView.swift +++ b/Sources/PokeTokenBar/UI/PopoverView.swift @@ -22,6 +22,7 @@ final class PopoverNavigation { var tab: PopoverTab = .home /// 일반적인 컬렉션 재진입에는 마지막 세그먼트를 유지하되, 대표 포켓몬 선택 진입점은 도감으로 강제한다. var showingCollectionLog = false + var showingTrainerCard = false /// 프로바이더 탭 선택 — reset() 대상이 아님(팝오버를 다시 열어도 보던 서비스 유지). var providerID: String? /// 설정을 열 때 고급 섹션을 펼친 채로 시작할지. 세션 키 행이 접힌 disclosure 안에 살아서, @@ -45,6 +46,7 @@ final class PopoverNavigation { func openRepresentativeDex() { showSettings = false showingCollectionLog = false + showingTrainerCard = false tab = .collection } } diff --git a/Sources/PokeTokenBar/UI/TrainerCardView.swift b/Sources/PokeTokenBar/UI/TrainerCardView.swift new file mode 100644 index 00000000..0671521e --- /dev/null +++ b/Sources/PokeTokenBar/UI/TrainerCardView.swift @@ -0,0 +1,654 @@ +import AppKit +import SwiftUI +import UniformTypeIdentifiers + +/// The card is always light, so an exported image looks the same in light and dark mode. +enum TrainerCardStyle { + static let size = CGSize(width: PopoverMetrics.contentWidth, height: 196) + static let exportScale: CGFloat = 4 + static let ink = Color(red: 0.13, green: 0.14, blue: 0.17) + static let muted = Color(red: 0.42, green: 0.44, blue: 0.49) + static let paper = Color(white: 0.985) + + /// Common type colors, keyed by PokéAPI type name. + private static let typeColors: [String: UInt32] = [ + "normal": 0xA8A77A, "fire": 0xEE8130, "water": 0x6390F0, "electric": 0xF7D02C, + "grass": 0x7AC74C, "ice": 0x96D9D6, "fighting": 0xC22E28, "poison": 0xA33EA1, + "ground": 0xE2BF65, "flying": 0xA98FF3, "psychic": 0xF95587, "bug": 0xA6B91A, + "rock": 0xB6A136, "ghost": 0x735797, "dragon": 0x6F35FC, "dark": 0x705746, + "steel": 0xB7B7CE, "fairy": 0xD685AD, + ] + + static func accent(forType type: String?) -> NSColor { + guard let type, let rgb = typeColors[type] else { return .systemOrange } + return NSColor(srgbRed: CGFloat(rgb >> 16 & 0xFF) / 255, green: CGFloat(rgb >> 8 & 0xFF) / 255, + blue: CGFloat(rgb & 0xFF) / 255, alpha: 1) + } + + /// Light type colors such as Electric or Ice cannot be read as text on the white card. + static func title(on accent: NSColor) -> Color { + Color(nsColor: accent.blended(withFraction: 0.35, of: .black) ?? accent) + } +} + +struct TrainerCardMember: Identifiable { + let entry: DexEntry + let name: String + var id: String { entry.id } +} + +/// Everything the card prints, read once from the store so the live card and the export match. +@MainActor +struct TrainerCardContent { + let language: AppLanguage + let trainerID: Int? + let trainerName: String + let showsTokens: Bool + let stats: CompanionStore.TrainerCardStats + let companionSpeciesID: Int? + let companionShiny: Bool + let companionName: String + let companionDetail: String + let companionUnownForm: UnownForm? + let types: [String] + let team: [TrainerCardMember] + let teamPicked: Bool + + init(store: CompanionStore, showsTokens: Bool) { + language = store.language + trainerID = store.trainerID + trainerName = store.trainerName + self.showsTokens = showsTokens + stats = store.trainerCardStats + // The pinned Pokémon, or the raised one when nothing is pinned — the card shows the same + // face as the menu bar, so a fresh egg does not take it over. + let subject = store.cardSubject + companionSpeciesID = subject.speciesID + companionShiny = subject.isShiny + companionName = subject.name + companionDetail = subject.detail + companionUnownForm = subject.unownForm + types = subject.speciesID.flatMap { store.pokemonDetailsByID[$0]?.types } ?? [] + team = store.trainerTeam.map { entry in + let name = store.dexStoredChainNames(entry)?[entry.finalID] ?? "#\(entry.finalID)" + return TrainerCardMember(entry: entry, + name: UnownForm.displayName(name, speciesID: entry.finalID, + form: entry.unownForm)) + } + teamPicked = store.isTeamPicked + } + + var l: L { L(language) } + + func firstCatchText() -> String? { + stats.firstCatch.map { date in + let formatter = DateFormatter() + formatter.locale = language.displayLocale + formatter.dateStyle = .medium + formatter.timeStyle = .none + return formatter.string(from: date) + } + } + + var idText: String { + l.trainerIDLabel(trainerID.map { String(format: "%05d", $0) } ?? "-----") + } +} + +@MainActor +private struct TrainerCardFrame: View { + let accent: NSColor + var holo = false + @ViewBuilder let content: Content + + var body: some View { + let base = Color(nsColor: accent) + ZStack { + RoundedRectangle(cornerRadius: 16) + .fill(LinearGradient(colors: [base, base.opacity(0.7)], + startPoint: .topLeading, endPoint: .bottomTrailing)) + if holo { + RoundedRectangle(cornerRadius: 16) + .fill(LinearGradient(colors: ([.pink, .yellow, .mint, .cyan, .purple, .pink] as [Color]) + .map { $0.opacity(0.6) }, + startPoint: .topLeading, endPoint: .bottomTrailing)) + } + RoundedRectangle(cornerRadius: 11).fill(TrainerCardStyle.paper).padding(6) + if holo { + RoundedRectangle(cornerRadius: 11) + .fill(LinearGradient(stops: [.init(color: .clear, location: 0.3), + .init(color: .cyan.opacity(0.12), location: 0.45), + .init(color: .pink.opacity(0.12), location: 0.55), + .init(color: .clear, location: 0.7)], + startPoint: .topLeading, endPoint: .bottomTrailing)) + .padding(6) + } + content.padding(18) + } + .frame(width: TrainerCardStyle.size.width, height: TrainerCardStyle.size.height) + .environment(\.colorScheme, .light) + } +} + +@MainActor +private struct TrainerCardHeader: View { + let title: String + let idText: String + let color: Color + @ViewBuilder var badge: Badge + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text(title.uppercased()) + .font(.system(size: 13, weight: .heavy, design: .rounded)) + .foregroundStyle(color) + .lineLimit(1).minimumScaleFactor(0.7) + badge + Spacer(minLength: 4) + Text(idText) + .font(.system(size: 9, weight: .semibold, design: .monospaced)) + .foregroundStyle(TrainerCardStyle.muted) + .fixedSize() + } + } +} + +@MainActor +struct TrainerCardFront: View { + let content: TrainerCardContent + + var body: some View { + let accent = TrainerCardStyle.accent(forType: content.types.first) + TrainerCardFrame(accent: accent, holo: content.companionShiny) { + VStack(spacing: 6) { + TrainerCardHeader(title: content.l.trainerCardTitle, idText: content.idText, + color: TrainerCardStyle.title(on: accent)) { EmptyView() } + HStack(alignment: .top, spacing: 10) { + let rows = Self.rows(content) + VStack(spacing: 0) { + ForEach(rows.indices, id: \.self) { index in + row(rows[index].label, rows[index].value, last: index == rows.count - 1) + } + } + companion(accent: accent).frame(width: 104) + } + Spacer(minLength: 0) + rarityBar + } + } + } + + static func rows(_ content: TrainerCardContent) -> [(label: String, value: String)] { + let l = content.l + var rows: [(label: String, value: String)] = [] + if !content.trainerName.isEmpty { rows.append((l.trainerNameLabel, content.trainerName)) } + if content.showsTokens { + rows.append((l.trainerTokensLabel, TokenFormatter.compact(content.stats.lifetimeTokens))) + } + rows.append((l.dexTitle, "\(content.stats.speciesCount) / \(content.stats.speciesTotal)")) + rows.append((l.dexShinyLabel, "\(content.stats.shinyCount)")) + rows.append((l.trainerGraduatedLabel, "\(content.stats.graduatedCount)")) + rows.append((l.trainerDuplicatesLabel, "\(content.stats.duplicateCount)")) + if let firstCatch = content.firstCatchText() { rows.append((l.trainerFirstCatchLabel, firstCatch)) } + return rows + } + + private func row(_ label: String, _ value: String, last: Bool) -> some View { + VStack(spacing: 0) { + HStack(spacing: 4) { + Text(label).font(.system(size: 9, weight: .medium)).foregroundStyle(TrainerCardStyle.muted) + .lineLimit(1) + Spacer(minLength: 4) + Text(value).font(.system(size: 10.5, weight: .bold, design: .rounded)) + .foregroundStyle(TrainerCardStyle.ink) + .lineLimit(1).minimumScaleFactor(0.7) + } + .padding(.vertical, 2) + if !last { Rectangle().fill(TrainerCardStyle.muted.opacity(0.18)).frame(height: 1) } + } + } + + private func companion(accent: NSColor) -> some View { + VStack(spacing: 3) { + ZStack { + Circle().fill(Color(nsColor: accent).opacity(0.14)) + Circle().strokeBorder(Color(nsColor: accent).opacity(0.5), lineWidth: 1.5) + SpriteView(speciesID: content.companionSpeciesID, size: 54, animated: true, + shiny: content.companionShiny, unownForm: content.companionUnownForm) + } + .frame(width: 70, height: 70) + HStack(spacing: 2) { + Text(content.companionName) + .font(.system(size: 10.5, weight: .bold, design: .rounded)) + .foregroundStyle(TrainerCardStyle.ink) + .lineLimit(1).minimumScaleFactor(0.7) + if content.companionShiny { Text("✨").font(.system(size: 8)) } + } + if !content.companionDetail.isEmpty { + Text(content.companionDetail) + .font(.system(size: 8)).foregroundStyle(TrainerCardStyle.muted) + .lineLimit(1).minimumScaleFactor(0.7) + } + HStack(spacing: 3) { + ForEach(content.types.prefix(2), id: \.self) { type in + PokemonNameLabel(.type, type, language: content.language) + .textCase(.uppercase) + .font(.system(size: 7.5, weight: .heavy, design: .rounded)) + .foregroundStyle(.white) + .lineLimit(1) + .padding(.horizontal, 5).padding(.vertical, 1) + .background(Capsule().fill(Color(nsColor: TrainerCardStyle.accent(forType: type)))) + } + } + } + } + + private var rarityBar: some View { + // Grouping the log never yields a zero, so a present key always means a visible slice. + let counts = rarityDisplayOrder.compactMap { rarity in + content.stats.rarityCounts[rarity].map { (rarity, $0) } + } + let total = max(1, counts.reduce(0) { $0 + $1.1 }) + return VStack(alignment: .leading, spacing: 3) { + GeometryReader { geo in + HStack(spacing: 2) { + ForEach(counts, id: \.0) { rarity, count in + Capsule().fill(rarityColor(rarity)) + .frame(width: max(4, (geo.size.width - CGFloat(counts.count * 2)) + * CGFloat(count) / CGFloat(total))) + } + } + } + .frame(height: 5) + HStack(spacing: 4) { + Text(counts.map { "\($0.1) \(content.l.rarityLabel($0.0))" }.joined(separator: " · ")) + .font(.system(size: 8)).foregroundStyle(TrainerCardStyle.muted) + .lineLimit(1).minimumScaleFactor(0.7) + Spacer(minLength: 4) + Text("PokeTokenBar") + .font(.system(size: 8, weight: .semibold, design: .rounded)) + .foregroundStyle(TrainerCardStyle.muted.opacity(0.8)) + .fixedSize() + } + } + } +} + +/// Team edits the card back offers. nil on an exported card, which has no controls. +struct TrainerTeamActions { + /// Opens the picker: a slot index replaces that member, nil fills the next free slot. + let pick: (Int?) -> Void + let remove: (DexEntry) -> Void + let move: (DexEntry, Int) -> Void +} + +@MainActor +struct TrainerCardBack: View { + let content: TrainerCardContent + var actions: TrainerTeamActions? + + var body: some View { + let accent = TrainerCardStyle.accent(forType: content.types.first) + let title = TrainerCardStyle.title(on: accent) + // Both sides carry the holo frame, so a shiny card stays a shiny card when it is flipped. + TrainerCardFrame(accent: accent, holo: content.companionShiny) { + VStack(spacing: 6) { + TrainerCardHeader(title: content.l.trainerTeamTitle, idText: content.idText, color: title) { + Text(content.teamPicked ? content.l.trainerTeamPicked : content.l.trainerTeamAuto) + .font(.system(size: 7.5, weight: .semibold)) + .foregroundStyle(content.teamPicked ? title : TrainerCardStyle.muted) + .lineLimit(1) + .padding(.horizontal, 5).padding(.vertical, 1.5) + .overlay(Capsule().strokeBorder(content.teamPicked ? title : TrainerCardStyle.muted, + lineWidth: 0.8)) + .fixedSize() + } + VStack(spacing: 6) { + ForEach(0..<2, id: \.self) { row in + HStack(spacing: 6) { + ForEach(0..<3, id: \.self) { column in + let slot = row * 3 + column + slotView(slot, title: title) + } + } + } + } + } + } + } + + @ViewBuilder + private func slotView(_ slot: Int, title: Color) -> some View { + let member = content.team.indices.contains(slot) ? content.team[slot] : nil + let shape = RoundedRectangle(cornerRadius: 9) + ZStack { + shape.fill(.white) + if let member { + shape.strokeBorder(rarityColor(member.entry.rarity), lineWidth: 1.5) + VStack(spacing: 0) { + SpriteView(speciesID: member.entry.finalID, size: 36, animated: true, + shiny: member.entry.isShiny, unownForm: member.entry.unownForm) + Text(member.name) + .font(.system(size: 9, weight: .bold, design: .rounded)) + .foregroundStyle(TrainerCardStyle.ink) + .lineLimit(1).minimumScaleFactor(0.7) + Text(content.l.rarityLabel(member.entry.rarity)) + .font(.system(size: 7.5, weight: .semibold)) + .foregroundStyle(rarityColor(member.entry.rarity)) + .lineLimit(1) + } + .padding(.horizontal, 4) + VStack { + HStack { + if slot == 0 { + Text(content.l.trainerTeamLead.uppercased()) + .font(.system(size: 6.5, weight: .heavy, design: .rounded)) + .foregroundStyle(.white) + .padding(.horizontal, 4).padding(.vertical, 1) + .background(Capsule().fill(title)) + .fixedSize() + } + Spacer(minLength: 0) + if member.entry.isShiny { Text("✨").font(.system(size: 8)) } + } + Spacer(minLength: 0) + } + .padding(4) + } else { + shape.strokeBorder(TrainerCardStyle.muted.opacity(0.35), + style: StrokeStyle(lineWidth: 1, dash: [4, 3])) + VStack(spacing: 2) { + PokeBallIcon(size: 18).grayscale(1).opacity(0.35) + if actions != nil { + Text(content.l.trainerTeamAdd) + .font(.system(size: 7.5, weight: .medium)) + .foregroundStyle(TrainerCardStyle.muted) + .lineLimit(1).minimumScaleFactor(0.7) + } + } + .padding(.horizontal, 4) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .contentShape(shape) + .onTapGesture { actions?.pick(member == nil ? nil : slot) } + // A tap gesture alone is invisible to VoiceOver: name the slot and give it the activation. + .accessibilityElement(children: .combine) + .accessibilityLabel(member.map { "\($0.name) · \(content.l.rarityLabel($0.entry.rarity))" } + ?? content.l.trainerTeamAdd) + .accessibilityAddTraits(actions == nil ? [] : .isButton) + .accessibilityAction { actions?.pick(member == nil ? nil : slot) } + .contextMenu { + if let actions, let member { + Button(content.l.trainerTeamReplace) { actions.pick(slot) } + Button(content.l.trainerTeamMoveLeft) { actions.move(member.entry, -1) } + .disabled(slot == 0) + Button(content.l.trainerTeamMoveRight) { actions.move(member.entry, 1) } + .disabled(slot >= content.team.count - 1) + Divider() + Button(content.l.trainerTeamRemove) { actions.remove(member.entry) } + } + } + .help(member.map { "\($0.name) · \(content.l.rarityLabel($0.entry.rarity))" } ?? content.l.trainerTeamAdd) + } +} + +@MainActor +private struct PokeBallIcon: View { + static let spriteName = "poke-ball" + let size: CGFloat + @State private var image = SpriteLoader.cachedItemImage(name: spriteName) + + var body: some View { + Group { + if let image { + Image(nsImage: image).resizable().interpolation(.none) + } else { + Circle().strokeBorder(TrainerCardStyle.muted, lineWidth: 1.5) + } + } + .frame(width: size, height: size) + .task { + guard image == nil else { return } + image = await SpriteLoader.itemImage(name: Self.spriteName) + } + } +} + +/// Collection tab screen: the card, its team picker and the export buttons. +@MainActor +struct TrainerCardScreen: View { + let store: CompanionStore + let onBack: () -> Void + @AppStorage(TrainerCardExport.showsTokensKey) private var showsTokens = true + @State private var nameDraft: String + /// Non-nil while picking. The inner value is the slot to replace, nil to fill the next one. + @State private var picking: Int?? + @State private var copied = false + + init(store: CompanionStore, onBack: @escaping () -> Void) { + self.store = store + self.onBack = onBack + _nameDraft = State(initialValue: store.trainerName) + } + + var body: some View { + let l = store.l + VStack(alignment: .leading, spacing: 8) { + HStack { + Button { + if picking != nil { picking = nil } else { onBack() } + } label: { + Label(l.back, systemImage: "chevron.left") + } + .buttonStyle(.borderless) + Spacer() + Text(picking == nil ? l.trainerCardTitle : l.trainerTeamPickTitle) + .font(.callout.weight(.semibold)) + } + if let slot = picking { + TrainerTeamPicker(store: store) { entry in + store.addToTeam(entry, slot: slot) + picking = nil + } + } else { + card + controls + Spacer(minLength: 0) + } + } + .task { + store.ensureTrainerID() + if let id = store.cardSubject.speciesID { await store.loadPokemonDetails(speciesID: id) } + await store.backfillMissingDexNames() + } + } + + private var card: some View { + let content = TrainerCardContent(store: store, showsTokens: showsTokens) + return VStack(spacing: 8) { + TrainerCardFront(content: content) + TrainerCardBack(content: content, actions: TrainerTeamActions( + pick: { slot in picking = .some(slot) }, + remove: { store.removeFromTeam($0) }, + move: { store.moveInTeam($0, by: $1) })) + } + } + + private var controls: some View { + let l = store.l + return VStack(spacing: 6) { + HStack(spacing: 8) { + TextField(l.trainerNamePlaceholder, text: $nameDraft) + .textFieldStyle(.roundedBorder) + .controlSize(.small) + .onChange(of: nameDraft) { + if nameDraft.count > TrainerCard.nameLimit { + nameDraft = String(nameDraft.prefix(TrainerCard.nameLimit)) + } + store.setTrainerName(nameDraft) + } + Toggle(l.trainerCardShowTokens, isOn: $showsTokens) + .toggleStyle(.checkbox) + .controlSize(.small) + .fixedSize() + } + HStack(spacing: 6) { + Button(copied ? l.trainerCardCopied : l.trainerCardCopy) { + Task { + guard await TrainerCardExport.copy(store: store, showsTokens: showsTokens) else { return } + copied = true + try? await Task.sleep(for: .seconds(1.5)) + copied = false + } + } + Button(l.trainerCardSave) { + Task { await TrainerCardExport.save(store: store, showsTokens: showsTokens) } + } + Spacer(minLength: 4) + // A toggle, not a reset button: the picked line-up is kept and comes back. + Toggle(l.trainerTeamUseAuto, isOn: Binding(get: { !store.isTeamPicked }, + set: { store.setAutomaticTeam($0) })) + .toggleStyle(.checkbox) + .fixedSize() + .help(l.trainerTeamAutoHelp) + } + .controlSize(.small) + } + } +} + +@MainActor +private struct TrainerTeamPicker: View { + let store: CompanionStore + let onPick: (DexEntry) -> Void + + var body: some View { + let inTeam = Set(store.trainerTeamKeys) + let candidates = store.teamCandidates.filter { store.teamKey(for: $0).map { !inTeam.contains($0) } ?? false } + if candidates.isEmpty { + Text(store.l.trainerTeamNoCandidates) + .font(.caption).foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ScrollView { + LazyVStack(spacing: 6) { + ForEach(candidates) { entry in + Button { onPick(entry) } label: { row(entry) } + .buttonStyle(.plain) + } + } + } + .frame(maxHeight: .infinity) + } + } + + private func row(_ entry: DexEntry) -> some View { + HStack(spacing: 8) { + SpriteView(speciesID: entry.finalID, size: 36, shiny: entry.isShiny, + unownForm: entry.unownForm) + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 4) { + Text(UnownForm.displayName(store.dexStoredChainNames(entry)?[entry.finalID] + ?? "#\(entry.finalID)", + speciesID: entry.finalID, form: entry.unownForm)) + .font(.callout.weight(.semibold)) + if entry.isShiny { Text("✨").font(.system(size: 10)) } + } + HStack(spacing: 4) { + Text(store.l.rarityLabel(entry.rarity).uppercased()) + .font(.system(size: 8, weight: .bold)) + .padding(.horizontal, 5).padding(.vertical, 1) + .background(rarityColor(entry.rarity)).foregroundStyle(.white) + .clipShape(Capsule()) + if store.isActiveDexEntry(entry) { + Text(store.l.dexRaising.uppercased()) + .font(.system(size: 8, weight: .bold)) + .padding(.horizontal, 5).padding(.vertical, 1) + .background(Color.accentColor.opacity(0.14)) + .foregroundStyle(Color.accentColor) + .clipShape(Capsule()) + } + } + } + Spacer(minLength: 4) + if let nature = entry.nature { + Text(nature.name(store.language)).font(.system(size: 9)).foregroundStyle(.secondary) + } + } + .padding(6) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 8)) + .contentShape(RoundedRectangle(cornerRadius: 8)) + } +} + +/// Renders the card to PNG. ImageRenderer draws a single pass and never runs a view's loading task, +/// so every sprite and type name the card needs is cached first. +@MainActor +enum TrainerCardExport { + static let showsTokensKey = "trainerCardShowsTokens" + + static var showsTokens: Bool { + UserDefaults.standard.object(forKey: showsTokensKey) as? Bool ?? true + } + + static func sheet(_ content: TrainerCardContent) -> some View { + VStack(spacing: 12) { + TrainerCardFront(content: content) + TrainerCardBack(content: content) + } + .padding(12) + .environment(\.locale, content.language.displayLocale) + } + + static func pngData(store: CompanionStore, showsTokens: Bool) async -> Data? { + store.ensureTrainerID() + if let id = store.cardSubject.speciesID { await store.loadPokemonDetails(speciesID: id) } + await preload(TrainerCardContent(store: store, showsTokens: showsTokens)) + let renderer = ImageRenderer(content: sheet(TrainerCardContent(store: store, showsTokens: showsTokens))) + renderer.scale = TrainerCardStyle.exportScale + guard let image = renderer.cgImage else { return nil } + return NSBitmapImageRep(cgImage: image).representation(using: .png, properties: [:]) + } + + private static func preload(_ content: TrainerCardContent) async { + let sprites = [(content.companionSpeciesID, content.companionShiny)] + + content.team.map { (Optional($0.entry.finalID), $0.entry.isShiny) } + for (speciesID, shiny) in sprites { + guard let speciesID else { + _ = await SpriteLoader.eggImage() + continue + } + _ = await SpriteLoader.animationFrames(speciesID: speciesID, shiny: shiny) + _ = await SpriteLoader.image(speciesID: speciesID, shiny: shiny) + } + _ = await SpriteLoader.itemImage(name: PokeBallIcon.spriteName) + for type in content.types { + _ = await PokemonNameDisplayStore.shared.load(.init(kind: .type, name: type), + provider: PokemonNameClient.shared) + } + } + + @discardableResult + static func copy(store: CompanionStore, showsTokens: Bool = TrainerCardExport.showsTokens) async -> Bool { + guard let png = await pngData(store: store, showsTokens: showsTokens) else { return false } + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + return pasteboard.setData(png, forType: .png) + } + + static func save(store: CompanionStore, showsTokens: Bool) async { + guard let png = await pngData(store: store, showsTokens: showsTokens) else { return } + let panel = NSSavePanel() + panel.allowedContentTypes = [.png] + panel.nameFieldStringValue = "trainer-card.png" + NSApp.activate(ignoringOtherApps: true) + guard panel.runModal() == .OK, let url = panel.url else { return } + do { + try png.write(to: url, options: .atomic) + } catch { + AppLog.write("trainer card save failed: \(error)") + } + } +} diff --git a/Tests/PokeTokenBarTests/SaveTransferTests.swift b/Tests/PokeTokenBarTests/SaveTransferTests.swift index 63a3e5ff..3eb0529a 100644 --- a/Tests/PokeTokenBarTests/SaveTransferTests.swift +++ b/Tests/PokeTokenBarTests/SaveTransferTests.swift @@ -530,7 +530,9 @@ final class SaveTransferTests: XCTestCase { let progress: Set = ["usedSinceInstall", "spentTokens", "eggUsage", "eggTier", "pendingHatchID", "pendingUnownForm", "active", "representativeSpeciesID", "representativeUnownForm", "dex", - "collectedFinals", "inventory"] + "collectedFinals", "inventory", + // The trainer card belongs to the player, like the representative pick. + "teamEntryIDs", "teamIsAutomatic", "trainerID", "trainerName"] let deviceLedger: Set = ["installBaselineSet", "claimedTodayTokensByProvider", "lastDate"] let accountLedger: Set = ["candyGrantTier", "candyFeatureSeeded"] let devicePreference: Set = ["language"] diff --git a/Tests/PokeTokenBarTests/TrainerCardTests.swift b/Tests/PokeTokenBarTests/TrainerCardTests.swift new file mode 100644 index 00000000..bd86f424 --- /dev/null +++ b/Tests/PokeTokenBarTests/TrainerCardTests.swift @@ -0,0 +1,475 @@ +import AppKit +import SwiftUI +import XCTest +@testable import PokeTokenBar + +private struct CardLineProvider: PokeProviding { + let line = EvoLine( + baseID: 1, + tree: EvoNode(speciesID: 1, children: [EvoNode(speciesID: 2, children: [EvoNode(speciesID: 3, children: [])])]), + rarity: .common, + names: [1: ["en": "P1"], 2: ["en": "P2"], 3: ["en": "P3"]]) + func line(baseSpeciesID: Int) async throws -> EvoLine { line } + func baseSpeciesIndex() async throws -> [BaseSpecies] { [BaseSpecies(id: 1, captureRate: 255)] } +} + +private let cardNow = Date(timeIntervalSince1970: 1_700_000_000) + +private func graduate(_ id: String, species: Int, rarity: Rarity = .common, shiny: Bool = false, + daysAgo: Double, released: Bool = false) -> DexEntry { + let date = cardNow.addingTimeInterval(-daysAgo * 86_400) + return DexEntry(id: id, baseID: species, finalID: species, chainOrder: [species], rarity: rarity, + caughtAt: date, isShiny: shiny, names: [species: ["en": "S\(species)"]], + releasedAt: released ? date : nil) +} + +@MainActor +final class TrainerCardTests: XCTestCase { + // Immutable and set inline: `setUp`/`tearDown` are nonisolated, so a @MainActor test cannot + // touch a mutable property from them (Swift 6.0 rejects it, 6.2 lets it through). + private let url = FileManager.default.temporaryDirectory + .appendingPathComponent("card-\(UUID().uuidString).json") + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: url) + } + + private func store(dex: [DexEntry], active: MonState? = nil, extra: String = "", + used: Int = 0) throws -> CompanionStore { + let dexJSON = String(decoding: try JSONEncoder().encode(dex), as: UTF8.self) + let activeJSON = try active.map { String(decoding: try JSONEncoder().encode($0), as: UTF8.self) } ?? "null" + let json = #"{"installBaselineSet":true,"usedSinceInstall":\#(used),"lastDate":"d","language":"en","dex":\#(dexJSON),"active":\#(activeJSON)\#(extra)}"# + try Data(json.utf8).write(to: url) + return reload() + } + + private func reload() -> CompanionStore { + CompanionStore(provider: CardLineProvider(), clock: { cardNow }, fileURL: url, rng: SeededRNG(seed: 7)) + } + + private var eightGraduates: [DexEntry] { + [graduate("common-old", species: 10, daysAgo: 9), + graduate("common-new", species: 11, daysAgo: 1), + graduate("rare", species: 12, rarity: .rare, daysAgo: 5), + graduate("legend", species: 13, rarity: .legendary, daysAgo: 7), + graduate("shiny-common", species: 14, shiny: true, daysAgo: 8), + graduate("uncommon", species: 15, rarity: .uncommon, daysAgo: 3), + graduate("released-legend", species: 16, rarity: .legendary, daysAgo: 2, released: true), + graduate("common-mid", species: 17, daysAgo: 4)] + } + + // MARK: Team + + func testTheAutomaticTeamListsShiniesThenTheRarestGraduates() throws { + let raised = MonState(baseID: 1, pathIDs: [1], stageIndex: 0, usedAtStage: 0, + rarity: .legendary, totalForms: 3, isShiny: true) + let s = try store(dex: eightGraduates, active: raised) + + XCTAssertFalse(s.isTeamPicked) + XCTAssertEqual(s.trainerTeam.map(\.id), + ["shiny-common", "legend", "rare", "uncommon", "common-new", "common-mid"], + "the raised Pokémon and released ones stay out, newest first on ties") + XCTAssertTrue(s.isTeamFull) + } + + func testTheFirstEditKeepsTheAutomaticPicksInPlace() throws { + let s = try store(dex: eightGraduates) + let rare = try XCTUnwrap(s.trainerTeam.first { $0.id == "rare" }) + + s.removeFromTeam(rare) + + XCTAssertTrue(s.isTeamPicked) + XCTAssertEqual(s.trainerTeam.map(\.id), + ["shiny-common", "legend", "uncommon", "common-new", "common-mid"]) + } + + func testAddingReplacingAndMovingMembers() throws { + let s = try store(dex: eightGraduates) + for member in s.trainerTeam { s.removeFromTeam(member) } + XCTAssertTrue(s.isTeamPicked) + XCTAssertTrue(s.trainerTeam.isEmpty, "an emptied team does not refill itself") + + let byID = Dictionary(uniqueKeysWithValues: s.teamCandidates.map { ($0.id, $0) }) + for id in ["common-old", "rare", "legend"] { XCTAssertTrue(s.addToTeam(try XCTUnwrap(byID[id]))) } + XCTAssertFalse(s.addToTeam(try XCTUnwrap(byID["rare"])), "no duplicates") + XCTAssertNil(byID["released-legend"], "released individuals are not candidates") + let released = try XCTUnwrap(s.dexEntries.first { $0.id == "released-legend" }) + XCTAssertFalse(s.addToTeam(released)) + XCTAssertFalse(s.isInTeam(released)) + + XCTAssertTrue(s.addToTeam(try XCTUnwrap(byID["uncommon"]), slot: 1)) + XCTAssertEqual(s.trainerTeam.map(\.id), ["common-old", "uncommon", "legend"]) + XCTAssertTrue(s.isInTeam(try XCTUnwrap(byID["uncommon"]))) + XCTAssertFalse(s.isInTeam(try XCTUnwrap(byID["rare"]))) + + s.moveInTeam(try XCTUnwrap(byID["legend"]), by: -1) + XCTAssertEqual(s.trainerTeam.map(\.id), ["common-old", "legend", "uncommon"]) + s.moveInTeam(try XCTUnwrap(byID["common-old"]), by: -1) + s.moveInTeam(try XCTUnwrap(byID["uncommon"]), by: 1) + XCTAssertEqual(s.trainerTeam.map(\.id), ["common-old", "legend", "uncommon"], "edges do not move") + + for id in ["rare", "common-new", "shiny-common"] { XCTAssertTrue(s.addToTeam(try XCTUnwrap(byID[id]))) } + XCTAssertTrue(s.isTeamFull) + XCTAssertFalse(s.addToTeam(try XCTUnwrap(byID["common-mid"])), "six members at most") + XCTAssertTrue(s.addToTeam(try XCTUnwrap(byID["common-mid"]), slot: 5), "a full team still takes a swap") + XCTAssertEqual(s.trainerTeam.last?.id, "common-mid") + } + + func testThePickedTeamIsSavedAndCanGoBackToAutomatic() throws { + let s = try store(dex: eightGraduates) + for member in s.trainerTeam { s.removeFromTeam(member) } + XCTAssertTrue(s.addToTeam(try XCTUnwrap(s.teamCandidates.first { $0.id == "common-old" }))) + + var reloaded = reload() + XCTAssertTrue(reloaded.isTeamPicked) + XCTAssertEqual(reloaded.trainerTeam.map(\.id), ["common-old"]) + + reloaded.setAutomaticTeam(true) + reloaded = reload() + XCTAssertFalse(reloaded.isTeamPicked) + XCTAssertEqual(reloaded.trainerTeam.count, 6) + } + + func testGoingBackToAutomaticKeepsThePickedTeamAside() throws { + let s = try store(dex: eightGraduates) + for member in s.trainerTeam { s.removeFromTeam(member) } + for id in ["legend", "rare"] { + XCTAssertTrue(s.addToTeam(try XCTUnwrap(s.teamCandidates.first { $0.id == id }))) + } + + s.setAutomaticTeam(true) + XCTAssertEqual(s.trainerTeam.count, 6, "the automatic team is shown again") + + var reloaded = reload() + reloaded.setAutomaticTeam(false) + XCTAssertEqual(reloaded.trainerTeam.map(\.id), ["legend", "rare"], "the picks survive the round trip") + + // An emptied team is a choice too, and coming back must not refill it. + for member in reloaded.trainerTeam { reloaded.removeFromTeam(member) } + reloaded.setAutomaticTeam(true) + reloaded.setAutomaticTeam(false) + XCTAssertTrue(reloaded.trainerTeam.isEmpty) + + reloaded = reload() + XCTAssertTrue(reloaded.isTeamPicked) + XCTAssertTrue(reloaded.trainerTeam.isEmpty) + } + + func testTurningOffTheAutomaticTeamStartsFromWhatIsShown() throws { + let s = try store(dex: eightGraduates) + let automatic = s.trainerTeam.map(\.id) + + s.setAutomaticTeam(false) + + XCTAssertTrue(s.isTeamPicked) + XCTAssertEqual(s.trainerTeam.map(\.id), automatic) + XCTAssertEqual(reload().trainerTeam.map(\.id), automatic) + } + + func testTheRaisedPokemonKeepsItsSlotWhenItGraduates() async throws { + let s = reload() + await s.hatch(baseID: 1) + let raised = try XCTUnwrap(s.teamCandidates.first { s.isActiveDexEntry($0) }) + let key = try XCTUnwrap(s.teamKey(for: raised)) + XCTAssertNotEqual(key, raised.id, "the synthesized row id changes as it evolves") + XCTAssertTrue(s.addToTeam(raised)) + XCTAssertTrue(s.isInTeam(raised)) + + for stage in 0..<3 { + s.applyUsage(PokemonBalance.phaseThreshold(rarity: .common, totalForms: 3, stageIndex: stage)) + } + + XCTAssertFalse(s.hasActive) + XCTAssertEqual(s.trainerTeam.map(\.id), [key]) + XCTAssertEqual(s.trainerTeam.first?.finalID, 3) + } + + func testTheTeamBadgeFollowsAGraduationWithoutAReload() async throws { + // The badge reads a set folded at the save boundary; a graduation joining the automatic + // team must refresh it, or the log would keep showing yesterday's team. + let s = try store(dex: Array(eightGraduates.prefix(2))) + XCTAssertEqual(s.trainerTeam.count, 2) + + await s.hatch(baseID: 1) + for stage in 0..<3 { + s.applyUsage(PokemonBalance.phaseThreshold(rarity: .common, totalForms: 3, stageIndex: stage)) + } + + let graduated = try XCTUnwrap(s.dexEntries.first { $0.finalID == 3 }) + XCTAssertTrue(s.isInTeam(graduated), "the fresh graduate joined the automatic team") + XCTAssertEqual(s.teamKeySet.count, 3) + } + + func testAReleasedPokemonLeavesTheTeam() throws { + let raised = MonState(baseID: 1, pathIDs: [1], stageIndex: 0, usedAtStage: 0, + rarity: .common, totalForms: 3) + let s = try store(dex: [graduate("kept", species: 10, daysAgo: 1)], active: raised, + used: 5_000_000_000) + let row = try XCTUnwrap(s.teamCandidates.first { s.isActiveDexEntry($0) }) + XCTAssertTrue(s.addToTeam(row)) + XCTAssertEqual(s.trainerTeam.count, 2) + + XCTAssertTrue(s.buyFreshEgg()) + + XCTAssertEqual(s.trainerTeam.map(\.id), ["kept"]) + XCTAssertEqual(s.state.teamEntryIDs, ["kept"]) + } + + func testLoadingDropsUnknownDuplicateAndReleasedTeamIDs() throws { + let extra = #","teamEntryIDs":["made-up","rare","rare","released-legend","legend","common-old","uncommon","common-new","common-mid"]"# + let s = try store(dex: eightGraduates, extra: extra) + + XCTAssertEqual(s.state.teamEntryIDs, ["rare", "legend", "common-old", "uncommon", "common-new", "common-mid"]) + } + + func testAnUnreadableTeamFallsBackToAutomatic() throws { + let s = try store(dex: eightGraduates, extra: #","teamEntryIDs":5,"trainerName":7"#) + + XCTAssertFalse(s.isTeamPicked) + XCTAssertEqual(s.trainerName, "") + XCTAssertEqual(s.trainerTeam.count, 6) + } + + // MARK: Trainer identity + + func testTheTrainerNumberIsAssignedOnceAndKept() throws { + let s = try store(dex: []) + XCTAssertNil(s.trainerID) + + s.ensureTrainerID() + let id = try XCTUnwrap(s.trainerID) + XCTAssertTrue(TrainerCard.idRange.contains(id)) + s.ensureTrainerID() + XCTAssertEqual(s.trainerID, id) + XCTAssertEqual(reload().trainerID, id) + } + + func testAnOutOfRangeTrainerNumberIsDropped() throws { + let s = try store(dex: [], extra: #","trainerID":123456789"#) + XCTAssertNil(s.trainerID) + } + + func testTheTrainerNameIsTrimmedCappedAndSaved() throws { + let s = try store(dex: []) + s.setTrainerName(" Sam ") + XCTAssertEqual(s.trainerName, "Sam") + s.setTrainerName("A name that is far too long") + XCTAssertEqual(s.trainerName, "A name that is f") + XCTAssertEqual(reload().trainerName, "A name that is f") + + s.setTrainerName("A name that is far too long") + XCTAssertEqual(s.trainerName, "A name that is f", "setting the same name again changes nothing") + + let edited = try store(dex: [], extra: #","trainerName":"An edited save with a long name""#) + XCTAssertEqual(edited.trainerName.count, TrainerCard.nameLimit) + } + + // MARK: Stats + + func testStatsCountIndividualsAndSpecies() throws { + let raised = MonState(baseID: 1, pathIDs: [1, 2], stageIndex: 1, usedAtStage: 0, + rarity: .rare, totalForms: 3, isShiny: true) + let s = try store(dex: eightGraduates, active: raised, used: 2_410_000_000) + + let stats = s.trainerCardStats + XCTAssertEqual(stats.lifetimeTokens, 2_410_000_000) + XCTAssertEqual(stats.speciesCount, 10, "eight graduate species plus the two reached forms") + XCTAssertEqual(stats.speciesTotal, 649) + XCTAssertEqual(stats.shinyCount, 2) + XCTAssertEqual(stats.graduatedCount, 7) + XCTAssertEqual(stats.duplicateCount, 0, "every line was raised once") + XCTAssertEqual(stats.firstCatch, cardNow.addingTimeInterval(-9 * 86_400)) + XCTAssertEqual(stats.rarityCounts, [.common: 4, .uncommon: 1, .rare: 2, .legendary: 1], + "the released legendary is gone, so it counts nowhere but the Pokédex") + } + + func testReleasedIndividualsCountNowhereButThePokedex() throws { + let shinyThenReleased = graduate("let-go", species: 30, rarity: .legendary, shiny: true, + daysAgo: 1, released: true) + let s = try store(dex: [graduate("kept", species: 10, daysAgo: 4), shinyThenReleased, + graduate("same-line-as-released", species: 30, daysAgo: 2)]) + + let stats = s.trainerCardStats + XCTAssertEqual(stats.shinyCount, 0, "the shiny was let go") + XCTAssertEqual(stats.duplicateCount, 0, "the released one does not make the line a duplicate") + XCTAssertEqual(stats.rarityCounts, [.common: 2]) + XCTAssertEqual(stats.firstCatch, cardNow.addingTimeInterval(-4 * 86_400)) + XCTAssertEqual(stats.speciesCount, 2, "the Pokédex keeps the species either way") + } + + func testDuplicatesCountIndividualsOfALineAlreadyOwned() throws { + var dex = eightGraduates + dex.append(graduate("second-legend", species: 13, rarity: .legendary, daysAgo: 1)) + dex.append(graduate("third-legend", species: 13, rarity: .legendary, daysAgo: 0)) + // Raising species 10 again makes the log hold two of that line as well. + let raised = MonState(baseID: 10, pathIDs: [10], stageIndex: 0, usedAtStage: 0, + rarity: .common, totalForms: 1) + let s = try store(dex: dex, active: raised) + + XCTAssertEqual(s.trainerCardStats.duplicateCount, 3) + XCTAssertEqual(s.trainerCardStats.speciesCount, 8, "duplicates add nothing to the Pokédex") + } + + func testSpeciesBeyondTheAnimatedRangeStayOutOfTheCount() throws { + // Eevee's chain reaches Sylveon (#700), past the range the Pokédex total covers. + let sylveon = DexEntry(id: "eeveelution", baseID: 133, finalID: 700, chainOrder: [133, 700], + rarity: .rare, caughtAt: cardNow, names: nil) + let s = try store(dex: [sylveon]) + + let stats = s.trainerCardStats + XCTAssertEqual(stats.speciesCount, 1, "Eevee counts, Sylveon is a bonus past the total") + XCTAssertLessThanOrEqual(stats.speciesCount, stats.speciesTotal) + } + + func testTheCardFramesThePinnedPokemonAndFallsBackToTheRaisedOne() throws { + let raised = MonState(baseID: 1, pathIDs: [1, 2], stageIndex: 1, usedAtStage: 0, + rarity: .rare, totalForms: 3, isShiny: true) + let s = try store(dex: eightGraduates, active: raised) + + XCTAssertEqual(s.cardSubject.speciesID, 2, "no pin, so the raised Pokémon is framed") + XCTAssertTrue(s.cardSubject.isShiny) + + XCTAssertTrue(s.setRepresentativeSpeciesID(13)) + let pinned = s.cardSubject + XCTAssertEqual(pinned.speciesID, 13) + XCTAssertFalse(pinned.isShiny, "that species was never owned shiny") + XCTAssertEqual(pinned.name, "S13") + XCTAssertEqual(pinned.detail, L(.en).rarityLabel(.legendary), + "a pin is a species, so the raised individual's stage and nature are dropped") + } + + func testTheCardCarriesTheUnownLetter() throws { + let unown = DexEntry(id: "unown-q", baseID: UnownForm.speciesID, finalID: UnownForm.speciesID, + chainOrder: [UnownForm.speciesID], rarity: .rare, caughtAt: cardNow, + names: [UnownForm.speciesID: ["en": "Unown"]], unownForm: .q) + let s = try store(dex: eightGraduates + [unown]) + + XCTAssertTrue(s.setRepresentativeSpeciesID(UnownForm.speciesID, unownForm: .q)) + let pinned = s.cardSubject + XCTAssertEqual(pinned.unownForm, .q, "the frame would otherwise draw the default A sprite") + XCTAssertEqual(pinned.name, "Unown [Q]") + + let member = try XCTUnwrap(TrainerCardContent(store: s, showsTokens: true).team + .first { $0.entry.id == "unown-q" }) + XCTAssertEqual(member.name, "Unown [Q]", "team slots name the letter like the catch log") + XCTAssertEqual(member.entry.unownForm, .q) + } + + func testAPinnedPokemonKeepsTheCardOffTheEgg() throws { + let s = try store(dex: eightGraduates) // no active Pokémon: an egg is incubating + XCTAssertEqual(s.cardSubject.name, "Token Egg") + + XCTAssertTrue(s.setRepresentativeSpeciesID(14)) + XCTAssertEqual(s.cardSubject.speciesID, 14) + XCTAssertTrue(s.cardSubject.isShiny, "that species was graduated shiny") + } + + func testTheCardRowsFollowTheNameAndTokenSettings() throws { + let s = try store(dex: [], used: 1_500) + let shown = TrainerCardFront.rows(TrainerCardContent(store: s, showsTokens: true)) + XCTAssertEqual(shown.map(\.label), ["Tokens", "Pokédex", "Shiny", "Graduated", "Duplicates"], + "no name row without a name, no first catch before any catch") + XCTAssertEqual(shown.first { $0.label == "Pokédex" }?.value, "0 / 649") + + s.setTrainerName("Sam") + let hidden = TrainerCardFront.rows(TrainerCardContent(store: s, showsTokens: false)) + XCTAssertEqual(hidden.map(\.label), ["Name", "Pokédex", "Shiny", "Graduated", "Duplicates"]) + XCTAssertEqual(hidden.first?.value, "Sam") + } + + // MARK: Save transfer + + func testASaveTransferCarriesTheCard() throws { + let s = try store(dex: eightGraduates) + s.ensureTrainerID() + s.setTrainerName("Sam") + s.removeFromTeam(try XCTUnwrap(s.trainerTeam.first)) + + let data = try SaveTransfer.encode(state: s.state, appVersion: "t", deviceName: "d", now: cardNow) + let imported = try SaveTransfer.decode(data).state + let rebased = SaveTransfer.rebasedForThisDevice(imported, current: CompanionState(), + todayTokensByProvider: [:], todayDate: "d", + hasUsageData: false) + + XCTAssertEqual(rebased.trainerID, s.trainerID) + XCTAssertEqual(rebased.trainerName, "Sam") + XCTAssertEqual(rebased.teamEntryIDs, s.state.teamEntryIDs) + } + + // MARK: Rendering + + func testTheCardRendersInEveryLanguage() throws { + let s = try store(dex: eightGraduates) + s.ensureTrainerID() + for language in AppLanguage.allCases { + s.setLanguage(language) + let l = L(language) + for text in [l.trainerCardTitle, l.trainerTeamTitle, l.trainerTeamAdd, l.trainerTeamFull, + l.trainerCardCopy, l.trainerCardSave, l.trainerCardCopyMenu, l.trainerNamePlaceholder] { + XCTAssertFalse(text.isEmpty, "\(language)") + } + XCTAssertTrue(l.trainerIDLabel("01234").contains("01234")) + let content = TrainerCardContent(store: s, showsTokens: true) + let renderer = ImageRenderer(content: TrainerCardExport.sheet(content)) + renderer.scale = 1 + let image = try XCTUnwrap(renderer.cgImage, "\(language)") + XCTAssertEqual(image.width, Int(TrainerCardStyle.size.width) + 24) + XCTAssertEqual(image.height, Int(TrainerCardStyle.size.height) * 2 + 36) + } + } + + func testThePickedTeamSideRendersEmptySlotsAndTheChip() throws { + // A line with no stored names falls back to the species number on the slot. + let nameless = DexEntry(id: "nameless", baseID: 20, finalID: 20, chainOrder: [20], + rarity: .rare, caughtAt: cardNow, names: nil) + let s = try store(dex: eightGraduates + [nameless]) + for member in s.trainerTeam.dropFirst(2) { s.removeFromTeam(member) } + XCTAssertTrue(s.addToTeam(try XCTUnwrap(s.teamCandidates.first { $0.id == "nameless" }))) + + let content = TrainerCardContent(store: s, showsTokens: true) + XCTAssertTrue(content.teamPicked) + XCTAssertEqual(content.team.count, 3, "three slots filled, three empty") + XCTAssertEqual(content.team.last?.name, "#20") + + let renderer = ImageRenderer(content: TrainerCardBack(content: content)) + renderer.scale = 1 + let image = try XCTUnwrap(renderer.cgImage) + XCTAssertEqual(image.width, Int(TrainerCardStyle.size.width)) + XCTAssertEqual(image.height, Int(TrainerCardStyle.size.height)) + } + + func testAShinyCardIsHoloOnBothSides() throws { + let plain = try store(dex: eightGraduates) + let shiny = try store(dex: eightGraduates, + active: MonState(baseID: 1, pathIDs: [1], stageIndex: 0, usedAtStage: 0, + rarity: .common, totalForms: 1, isShiny: true)) + XCTAssertTrue(shiny.cardSubject.isShiny) + + func pixels(_ view: some View) throws -> Data { + let renderer = ImageRenderer(content: view) + renderer.scale = 1 + let image = try XCTUnwrap(renderer.cgImage) + return try XCTUnwrap(NSBitmapImageRep(cgImage: image).representation(using: .png, properties: [:])) + } + let plainContent = TrainerCardContent(store: plain, showsTokens: true) + let shinyContent = TrainerCardContent(store: shiny, showsTokens: true) + XCTAssertNotEqual(try pixels(TrainerCardFront(content: plainContent)), + try pixels(TrainerCardFront(content: shinyContent))) + XCTAssertNotEqual(try pixels(TrainerCardBack(content: plainContent)), + try pixels(TrainerCardBack(content: shinyContent)), + "the team side carries the holo frame too") + } + + func testTypeColorsFallBackToOrange() { + XCTAssertEqual(TrainerCardStyle.accent(forType: nil), .systemOrange) + XCTAssertEqual(TrainerCardStyle.accent(forType: "shadow"), .systemOrange) + XCTAssertNotEqual(TrainerCardStyle.accent(forType: "water"), .systemOrange) + } + + func testOpeningTheRepresentativePickerLeavesTheCard() { + let nav = PopoverNavigation() + nav.showingTrainerCard = true + nav.openRepresentativeDex() + XCTAssertFalse(nav.showingTrainerCard) + } +}