From d838169f7d95851523eb1a78bb239d9b22819075 Mon Sep 17 00:00:00 2001 From: Killian HERZER Date: Wed, 16 Sep 2026 14:51:03 +0200 Subject: [PATCH 1/2] feat(casino): add Celadon Casino with slot machine, exclusive Porygon, themes, and Star Prism --- .gitignore | 1 + Sources/PokeTokenBar/Core/CasinoModel.swift | 345 ++++++++ .../PokeTokenBar/Core/CompanionModel.swift | 14 +- .../PokeTokenBar/Core/CompanionStore.swift | 113 ++- Sources/PokeTokenBar/Core/Localization.swift | 108 +++ Sources/PokeTokenBar/Core/SaveTransfer.swift | 5 +- Sources/PokeTokenBar/UI/BagView.swift | 3 + Sources/PokeTokenBar/UI/CasinoView.swift | 824 ++++++++++++++++++ Sources/PokeTokenBar/UI/CompanionView.swift | 29 +- Sources/PokeTokenBar/UI/PopoverView.swift | 19 +- Sources/PokeTokenBar/UI/SettingsView.swift | 35 +- Sources/PokeTokenBar/UI/ShopView.swift | 49 +- Tests/PokeTokenBarTests/CasinoTests.swift | 226 +++++ .../PokeTokenBarTests/SaveTransferTests.swift | 4 +- 14 files changed, 1736 insertions(+), 39 deletions(-) create mode 100644 Sources/PokeTokenBar/Core/CasinoModel.swift create mode 100644 Sources/PokeTokenBar/UI/CasinoView.swift create mode 100644 Tests/PokeTokenBarTests/CasinoTests.swift diff --git a/.gitignore b/.gitignore index 65ca3b976..fff216436 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,7 @@ docs/* # Agent worktrees / local state .claude/ +.idea/ .worktrees/ # Local-only review-gate opt-out (not published) diff --git a/Sources/PokeTokenBar/Core/CasinoModel.swift b/Sources/PokeTokenBar/Core/CasinoModel.swift new file mode 100644 index 000000000..1dfc42a0a --- /dev/null +++ b/Sources/PokeTokenBar/Core/CasinoModel.swift @@ -0,0 +1,345 @@ +import Foundation +import SwiftUI + +// MARK: - Casino Coins & Packages + +public struct CoinPackage: Identifiable, Sendable { + public let id: String + public let coins: Int + public let tokenCost: Int + public let bonusPercent: Int + + public init(id: String, coins: Int, tokenCost: Int, bonusPercent: Int = 0) { + self.id = id + self.coins = coins + self.tokenCost = tokenCost + self.bonusPercent = bonusPercent + } + + public static let all: [CoinPackage] = [ + CoinPackage(id: "coins_50", coins: 50, tokenCost: 35_000_000), + CoinPackage(id: "coins_100", coins: 100, tokenCost: 65_000_000, bonusPercent: 8), + CoinPackage(id: "coins_500", coins: 500, tokenCost: 300_000_000, bonusPercent: 15), + CoinPackage(id: "coins_1000", coins: 1_000, tokenCost: 550_000_000, bonusPercent: 25), + CoinPackage(id: "coins_5000", coins: 5_000, tokenCost: 2_500_000_000, bonusPercent: 40), + CoinPackage(id: "coins_10000", coins: 10_000, tokenCost: 4_800_000_000, bonusPercent: 50), + ] +} + +// MARK: - Slot Machine (Machine à sous de Céladopole) + +public enum SlotSymbol: String, CaseIterable, Sendable { + case seven = "777" + case bar = "BAR" + case jigglypuff = "jigglypuff" + case pikachu = "pikachu" + case pokeBall = "pokeball" + case cherry = "cherry" + case voltorb = "voltorb" + + public enum Visual: Sendable, Equatable { + case text(String) + case pokemon(speciesID: Int) + case item(name: String) + } + + public var visual: Visual { + switch self { + case .seven: return .text("777") + case .bar: return .text("BAR") + case .jigglypuff: return .pokemon(speciesID: 39) + case .pikachu: return .pokemon(speciesID: 25) + case .pokeBall: return .item(name: "poke-ball") + case .cherry: return .item(name: "cheri-berry") + case .voltorb: return .pokemon(speciesID: 100) + } + } + + public var name: String { + switch self { + case .seven: return "Jackpot 777" + case .bar: return "Bar Rocket" + case .jigglypuff: return "Rondoudou" + case .pikachu: return "Pikachu" + case .pokeBall: return "Poké Ball" + case .cherry: return "Cerise" + case .voltorb: return "Voltorbe" + } + } + + public var lineMultiplier: Int { + switch self { + case .seven: return 300 + case .bar: return 100 + case .jigglypuff: return 30 + case .cherry: return 25 + case .pikachu: return 20 + case .pokeBall: return 10 + case .voltorb: return 0 + } + } +} + +public struct SlotResult: Sendable { + public let grid: [[SlotSymbol]] // 3 rows, 3 cols + public let winningLines: [Int] // 0: mid, 1: top, 2: bot, 3: diag1, 4: diag2 + public let totalWin: Int + public let isJackpot: Bool + public let unlockedTheme: AppThemeKind? + + public init(grid: [[SlotSymbol]], winningLines: [Int], totalWin: Int, isJackpot: Bool, unlockedTheme: AppThemeKind? = nil) { + self.grid = grid + self.winningLines = winningLines + self.totalWin = totalWin + self.isJackpot = isJackpot + self.unlockedTheme = unlockedTheme + } +} + +public enum SlotMachineEngine { + // 3 authentic 21-stop reels with realistic distribution + // ~28.6% Voltorb blanks (6 per reel) to create authentic near-misses and house edge + private static let reel1: [SlotSymbol] = [ + .seven, .voltorb, .cherry, .pokeBall, .voltorb, + .pikachu, .cherry, .voltorb, .jigglypuff, .pokeBall, + .bar, .voltorb, .pikachu, .cherry, .pokeBall, + .voltorb, .jigglypuff, .cherry, .voltorb, .pikachu, .pokeBall + ] + private static let reel2: [SlotSymbol] = [ + .bar, .voltorb, .cherry, .pikachu, .voltorb, + .pokeBall, .cherry, .voltorb, .seven, .jigglypuff, + .voltorb, .cherry, .pokeBall, .pikachu, .voltorb, + .cherry, .pokeBall, .voltorb, .jigglypuff, .pikachu, .pokeBall + ] + private static let reel3: [SlotSymbol] = [ + .cherry, .voltorb, .seven, .pokeBall, .voltorb, + .pikachu, .cherry, .voltorb, .bar, .jigglypuff, + .voltorb, .cherry, .pokeBall, .voltorb, .pikachu, + .cherry, .voltorb, .pokeBall, .jigglypuff, .pikachu, .pokeBall + ] + + public static func spin(lines: Int = 5, multiplier: Int = 1, currentUnlocked: Set = []) -> SlotResult { + let r1 = Int.random(in: 0.. SlotSymbol { + let idx = (centerIndex + offset + reel.count) % reel.count + return reel[idx] + } + + // rows: 0 = top, 1 = middle, 2 = bottom + let row0: [SlotSymbol] = [symbol(reel: reel1, centerIndex: r1, offset: -1), + symbol(reel: reel2, centerIndex: r2, offset: -1), + symbol(reel: reel3, centerIndex: r3, offset: -1)] + let row1: [SlotSymbol] = [symbol(reel: reel1, centerIndex: r1, offset: 0), + symbol(reel: reel2, centerIndex: r2, offset: 0), + symbol(reel: reel3, centerIndex: r3, offset: 0)] + let row2: [SlotSymbol] = [symbol(reel: reel1, centerIndex: r1, offset: 1), + symbol(reel: reel2, centerIndex: r2, offset: 1), + symbol(reel: reel3, centerIndex: r3, offset: 1)] + + let grid = [row0, row1, row2] + + // Lines: + // Line 0: row 1 (middle) -> enabled on lines >= 1 + // Line 1: row 0 (top) -> enabled on lines >= 3 + // Line 2: row 2 (bottom) -> enabled on lines >= 3 + // Line 3: diag top-left to bot-right -> enabled on lines >= 5 + // Line 4: diag bot-left to top-right -> enabled on lines >= 5 + var activeLines: [(id: Int, symbols: [SlotSymbol])] = [] + if lines >= 1 { activeLines.append((0, row1)) } + if lines >= 3 { + activeLines.append((1, row0)) + activeLines.append((2, row2)) + } + if lines >= 5 { + activeLines.append((3, [row0[0], row1[1], row2[2]])) + activeLines.append((4, [row2[0], row1[1], row0[2]])) + } + + var winningLines: [Int] = [] + var totalWin = 0 + var jackpot = false + var hasThemeTrigger = false + + for line in activeLines { + let syms = line.symbols + if syms[0] == syms[1] && syms[1] == syms[2] { + let s = syms[0] + if s != .voltorb { + let win = s.lineMultiplier * multiplier + totalWin += win + winningLines.append(line.id) + if s == .seven { jackpot = true } + if s == .jigglypuff { hasThemeTrigger = true } + } + } else if syms[0] == .cherry && syms[1] == .cherry { + // 2 Cherries (Reel 1 & 2) pays 7x multiplier + totalWin += 7 * multiplier + winningLines.append(line.id) + } + } + + // ONE winning condition triggers a random theme roll based on rarity: 3 Jigglypuffs! + var wonTheme: AppThemeKind? = nil + if hasThemeTrigger { + if let rolled = AppThemeKind.rollRandomTheme(excluding: currentUnlocked) { + wonTheme = rolled + } else { + // All themes are already unlocked: bonus jackpot prize! + totalWin += 250 * multiplier + } + } + + return SlotResult(grid: grid, winningLines: winningLines, totalWin: totalWin, isJackpot: jackpot, unlockedTheme: wonTheme) + } + + public static func spin(bet: Int) -> SlotResult { + let lines: Int + switch bet { + case 1: lines = 1 + case 2: lines = 3 + default: lines = 5 + } + return spin(lines: lines, multiplier: 1, currentUnlocked: []) + } +} + +// MARK: - App Themes (Thèmes d'application) + +public enum AppThemeKind: String, CaseIterable, Codable, Sendable { + case classic = "classic" + case gameBoy1989 = "gameboy_1989" + case celadonNeon = "celadon_neon" + case teamRocket = "team_rocket" + case indigoPlateau = "indigo_plateau" + case masterBall = "master_ball" + + public var itemSprite: String { + switch self { + case .classic: return "poke-ball" + case .gameBoy1989: return "town-map" + case .celadonNeon: return "coin-case" + case .teamRocket: return "silph-scope" + case .indigoPlateau: return "ss-ticket" + case .masterBall: return "master-ball" + } + } + + public var accentColor: Color { + switch self { + case .classic: return .accentColor + case .gameBoy1989: return Color(red: 0.35, green: 0.45, blue: 0.15) + case .celadonNeon: return Color(red: 0.0, green: 0.85, blue: 0.85) + case .teamRocket: return Color(red: 0.90, green: 0.15, blue: 0.20) + case .indigoPlateau: return Color(red: 0.85, green: 0.65, blue: 0.15) + case .masterBall: return Color(red: 0.70, green: 0.20, blue: 0.85) + } + } + + public var secondaryAccentColor: Color { + switch self { + case .classic: return .orange + case .gameBoy1989: return Color(red: 0.20, green: 0.30, blue: 0.10) + case .celadonNeon: return Color(red: 0.95, green: 0.30, blue: 0.85) + case .teamRocket: return Color(red: 0.30, green: 0.30, blue: 0.35) + case .indigoPlateau: return Color(red: 0.20, green: 0.40, blue: 0.85) + case .masterBall: return Color(red: 0.95, green: 0.40, blue: 0.70) + } + } + + public var windowBackground: Color { + switch self { + case .classic: + return .clear + case .gameBoy1989: + return Color(red: 0.55, green: 0.65, blue: 0.25).opacity(0.12) + case .celadonNeon: + return Color(red: 0.05, green: 0.08, blue: 0.18).opacity(0.25) + case .teamRocket: + return Color(red: 0.12, green: 0.05, blue: 0.06).opacity(0.25) + case .indigoPlateau: + return Color(red: 0.06, green: 0.10, blue: 0.25).opacity(0.20) + case .masterBall: + return Color(red: 0.18, green: 0.06, blue: 0.22).opacity(0.22) + } + } + + public var cardBackground: Color { + switch self { + case .classic: return Color.secondary.opacity(0.08) + case .gameBoy1989: return Color(red: 0.60, green: 0.70, blue: 0.30).opacity(0.18) + case .celadonNeon: return Color(red: 0.05, green: 0.12, blue: 0.22).opacity(0.40) + case .teamRocket: return Color(red: 0.15, green: 0.15, blue: 0.18).opacity(0.50) + case .indigoPlateau: return Color(red: 0.10, green: 0.18, blue: 0.35).opacity(0.35) + case .masterBall: return Color(red: 0.25, green: 0.10, blue: 0.30).opacity(0.40) + } + } + + public var borderGradient: LinearGradient { + LinearGradient( + colors: [accentColor, secondaryAccentColor], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } + + public var rarityWeight: Int { + switch self { + case .classic: return 0 // Default unlocked + case .gameBoy1989: return 40 // Common + case .celadonNeon: return 30 // Uncommon + case .teamRocket: return 18 // Rare + case .indigoPlateau: return 9 // Epic + case .masterBall: return 3 // Legendary + } + } + + public static func rollRandomTheme(excluding unlocked: Set) -> AppThemeKind? { + let available = AppThemeKind.allCases.filter { $0 != .classic && !unlocked.contains($0.rawValue) } + guard !available.isEmpty else { return nil } + + let totalWeight = available.reduce(0) { $0 + $1.rarityWeight } + guard totalWeight > 0 else { return available.randomElement() } + + var pick = Int.random(in: 0.. = [137] // Porygon (#137) is strictly exclusive to the Game Corner + public static let starPrismCost: Int = 50_000 +} + +// MARK: - Prize Corner Items + +public struct CasinoPrizeItem: Identifiable, Sendable { + public let id: String + public let coinCost: Int + public let speciesID: Int? + public let isStarPrism: Bool + + public init(id: String, coinCost: Int, speciesID: Int? = nil, isStarPrism: Bool = false) { + self.id = id + self.coinCost = coinCost + self.speciesID = speciesID + self.isStarPrism = isStarPrism + } + + public static let all: [CasinoPrizeItem] = [ + CasinoPrizeItem(id: "prize_abra", coinCost: 180, speciesID: 63), + CasinoPrizeItem(id: "prize_cleffa", coinCost: 500, speciesID: 173), + CasinoPrizeItem(id: "prize_dratini", coinCost: 2_800, speciesID: 147), + CasinoPrizeItem(id: "prize_scyther", coinCost: 5_500, speciesID: 123), + CasinoPrizeItem(id: "prize_porygon", coinCost: 9_999, speciesID: 137), + CasinoPrizeItem(id: "prize_star_prism", coinCost: 50_000, isStarPrism: true), + ] +} diff --git a/Sources/PokeTokenBar/Core/CompanionModel.swift b/Sources/PokeTokenBar/Core/CompanionModel.swift index 3da239743..401108d34 100644 --- a/Sources/PokeTokenBar/Core/CompanionModel.swift +++ b/Sources/PokeTokenBar/Core/CompanionModel.swift @@ -176,6 +176,7 @@ enum ItemKind: String, Codable, Sendable, CaseIterable { case rareCandy case mint case shinyCharm + case starPrism /// PokéAPI 아이템 스프라이트 파일명(.../sprites/items/{name}.png). nil = 스프라이트 없음(이모지 폴백만). var spriteName: String? { @@ -183,6 +184,7 @@ enum ItemKind: String, Codable, Sendable, CaseIterable { case .rareCandy: return "rare-candy" case .mint: return nil // PokéAPI 에 민트 스프라이트 없음(8세대 아이템) → 이모지 폴백 case .shinyCharm: return "shiny-charm" + case .starPrism: return "star-piece" } } /// 스프라이트 로딩 전/미제공/실패 시 폴백 이모지. @@ -191,6 +193,7 @@ enum ItemKind: String, Codable, Sendable, CaseIterable { case .rareCandy: return "🍬" case .mint: return "🌿" case .shinyCharm: return "✨" + case .starPrism: return "🌟" } } /// 상점 판매가(재화 = 사용한 토큰). nil = 상점 미판매. @@ -199,13 +202,14 @@ enum ItemKind: String, Codable, Sendable, CaseIterable { case .rareCandy: return RareCandy.price case .mint: return Mint.price case .shinyCharm: return ShinyCharm.price + default: return nil } } /// 보유형(패시브) 아이템 — 소비하지 않고 보유하는 동안 상시 효과. 1회 구매(재구매 불가), 가방엔 "적용 중" 표시. var isPassive: Bool { switch self { - case .rareCandy, .mint: return false - case .shinyCharm: return true + case .rareCandy, .mint, .starPrism: return false + default: return true } } } @@ -643,6 +647,9 @@ struct CompanionState: Codable, Sendable { var candyGrantTier: [String: Int] = [:] // 사탕 지급 첫 실행 시드 완료 — 업데이트 직후 이미 100%였던 창의 소급 지급 차단. var candyFeatureSeeded = false + var casinoCoins = 0 + var unlockedThemes: Set = [AppThemeKind.classic.rawValue] + var activeTheme: String = AppThemeKind.classic.rawValue init() {} @@ -678,6 +685,9 @@ 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) + casinoCoins = c.lenient(Int.self, forKey: .casinoCoins, default: 0) + unlockedThemes = c.lenient(Set.self, forKey: .unlockedThemes, default: [AppThemeKind.classic.rawValue]) + activeTheme = c.lenient(String.self, forKey: .activeTheme, default: AppThemeKind.classic.rawValue) } /// 졸업 기록 또는 현재 개체가 실제로 도달한 단계에 이 종이 포함되는가. diff --git a/Sources/PokeTokenBar/Core/CompanionStore.swift b/Sources/PokeTokenBar/Core/CompanionStore.swift index 5d096e006..e276b9385 100644 --- a/Sources/PokeTokenBar/Core/CompanionStore.swift +++ b/Sources/PokeTokenBar/Core/CompanionStore.swift @@ -815,6 +815,34 @@ final class CompanionStore { return new } + // MARK: 별의 프리즘 (이로치 영구 변환) + + /// 별의 프리즘 사용 가능 — 활성 포켓몬 + 재고 > 0 + 아직 이로치가 아님. + var canUseStarPrism: Bool { + hasActive && itemCount(.starPrism) > 0 && !(state.active?.isShiny ?? false) + } + + /// 별의 프리즘 1개 사용 — 현재 포켓몬 및 도감의 해당 진화 라인 전체를 영구히 이로치로 변환. + @discardableResult + func useStarPrism() -> Bool { + guard canUseStarPrism, var active = state.active else { return false } + state.inventory[ItemKind.starPrism.rawValue] = itemCount(.starPrism) - 1 + active.isShiny = true + state.active = active + + // 도감의 진화 라인 전체를 이로치로 갱신 + for i in 0.. Bool { buyEgg(nil) } + // MARK: - 카지노 (Casino) + + var casinoCoins: Int { state.casinoCoins } + var unlockedThemes: Set { state.unlockedThemes } + var activeTheme: AppThemeKind { AppThemeKind(rawValue: state.activeTheme) ?? .classic } + + /// 동전 획득 (미니게임 승리 / 잭팟 등) + func addCasinoCoins(_ amount: Int) { + guard amount > 0 else { return } + state.casinoCoins = min(SaveTransfer.maxTokenValue, state.casinoCoins + amount) + save() + } + + /// 동전 소비 (베팅, 경품 교환 등) + @discardableResult + func spendCasinoCoins(_ amount: Int) -> Bool { + guard amount > 0, state.casinoCoins >= amount else { return false } + state.casinoCoins -= amount + save() + return true + } + + /// 동전 팩 구매 (사용한 토큰으로 지불) + @discardableResult + func buyCasinoCoins(package: CoinPackage) -> Bool { + guard availableTokens >= package.tokenCost else { return false } + state.spentTokens += package.tokenCost + state.casinoCoins = min(SaveTransfer.maxTokenValue, state.casinoCoins + package.coins) + save() + return true + } + + /// 경품 포켓몬 교환 (아브라, 미니뇽, 스라크, 폴리곤 등) + @discardableResult + func buyCasinoPokemon(speciesID: Int, coinCost: Int) -> Bool { + guard spendCasinoCoins(coinCost) else { return false } + if let a = state.active { + state.dex.append(releasedDexEntry(from: a)) + } + state.active = nil + state.reconcileRepresentativeSelection() + activeGeneration += 1 + currentLine = nil + state.eggUsage = PokemonBalance.eggHatchThreshold + state.eggTier = nil + state.pendingHatchID = speciesID + prefetchedLineID = nil + justGraduated = nil; justEvolvedTo = nil; eventUntil = nil + save() + Task { await self.hatch(baseID: speciesID) } + return true + } + + /// 테마 해금 (슬롯머신 달성 시 획득) + @discardableResult + func unlockCasinoTheme(_ theme: AppThemeKind) -> Bool { + guard !state.unlockedThemes.contains(theme.rawValue) else { return false } + state.unlockedThemes.insert(theme.rawValue) + save() + return true + } + + /// 활성 테마 설정 + func setActiveTheme(_ theme: AppThemeKind) { + guard state.unlockedThemes.contains(theme.rawValue) else { return } + state.activeTheme = theme.rawValue + save() + } + + /// 별의 프리즘 경품 교환 + @discardableResult + func buyStarPrism() -> Bool { + let cost = CasinoConstants.starPrismCost + guard spendCasinoCoins(cost) else { return false } + state.inventory[ItemKind.starPrism.rawValue, default: 0] += 1 + save() + return true + } + /// 지급 판정(순수·엣지 트리거) — 한도 창이 100% 를 새로 넘어선 순간에만 지급. /// - 100% 미만 → 맵에서 제거(재무장). resets_at 등 휘발 필드는 key 에 없다(안정 식별자만). /// - 이미 지급한 창(tier≥1)은 재지급 안 함. session=1개·weekly=weeklyGrant. @@ -1248,10 +1355,12 @@ final class CompanionStore { private func chooseBase() async -> Int? { let tier = state.eggTier if let full = try? await provider.baseSpeciesIndex(), !full.isEmpty { + // 카지노 한정 종(폴리곤 등)은 일반 알 부화 풀에서 제외한다 (경품 교환소 전용) + let eligible = full.filter { !CasinoConstants.exclusiveSpeciesIDs.contains($0.id) } // 등급 보증 알은 후보를 먼저 좁힌다 — capture_rate 상한이 곧 등급 하한이므로 // (Rarity.captureRateCeiling) 전설도 자연히 포함된다("희귀 이상"에 전설이 들어가는 게 정상). // 좁힌 결과가 비면 보증을 못 지키므로 전체 풀로 폴백하지 말고 알을 유지한다(다음 틱 재시도). - let index = tier.map { t in full.filter { t.includes(captureRate: $0.captureRate) } } ?? full + let index = tier.map { t in eligible.filter { t.includes(captureRate: $0.captureRate) } } ?? eligible guard !index.isEmpty else { AppLog.write("hatch: no candidate for guaranteed \(tier?.rawValue ?? "none") — egg kept, retry next tick") return nil @@ -1281,8 +1390,10 @@ final class CompanionStore { for attempt in 1...16 { let ids = PokemonAssets.animatedSpeciesIDs let id = Int(rng.next() % UInt64(ids.count)) + ids.lowerBound + if CasinoConstants.exclusiveSpeciesIDs.contains(id) { continue } do { if let bs = try await provider.baseSpecies(id: id) { + if CasinoConstants.exclusiveSpeciesIDs.contains(bs.id) { continue } // 등급 보증은 가중 경로와 **같은 기준**으로 여기서도 걸러야 한다 — 이 폴백만 빠지면 // GraphQL 인덱스 장애 때 보증이 조용히 깨진다. 못 찾으면 알 유지(구매 소멸 금지). if let tier, !tier.includes(captureRate: bs.captureRate) { continue } diff --git a/Sources/PokeTokenBar/Core/Localization.swift b/Sources/PokeTokenBar/Core/Localization.swift index 3088907bb..ff2acb5b2 100644 --- a/Sources/PokeTokenBar/Core/Localization.swift +++ b/Sources/PokeTokenBar/Core/Localization.swift @@ -916,6 +916,7 @@ struct L { case .rareCandy: return t("이상한 사탕", "Rare Candy", "ふしぎなアメ", "Caramelo Raro", "Super Bonbon", "Doce Raro", "Sonderbonbon") case .mint: return t("민트", "Mint", "ミント", "Menta", "Menthe", "Menta", "Minze") case .shinyCharm: return t("이로치 부적", "Shiny Charm", "ひかるおまもり", "Amuleto Iris", "Charme Chroma", "Amuleto Shiny", "Schillerpin") + case .starPrism: return t("별의 프리즘", "Star Prism", "スタープリズム", "Prisma Estelar", "Prisme Étoilé", "Prisma Estelar", "Sternenprisma") } } func itemDescription(_ kind: ItemKind) -> String { @@ -945,10 +946,29 @@ struct L { "Tant que tu le possèdes, augmente les chances qu'un Pokémon chromatique éclose.", "Enquanto estiver na sua bolsa, aumenta a chance de nascer um Pokémon shiny.", "Erhöht im Beutel die Chance, dass ein schillerndes Pokémon schlüpft.") + case .starPrism: + return t("현재 포켓몬과 그 진화 라인 전체를 영구히 이로치로 변환해요.", + "Permanently transmutes your current Pokémon and its entire evolutionary line into shiny.", + "現在のポケモンとその進化系統全体を永久に色違いに変化させます。", + "Transforma permanentemente a tu Pokémon actual y toda su línea evolutiva en variocolor.", + "Transforme définitivement ton Pokémon actuel et toute sa lignée d'évolution en chromatique.", + "Transforma permanentemente seu Pokémon atual e toda a sua linha evolutiva em shiny.", + "Verwandelt dein aktuelles Pokémon und seine gesamte Entwicklungslinie dauerhaft in Schillernd.") } } /// 가방 사용 컨트롤의 효과 힌트 — 민트("성격 랜덤 변경", 사탕의 "+XP" 자리). var mintEffectHint: String { t("성격 랜덤 변경", "Random nature", "せいかくランダム変更", "Naturaleza aleatoria", "Nature aléatoire", "Natureza aleatória", "Zufälliges Wesen") } + var starPrismEffectHint: String { t("이로치 변환", "Shiny Transmute", "色違い変化", "Transmutación Shiny", "Transmutation Shiny", "Transmutação Shiny", "Schillernde Verwandlung") } + var starPrismUsedTitle: String { t("별의 프리즘이 빛납니다!", "The Star Prism glows brightly!", "スタープリズムが輝きます!", "¡El Prisma Estelar brilla con fuerza!", "Le Prisme Étoilé rayonne d'éclat !", "O Prisma Estelar brilha intensamente!", "Das Sternenprisma erstrahlt im Glanz!") } + func starPrismUsedBody(name: String) -> String { + t("\(name)과(와) 그 진화 라인이 모두 이로치로 변했습니다!", + "\(name) and its evolutionary line are now shiny!", + "\(name)とその進化系統がすべて色違いになりました!", + "¡\(name) y toda su línea evolutiva ahora son variocolor!", + "\(name) et toute sa lignée sont devenus chromatiques !", + "\(name) e toda a sua linha evolutiva agora são shiny!", + "\(name) und die gesamte Entwicklungslinie sind jetzt Schillernd!") + } // MARK: 상점 (재화 = 사용한 토큰) var shop: String { t("상점", "Shop", "ショップ", "Tienda", "Boutique", "Loja", "Laden") } @@ -1037,4 +1057,92 @@ struct L { "Você esgotou seu limite de tokens — \(window). Você merece um agrado: use no seu Pokémon para evoluir!", "Du hast das Token-Limit für \(window) ausgeschöpft. Eine Belohnung für deinen Einsatz – verwende sie, um dein Pokémon zu entwickeln!") } + + // MARK: - 카지노 & 미니게임 (Casino) + var gameCorner: String { t("카지노", "Casino", "カジノ", "Casino", "Casino", "Cassino", "Casino") } + var miniGames: String { t("미니게임", "Mini-Games", "ミニゲーム", "Minijuegos", "Mini-Jeux", "Minijogos", "Minispiele") } + var payoutTable: String { t("배당표", "Payout Table", "配当表", "Tabla de Pagos", "Table des gains", "Tabela de Pagos", "Gewinntabelle") } + var rarePokemon: String { t("희귀 포켓몬", "Rare Pokémon", "レアポケモン", "Pokémon Raros", "Pokémon Rares", "Pokémon Raros", "Seltene Pokémon") } + var casinoCoins: String { t("동전", "Coins", "コイン", "Fichas", "Jetons", "Fichas", "Münzen") } + var buyCoins: String { t("동전 구매", "Buy Coins", "コイン購入", "Comprar Fichas", "Acheter des Jetons", "Comprar Fichas", "Münzen kaufen") } + var coinBalance: String { t("보유 동전", "Coin Balance", "所持コイン", "Saldo de Fichas", "Solde de Jetons", "Saldo de Fichas", "Münzguthaben") } + var slotMachine: String { t("슬롯머신", "Slot Machine", "スロットマシン", "Tragaperras", "Machine à sous", "Caça-Níquel", "Spielautomat") } + var voltorbFlip: String { t("찌리리공 뒤집기", "Voltorb Flip", "ビリリダマめくり", "Gira Voltorb", "Voltorb Flip", "Gira Voltorb", "Voltobal-Dreh") } + var prizeCorner: String { t("경품 교환소", "Prize Corner", "景品交換所", "Mostrador de Premios", "Comptoir des Prix", "Troca de Prêmios", "Wechselstube") } + var spinReels: String { t("돌리기", "Spin", "スピン", "Girar", "Lancer", "Girar", "Drehen") } + var betAmount: String { t("베팅", "Bet", "ベット", "Apuesta", "Mise", "Aposta", "Einsatz") } + var paylinesCount: String { t("라인", "Lines", "라인", "Líneas", "Lignes", "Linhas", "Linien") } + var multiplierLabel: String { t("배율", "Multiplier", "倍率", "Multiplicador", "Multiplicateur", "Multiplicador", "Multiplikator") } + var spinning: String { t("회전 중...", "Spinning...", "回転中...", "Girando...", "Lancement...", "Girando...", "Dreht...") } + var twoCherriesWinHint: String { t("체리 2개 : 7×", "2 Cherries : 7×", "チェリー2個 : 7×", "2 Cerezas : 7×", "2 Cerises : 7×", "2 Cerejas : 7×", "2 Kirschen : 7×") } + var payoutWin: String { t("획득", "Win", "獲得", "Premio", "Gain", "Ganho", "Gewinn") } + var jackpotWin: String { t("잭팟 777!", "JACKPOT 777!", "大当たり 777!", "¡BOTE 777!", "JACKPOT 777 !", "JACKPOT 777!", "JACKPOT 777!") } + var voltorbLevelTitle: String { t("레벨", "Level", "レベル", "Nivel", "Niveau", "Nível", "Stufe") } + var cashOutAction: String { t("정산하기", "Cash Out", "精算する", "Cobrar", "Encaisser", "Encerrar", "Auszahlen") } + var memoPencilMode: String { t("메모 모드", "Memo Mode", "メモモード", "Modo Notas", "Mode Mémo", "Modo Notas", "Notizen-Modus") } + var flipCardMode: String { t("뒤집기 모드", "Flip Mode", "めくるモード", "Modo Abrir", "Mode Carte", "Modo Abrir", "Aufdecken") } + var voltorbGameOver: String { t("폭발! 게임 오버", "Boom! Game Over", "ドカン!ゲームオーバー", "¡Boom! Fin de Partida", "Explosion !", "Boom! Fim de Jogo", "Boom! Vorbei") } + var voltorbLevelCleared: String { t("레벨 클리어!", "Level Cleared!", "レベルクリア!", "¡Nivel Superado!", "Niveau Réussi !", "Nível Concluído!", "Stufe Geschafft!") } + var playAgainAction: String { t("다시 플레이", "Play Again", "もう一度遊ぶ", "Jugar de Nuevo", "Rejouer", "Jogar Novamente", "Nochmal spielen") } + var casinoExclusiveBadge: String { t("카지노 한정", "Casino Exclusive", "カジノ限定", "Exclusivo Casino", "Exclusif Casino", "Exclusivo Cassino", "Exklusiv Spielhalle") } + var exclusivePokemonHint: String { t("알에서 부화하지 않는 전용 포켓몬", "Exclusive Pokémon unobtainable from eggs", "タマゴからは孵化しない限定ポケモン", "Pokémon exclusivo no obtenible de huevos", "Pokémon exclusif impossible à obtenir via les œufs", "Pokémon exclusivo não obtido por ovos", "Exklusives Pokémon, nicht aus Eiern erhältlich") } + func casinoPrizeSpeciesName(_ id: Int) -> String { + switch id { + case 137: return t("폴리곤", "Porygon", "ポリゴン", "Porygon", "Porygon", "Porygon", "Porygon") + case 63: return t("케이시", "Abra", "ケーシィ", "Abra", "Abra", "Abra", "Abra") + case 173: return t("삐", "Cleffa", "ピィ", "Cleffa", "Mélo", "Cleffa", "Pii") + case 147: return t("미뇽", "Dratini", "ミニリュウ", "Dratini", "Minidraco", "Dratini", "Dratini") + case 123: return t("스라크", "Scyther", "ストライク", "Scyther", "Insécateur", "Scyther", "Sichlor") + default: return "#\(id)" + } + } + var appThemesSection: String { t("앱 테마", "App Themes", "アプリテーマ", "Temas de App", "Thèmes d'application", "Temas do App", "App-Designs") } + var themeApplied: String { t("적용 중", "Active", "適用中", "Activo", "Actif", "Ativo", "Aktiv") } + var themeApplyAction: String { t("적용하기", "Apply", "適用", "Aplicar", "Appliquer", "Aplicar", "Aktivieren") } + var exchangeAction: String { t("교환", "Exchange", "交換", "Canjear", "Échanger", "Trocar", "Eintauschen") } + func themeName(_ kind: AppThemeKind) -> String { + switch kind { + case .classic: return t("클래식", "Classic", "クラシック", "Clásico", "Classique", "Clássico", "Klassisch") + case .gameBoy1989: return t("게임보이 1989", "Game Boy 1989", "ゲームボーイ 1989", "Game Boy 1989", "Game Boy 1989", "Game Boy 1989", "Game Boy 1989") + case .celadonNeon: return t("무지개 네온", "Celadon Neon", "タマムシネオン", "Azulona Neón", "Néon Céladopole", "Celadon Neon", "Prismania Neon") + case .teamRocket: return t("로켓단 다크", "Team Rocket Dark", "ロケット団ダーク", "Team Rocket Oscuro", "Team Rocket Noir", "Equipe Rocket Escuro", "Team Rocket Dunkel") + case .indigoPlateau: return t("석영고원 챔피언", "Indigo Plateau", "セキエイ高原", "Meseta Añil", "Plateau Indigo", "Planalto Índigo", "Indigo Plateau") + case .masterBall: return t("마스터볼", "Master Ball", "マスターボール", "Master Ball", "Master Ball", "Master Ball", "Meisterball") + } + } + var newThemeUnlocked: String { t("새 테마 해금!", "New Theme Unlocked!", "新テーマ解放!", "¡Nuevo Tema Desbloqueado!", "Nouveau Thème Débloqué !", "Novo Tema Desbloqueado!", "Neues Design Freigeschaltet!") } + var activeThemeLabel: String { t("활성 테마", "Active Theme", "適用中のテーマ", "Tema Activo", "Thème Actif", "Tema Ativo", "Aktives Design") } + var unlockedThemesCount: String { t("해금됨", "unlocked", "解放済み", "desbloqueados", "débloqués", "desbloqueados", "freigeschaltet") } + var slotThemeRollHint: String { t("푸린 3마리를 맞추면 무작위 테마를 획득할 수 있습니다!", "Line up 3 Jigglypuffs to roll for a random theme!", "プリンを3体揃えるとランダムなテーマを獲得できます!", "¡Alinea 3 Jigglypuff para obtener un tema aleatorio!", "Alignez 3 Rondoudou pour remporter un thème aléatoire selon sa rareté !", "Alinhe 3 Jigglypuff para tentar a sorte em um tema aleatório!", "3 Pummeluff anordnen, um ein zufälliges Design zu gewinnen!") } + func themeRarityLabel(_ kind: AppThemeKind) -> String { + switch kind { + case .classic: + return t("기본", "Base", "基本", "Base", "Base", "Base", "Basis") + case .gameBoy1989: + return t("일반", "Common", "コモン", "Común", "Commun", "Comum", "Gewöhnlich") + case .celadonNeon: + return t("고급", "Uncommon", "アンコモン", "Poco Común", "Peu commun", "Incomum", "Ungewöhnlich") + case .teamRocket: + return t("희귀", "Rare", "レア", "Raro", "Rare", "Raro", "Selten") + case .indigoPlateau: + return t("에픽", "Epic", "エピック", "Épico", "Épique", "Épico", "Episch") + case .masterBall: + return t("전설", "Legendary", "レジェンド", "Legendario", "Légendaire", "Lendário", "Legendär") + } + } + func themeUnlockHint(_ kind: AppThemeKind) -> String { + switch kind { + case .classic: + return t("기본 해금", "Unlocked by default", "初期解放", "Desbloqueado por defecto", "Débloqué par défaut", "Desbloqueado por padrão", "Standardmäßig freigeschaltet") + default: + return t("슬롯머신에서 푸린 3마리 달성 시 무작위 추첨", "Random roll when lining up 3 Jigglypuffs on slots", "スロットでプリン3体時にランダム抽選", "Tirada aleatoria al alinear 3 Jigglypuff en la tragaperras", "Tirage aléatoire en alignant 3 Rondoudou à la machine", "Sorteio aleatório ao alinhar 3 Jigglypuff no caça-níquel", "Zufallsziehung bei 3 Pummeluff am Automaten") + } + } + func linesCountLabel(_ count: Int) -> String { + switch count { + case 1: return t("1라인", "1 Line", "1ライン", "1 Línea", "1 Ligne", "1 Linha", "1 Linie") + case 3: return t("3라인", "3 Lines", "3ライン", "3 Líneas", "3 Lignes", "3 Linhas", "3 Linien") + default: return t("5라인", "5 Lines", "5ライン", "5 Líneas", "5 Lignes", "5 Linhas", "5 Linien") + } + } } diff --git a/Sources/PokeTokenBar/Core/SaveTransfer.swift b/Sources/PokeTokenBar/Core/SaveTransfer.swift index 828ba88cd..53c0a9cfb 100644 --- a/Sources/PokeTokenBar/Core/SaveTransfer.swift +++ b/Sources/PokeTokenBar/Core/SaveTransfer.swift @@ -149,6 +149,7 @@ enum SaveTransfer { s.claimedTodayTokensByProvider = s.claimedTodayTokensByProvider?.reduce(into: [:]) { result, entry in result[entry.key] = clampToken(entry.value) } + s.casinoCoins = max(0, min(s.casinoCoins, maxTokenValue)) // 알 보증은 "지금 품고 있는 알"에만 붙는 값이라 활성 포켓몬과 공존할 수 없다. 손편집·구버전 // 조합으로 둘 다 들어오면 그 보증이 다음 알로 새어 영구 프리미엄이 되므로 여기서 떨군다. // 그 보증으로 미리 뽑아둔 종(pendingHatchID)도 함께 버린다 — 보증만 지우면 졸업 후 받는 **무료** @@ -182,7 +183,7 @@ enum SaveTransfer { /// - **로컬 장부**: *그 기기가* 어디까지 적립했나(`claimedTodayTokensByProvider`·`lastDate`·`installBaselineSet`) /// → 새 기기 기준으로 다시 잡는다. 그대로 들여오면 옛 기기의 오늘 총량이 문턱이 되어 /// `CompanionStore.update` 의 프로바이더별 증분 게이트가 이전 당일 내내 거짓이 되고, - /// 새 기기 사용분이 조용히 안 잡힌다(자정에 저절로 낫기 때문에 버그로 안 보인다). + /// 새 기기 사용분이 조용히 안 잡힌다(자정에 저절로 낮기 때문에 버그로 안 보인다). /// - **기기 환경설정**: 진행이 아니라 이 기기에서 보는 방식(`language`) → **현재 기기 값을 지킨다**. /// 일본어 Mac 의 세이브가 영어 Mac 의 UI 언어를 바꾸면 안 된다. /// @@ -196,6 +197,8 @@ enum SaveTransfer { hasUsageData: Bool) -> CompanionState { var state = imported state.language = current.language + state.activeTheme = current.activeTheme + state.unlockedThemes = imported.unlockedThemes.union(current.unlockedThemes) state.candyGrantTier = mergedGrantTier(imported.candyGrantTier, current.candyGrantTier) state.candyFeatureSeeded = imported.candyFeatureSeeded || current.candyFeatureSeeded let hasCurrentProviderData = hasUsageData && !todayTokensByProvider.isEmpty diff --git a/Sources/PokeTokenBar/UI/BagView.swift b/Sources/PokeTokenBar/UI/BagView.swift index 6582a1730..df48fe7b2 100644 --- a/Sources/PokeTokenBar/UI/BagView.swift +++ b/Sources/PokeTokenBar/UI/BagView.swift @@ -76,6 +76,7 @@ private struct ItemCard: View { switch kind { case .rareCandy: return store.canUseRareCandy case .mint: return store.canUseMint + case .starPrism: return store.canUseStarPrism case .shinyCharm: return false // 보유형 — 사용 개념 없음(상시 효과) } } @@ -85,12 +86,14 @@ private struct ItemCard: View { case .rareCandy: return "+\(TokenFormatter.compact(RareCandy.xp)) XP" case .mint: return l.mintEffectHint case .shinyCharm: return l.shinyCharmEffectHint + case .starPrism: return l.starPrismEffectHint } } private func performUse() { switch kind { case .rareCandy: _ = store.useRareCandy() case .mint: _ = store.useMint() + case .starPrism: _ = store.useStarPrism() case .shinyCharm: break // 보유형 — 사용 동작 없음 } } diff --git a/Sources/PokeTokenBar/UI/CasinoView.swift b/Sources/PokeTokenBar/UI/CasinoView.swift new file mode 100644 index 000000000..7b2244995 --- /dev/null +++ b/Sources/PokeTokenBar/UI/CasinoView.swift @@ -0,0 +1,824 @@ +import SwiftUI + +// MARK: - Generic Item Sprite & Coin Icon Views + +/// PokéAPI 아이템 스프라이트(캐시/런타임 페치)를 렌더링하는 뷰 (이모지 없음). +@MainActor +struct ItemNamedSpriteView: View { + let name: String + var size: CGFloat = 20 + var fallbackSymbol: String = "circle.inset.filled" + @State private var img: NSImage? + + init(name: String, size: CGFloat = 20, fallbackSymbol: String = "circle.inset.filled") { + self.name = name + self.size = size + self.fallbackSymbol = fallbackSymbol + _img = State(initialValue: SpriteLoader.cachedItemImage(name: name)) + } + + var body: some View { + Group { + if let img { + let fit = SpriteFit.size(for: img.size, box: size) + Image(nsImage: img).resizable().interpolation(.none) + .frame(width: fit.width, height: fit.height) + .frame(width: size, height: size) + } else { + Image(systemName: fallbackSymbol) + .font(.system(size: size * 0.65)) + .frame(width: size, height: size) + .foregroundStyle(.secondary) + } + } + .task(id: name) { + guard img == nil else { return } + img = await SpriteLoader.itemImage(name: name) + } + } +} + +/// 동전 케이스(Coin Case) 스프라이트를 사용하는 카지노 동전 아이콘. +@MainActor +struct CoinSpriteView: View { + var size: CGFloat = 16 + + var body: some View { + ItemNamedSpriteView(name: "coin-case", size: size, fallbackSymbol: "circle.circle.fill") + } +} + +// MARK: - Main Casino View (Clean, Simple & Overflow-Proof) + +/// 카지노 (Casino) +/// 깔끔한 2단 탭: [ 슬롯머신 | 경품 교환소 ] +@MainActor +struct CasinoView: View { + let store: CompanionStore + let nav: PopoverNavigation + + enum MainTab: Int, CaseIterable { + case slots + case prizes + } + + @State private var mainTab: MainTab = .slots + @State private var showCoinStore: Bool = false + + var body: some View { + let l = store.l + VStack(alignment: .leading, spacing: 8) { + // Header Bar: Solde de Jetons + Bouton Achat + headerBar(l) + + // Tiroir d'achat de jetons (repliable) + if showCoinStore { + coinStoreSection(l) + } + + // Navigation principale: [ Machine à sous | Comptoir des Prix ] + Picker("", selection: $mainTab) { + Text(l.slotMachine).tag(MainTab.slots) + Text(l.prizeCorner).tag(MainTab.prizes) + } + .pickerStyle(.segmented) + .labelsHidden() + + // Contenu de la section + if mainTab == .slots { + SlotMachineView(store: store) + } else { + CasinoPrizeCornerView(store: store, nav: nav) + } + } + .frame(maxWidth: PopoverMetrics.contentWidth, alignment: .top) + .animation(.easeInOut(duration: 0.15), value: showCoinStore) + } + + private func headerBar(_ l: L) -> some View { + HStack(spacing: 8) { + CoinSpriteView(size: 18) + VStack(alignment: .leading, spacing: 0) { + Text(l.coinBalance) + .font(.caption2) + .foregroundStyle(.secondary) + Text("\(store.casinoCoins)") + .font(.system(size: 17, weight: .bold)) + .monospacedDigit() + } + + Spacer() + + Button { + showCoinStore.toggle() + } label: { + HStack(spacing: 3) { + Image(systemName: showCoinStore ? "chevron.up" : "plus") + .font(.caption2) + Text(l.buyCoins) + .font(.caption) + } + } + .buttonStyle(.bordered) + .controlSize(.small) + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + .background(Color.secondary.opacity(0.06)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + + private func coinStoreSection(_ l: L) -> some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(l.buyCoins) + .font(.caption2.weight(.bold)) + .foregroundStyle(.secondary) + Spacer() + Text("\(l.spendableTokens): \(TokenFormatter.compact(store.availableTokens))") + .font(.caption2) + .foregroundStyle(.secondary) + } + + LazyVGrid(columns: [GridItem(.flexible(), spacing: 6), GridItem(.flexible(), spacing: 6)], spacing: 6) { + ForEach(CoinPackage.all) { pkg in + CoinPackageTile(store: store, package: pkg) + } + } + } + .padding(8) + .background(Color.secondary.opacity(0.04)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } +} + +// MARK: - Coin Package Tile (Clean & Compact) + +@MainActor +private struct CoinPackageTile: View { + let store: CompanionStore + let package: CoinPackage + + private var canAfford: Bool { store.availableTokens >= package.tokenCost } + + var body: some View { + HStack(spacing: 4) { + CoinSpriteView(size: 13) + VStack(alignment: .leading, spacing: 1) { + HStack(spacing: 2) { + Text("\(package.coins)") + .font(.caption2.weight(.bold)) + if package.bonusPercent > 0 { + Text("+\(package.bonusPercent)%") + .font(.system(size: 7, weight: .bold)) + .foregroundStyle(.orange) + } + } + Text(TokenFormatter.compact(package.tokenCost)) + .font(.system(size: 8)) + .foregroundStyle(.secondary) + } + Spacer(minLength: 2) + Button(store.l.buy) { + store.buyCasinoCoins(package: package) + } + .buttonStyle(.borderedProminent) + .controlSize(.mini) + .disabled(!canAfford) + } + .padding(5) + .background(Color.secondary.opacity(0.06)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } +} + +// MARK: - Slot Machine View (Clean, Native & Perfectly Sized) + +@MainActor +private struct SlotMachineView: View { + let store: CompanionStore + + @State private var grid: [[SlotSymbol]] = [ + [.seven, .bar, .pikachu], + [.cherry, .pokeBall, .jigglypuff], + [.bar, .voltorb, .seven] + ] + @State private var selectedLines: Int = 5 + @State private var multiplier: Int = 1 + @State private var isSpinning: Bool = false + @State private var lastResult: SlotResult? = nil + @State private var newlyUnlockedTheme: AppThemeKind? = nil + @State private var showPayouts: Bool = false + + private var baseCoins: Int { + switch selectedLines { + case 1: return 1 + case 3: return 2 + default: return 3 + } + } + + private var totalCost: Int { baseCoins * multiplier } + private var canSpin: Bool { store.casinoCoins >= totalCost && !isSpinning } + + var body: some View { + let l = store.l + VStack(spacing: 8) { + // Theme Celebration Toast + if let wonTheme = newlyUnlockedTheme { + HStack(spacing: 6) { + ItemNamedSpriteView(name: wonTheme.itemSprite, size: 16) + VStack(alignment: .leading, spacing: 0) { + Text("\(l.newThemeUnlocked) \(l.themeName(wonTheme))") + .font(.caption2.weight(.bold)) + .foregroundStyle(.yellow) + Text(l.themeRarityLabel(wonTheme)) + .font(.system(size: 8)) + .foregroundStyle(.secondary) + } + Spacer() + Button(l.themeApplyAction) { + store.setActiveTheme(wonTheme) + newlyUnlockedTheme = nil + } + .buttonStyle(.borderedProminent) + .controlSize(.mini) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.yellow.opacity(0.15)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + + // Win Toast + if let res = lastResult, res.totalWin > 0 { + HStack(spacing: 4) { + if res.isJackpot { + Text(l.jackpotWin) + .font(.caption2.weight(.black)) + .foregroundStyle(.yellow) + } else { + Text("+" + String(res.totalWin)) + .font(.caption2.weight(.bold)) + .foregroundStyle(.green) + CoinSpriteView(size: 11) + } + } + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background(res.isJackpot ? Color.yellow.opacity(0.2) : Color.green.opacity(0.12)) + .clipShape(Capsule()) + } + + // 3x3 Reels (Clean & centered) + VStack(spacing: 3) { + ForEach(0..<3, id: \.self) { row in + HStack(spacing: 3) { + ForEach(0..<3, id: \.self) { col in + SlotCellTile( + symbol: grid[row][col], + isHighlighted: isCellInWinningLine(row: row, col: col) + ) + } + } + } + } + .padding(6) + .background(Color.secondary.opacity(0.06)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + + // Betting Controls: Lines & Multipliers + HStack(spacing: 6) { + HStack(spacing: 4) { + Text(l.paylinesCount) + .font(.caption2) + .foregroundStyle(.secondary) + Picker("", selection: $selectedLines) { + Text("1").tag(1) + Text("3").tag(3) + Text("5").tag(5) + } + .pickerStyle(.segmented) + .labelsHidden() + .frame(width: 80) + } + + Spacer() + + HStack(spacing: 4) { + Text(l.multiplierLabel) + .font(.caption2) + .foregroundStyle(.secondary) + Picker("", selection: $multiplier) { + Text("×1").tag(1) + Text("×5").tag(5) + Text("×10").tag(10) + } + .pickerStyle(.segmented) + .labelsHidden() + .frame(width: 90) + } + } + .padding(.horizontal, 2) + + // Spin Action Button + Button { + spinReels() + } label: { + HStack(spacing: 5) { + if isSpinning { + ProgressView().controlSize(.small) + Text(l.spinning) + .font(.caption.weight(.semibold)) + } else { + Text(l.spinReels) + .font(.caption.weight(.semibold)) + Text("(\(totalCost)") + .font(.caption2) + CoinSpriteView(size: 11) + Text(")") + .font(.caption2) + } + } + .frame(maxWidth: .infinity) + .padding(.vertical, 3) + } + .buttonStyle(.borderedProminent) + .controlSize(.regular) + .disabled(!canSpin) + + // Payouts Disclosure + DisclosureGroup(isExpanded: $showPayouts) { + payoutsSummaryView(l) + } label: { + Text(l.payoutTable) + .font(.caption2) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 2) + } + .padding(8) + .background(Color.secondary.opacity(0.04)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + + private func isCellInWinningLine(row: Int, col: Int) -> Bool { + guard let lines = lastResult?.winningLines else { return false } + for line in lines { + switch line { + case 0: if row == 1 { return true } + case 1: if row == 0 { return true } + case 2: if row == 2 { return true } + case 3: if row == col { return true } + case 4: if row == (2 - col) { return true } + default: break + } + } + return false + } + + private func spinReels() { + guard store.spendCasinoCoins(totalCost) else { return } + isSpinning = true + lastResult = nil + newlyUnlockedTheme = nil + + Task { + for _ in 0..<4 { + try? await Task.sleep(nanoseconds: 60_000_000) + grid = [ + [SlotSymbol.allCases.randomElement()!, SlotSymbol.allCases.randomElement()!, SlotSymbol.allCases.randomElement()!], + [SlotSymbol.allCases.randomElement()!, SlotSymbol.allCases.randomElement()!, SlotSymbol.allCases.randomElement()!], + [SlotSymbol.allCases.randomElement()!, SlotSymbol.allCases.randomElement()!, SlotSymbol.allCases.randomElement()!] + ] + } + + let result = SlotMachineEngine.spin( + lines: selectedLines, + multiplier: multiplier, + currentUnlocked: store.unlockedThemes + ) + grid = result.grid + lastResult = result + + if result.totalWin > 0 { + store.addCasinoCoins(result.totalWin) + } + + if let wonTheme = result.unlockedTheme { + store.unlockCasinoTheme(wonTheme) + newlyUnlockedTheme = wonTheme + } + + isSpinning = false + } + } + + private func payoutsSummaryView(_ l: L) -> some View { + VStack(alignment: .leading, spacing: 6) { + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: 4) { + payoutEntry("777", "300×") + payoutEntry("BAR", "100×") + HStack(spacing: 2) { + SpriteView(speciesID: 39, size: 14) + Text("30×").font(.system(size: 9, weight: .bold)).foregroundStyle(.orange) + Image(systemName: "sparkles") + .font(.system(size: 7)) + .foregroundStyle(.purple) + } + HStack(spacing: 2) { + ItemNamedSpriteView(name: "cheri-berry", size: 12) + Text("25×").font(.system(size: 9, weight: .bold)).foregroundStyle(.orange) + } + HStack(spacing: 2) { + SpriteView(speciesID: 25, size: 14) + Text("20×").font(.system(size: 9, weight: .bold)).foregroundStyle(.orange) + } + HStack(spacing: 2) { + ItemNamedSpriteView(name: "poke-ball", size: 12) + Text("10×").font(.system(size: 9, weight: .bold)).foregroundStyle(.orange) + } + } + .padding(.top, 4) + + HStack(spacing: 3) { + ItemNamedSpriteView(name: "cheri-berry", size: 10) + Text(l.twoCherriesWinHint) + Text("·") + Text(l.slotThemeRollHint) + } + .font(.system(size: 8)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .center) + } + } + + private func payoutEntry(_ label: String, _ mult: String) -> some View { + HStack(spacing: 2) { + Text(label).font(.system(size: 9, weight: .bold)) + Text(mult).font(.system(size: 9, weight: .bold)).foregroundStyle(.orange) + } + } +} + +// MARK: - Slot Cell Tile + +@MainActor +private struct SlotCellTile: View { + let symbol: SlotSymbol + let isHighlighted: Bool + + var body: some View { + ZStack { + switch symbol.visual { + case .text(let txt): + Text(txt) + .font(.system(size: 13, weight: .heavy, design: .rounded)) + .foregroundStyle(txt == "777" ? Color.red : Color.primary) + case .pokemon(let speciesID): + SpriteView(speciesID: speciesID, size: 28, animated: false) + case .item(let itemName): + ItemNamedSpriteView(name: itemName, size: 24) + } + } + .frame(width: 44, height: 40) + .background(isHighlighted ? Color.yellow.opacity(0.2) : Color.secondary.opacity(0.08)) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(isHighlighted ? Color.yellow : Color.clear, lineWidth: 1.5) + ) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } +} + +// MARK: - Prize Corner View (Consistent with ShopItemCard style) + +@MainActor +private struct CasinoPrizeCornerView: View { + let store: CompanionStore + let nav: PopoverNavigation + + var body: some View { + let l = store.l + VStack(alignment: .leading, spacing: 8) { + // Porygon (Featured) + if let porygonItem = CasinoPrizeItem.all.first(where: { $0.speciesID == 137 }) { + PorygonFeatureCard(store: store, item: porygonItem) + } + + // Star Prism + if let starPrism = CasinoPrizeItem.all.first(where: { $0.isStarPrism }) { + StarPrismPrizeCard(store: store, item: starPrism) + } + + // Rare Pokémon + VStack(alignment: .leading, spacing: 4) { + Text(l.rarePokemon) + .font(.caption2.weight(.bold)) + .foregroundStyle(.secondary) + + let rareMons = CasinoPrizeItem.all.filter { $0.speciesID != nil && $0.speciesID != 137 } + LazyVGrid(columns: [GridItem(.flexible(), spacing: 6), GridItem(.flexible(), spacing: 6)], spacing: 6) { + ForEach(rareMons) { item in + CasinoPokemonCard(store: store, item: item) + } + } + } + + // App Themes + VStack(alignment: .leading, spacing: 4) { + Text(l.appThemesSection) + .font(.caption2.weight(.bold)) + .foregroundStyle(.secondary) + + Text(l.slotThemeRollHint) + .font(.caption2) + .foregroundStyle(.tertiary) + .padding(.bottom, 2) + + VStack(spacing: 3) { + ForEach(AppThemeKind.allCases, id: \.self) { theme in + ThemeRowTile(store: store, theme: theme) + } + } + } + } + } +} + +// MARK: - Porygon Feature Card + +@MainActor +private struct PorygonFeatureCard: View { + let store: CompanionStore + let item: CasinoPrizeItem + @State private var confirming = false + + private var canAfford: Bool { store.casinoCoins >= item.coinCost } + + var body: some View { + let l = store.l + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .top, spacing: 8) { + SpriteView(speciesID: 137, size: 36, animated: false) + + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text(l.casinoPrizeSpeciesName(137)) + .font(.callout.weight(.semibold)) + Text(l.casinoExclusiveBadge) + .font(.system(size: 8, weight: .bold)) + .foregroundStyle(.purple) + .padding(.horizontal, 4) + .padding(.vertical, 1) + .background(Color.purple.opacity(0.15)) + .clipShape(Capsule()) + } + Text(l.exclusivePokemonHint) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(2) + } + Spacer() + } + + HStack { + HStack(spacing: 3) { + Text(l.shopPriceLabel) + .font(.caption2) + .foregroundStyle(.tertiary) + CoinSpriteView(size: 13) + Text("\(item.coinCost)") + .font(.caption2.weight(.bold)) + .monospacedDigit() + } + Spacer() + + if confirming { + HStack(spacing: 4) { + Button(l.exchangeAction) { + store.buyCasinoPokemon(speciesID: 137, coinCost: item.coinCost) + confirming = false + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + + Button(l.cancel) { confirming = false } + .buttonStyle(.borderless) + .controlSize(.small) + } + } else { + Button(l.exchangeAction) { confirming = true } + .buttonStyle(.borderedProminent) + .controlSize(.small) + .disabled(!canAfford) + } + } + } + .padding(8) + .background(Color.secondary.opacity(0.06)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } +} + +// MARK: - Star Prism Prize Card + +@MainActor +private struct StarPrismPrizeCard: View { + let store: CompanionStore + let item: CasinoPrizeItem + @State private var confirming = false + + private var canAfford: Bool { store.casinoCoins >= item.coinCost } + + var body: some View { + let l = store.l + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .top, spacing: 8) { + ItemNamedSpriteView(name: "bright-powder", size: 30) + + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text(l.itemName(.starPrism)) + .font(.callout.weight(.semibold)) + let owned = store.itemCount(.starPrism) + if owned > 0 { + Text(l.ownedCount(owned)) + .font(.caption2.weight(.bold)) + .foregroundStyle(.secondary) + } + } + Text(l.itemDescription(.starPrism)) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(2) + } + Spacer() + } + + HStack { + HStack(spacing: 3) { + Text(l.shopPriceLabel) + .font(.caption2) + .foregroundStyle(.tertiary) + CoinSpriteView(size: 13) + Text("\(item.coinCost)") + .font(.caption2.weight(.bold)) + .monospacedDigit() + } + Spacer() + + if confirming { + HStack(spacing: 4) { + Button(l.exchangeAction) { + store.buyStarPrism() + confirming = false + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + + Button(l.cancel) { confirming = false } + .buttonStyle(.borderless) + .controlSize(.small) + } + } else { + Button(l.exchangeAction) { confirming = true } + .buttonStyle(.borderedProminent) + .controlSize(.small) + .disabled(!canAfford) + } + } + } + .padding(8) + .background(Color.secondary.opacity(0.06)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } +} + +// MARK: - Casino Pokemon Card (Abra, Dratini, Scyther, Cleffa) + +@MainActor +private struct CasinoPokemonCard: View { + let store: CompanionStore + let item: CasinoPrizeItem + @State private var confirming = false + + private var canAfford: Bool { store.casinoCoins >= item.coinCost } + + var body: some View { + let l = store.l + let speciesID = item.speciesID ?? 1 + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + SpriteView(speciesID: speciesID, size: 28, animated: false) + VStack(alignment: .leading, spacing: 1) { + Text(l.casinoPrizeSpeciesName(speciesID)) + .font(.caption.weight(.semibold)) + .lineLimit(1) + HStack(spacing: 2) { + CoinSpriteView(size: 10) + Text("\(item.coinCost)") + .font(.system(size: 9, weight: .bold)) + .monospacedDigit() + } + } + Spacer(minLength: 0) + } + + HStack { + Spacer() + if confirming { + HStack(spacing: 2) { + Button(l.exchangeAction) { + store.buyCasinoPokemon(speciesID: speciesID, coinCost: item.coinCost) + confirming = false + } + .buttonStyle(.borderedProminent) + .controlSize(.mini) + + Button(action: { confirming = false }) { + Image(systemName: "xmark") + } + .buttonStyle(.borderless) + .controlSize(.mini) + } + } else { + Button(l.exchangeAction) { confirming = true } + .buttonStyle(.bordered) + .controlSize(.mini) + .disabled(!canAfford) + } + } + } + .padding(6) + .background(Color.secondary.opacity(0.06)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } +} + +// MARK: - Theme Row Tile (Showcase & Apply) + +@MainActor +private struct ThemeRowTile: View { + let store: CompanionStore + let theme: AppThemeKind + + private var isUnlocked: Bool { store.unlockedThemes.contains(theme.rawValue) } + private var isActive: Bool { store.activeTheme == theme } + + var body: some View { + let l = store.l + HStack(spacing: 8) { + ItemNamedSpriteView(name: theme.itemSprite, size: 20) + + HStack(spacing: 5) { + Text(l.themeName(theme)) + .font(.caption.weight(.semibold)) + + if theme != .classic { + Text(l.themeRarityLabel(theme)) + .font(.system(size: 8, weight: .bold)) + .foregroundStyle(.secondary) + .padding(.horizontal, 4) + .padding(.vertical, 1) + .background(Color.secondary.opacity(0.12)) + .clipShape(Capsule()) + } + + HStack(spacing: 2) { + Circle().fill(theme.accentColor).frame(width: 5, height: 5) + Circle().fill(theme.secondaryAccentColor).frame(width: 5, height: 5) + } + } + + Spacer(minLength: 4) + + if isUnlocked { + if isActive { + Text(l.themeApplied) + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(theme.accentColor) + .padding(.horizontal, 5) + .padding(.vertical, 2) + .background(theme.accentColor.opacity(0.15)) + .clipShape(Capsule()) + } else { + Button(l.themeApplyAction) { + store.setActiveTheme(theme) + } + .buttonStyle(.bordered) + .controlSize(.mini) + } + } else { + Image(systemName: "lock.fill") + .font(.system(size: 9)) + .foregroundStyle(.tertiary) + .padding(.trailing, 2) + } + } + .padding(.horizontal, 8) + .padding(.vertical, 5) + .background(isActive ? theme.accentColor.opacity(0.08) : Color.secondary.opacity(0.04)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } +} diff --git a/Sources/PokeTokenBar/UI/CompanionView.swift b/Sources/PokeTokenBar/UI/CompanionView.swift index 2debc2d8e..c2aa3cf00 100644 --- a/Sources/PokeTokenBar/UI/CompanionView.swift +++ b/Sources/PokeTokenBar/UI/CompanionView.swift @@ -255,6 +255,7 @@ struct EvoLineView: View { var names: [Int: String]? = nil // 제공되면 각 스프라이트 밑에 작은 이름 라벨(도감 단계별 이름) /// 한 줄이 쓸 수 있는 가로 폭. 기본 .infinity = 제한 없음(스크롤 없이 나열). var maxWidth: CGFloat = .infinity + var accentColor: Color = .accentColor private static let spacing: CGFloat = 2 /// 화살표 칸 폭 = 썸네일 × 이 비율. 고정 frame 을 줘 SF Symbol 글리프 폭에 의존하지 않게 한다 — @@ -431,7 +432,7 @@ struct EvoLineView: View { .saturation(node.state == .future ? 0.4 : 1) .overlay(alignment: .bottom) { if node.state == .current { - Circle().fill(Color.accentColor).frame(width: 4, height: 4).offset(y: 2) + Circle().fill(accentColor).frame(width: 4, height: 4).offset(y: 2) } } if let names, case .species(let id) = node.content { @@ -577,7 +578,8 @@ struct CompanionHeader: View { if store.hasActive, !store.lineNodes.isEmpty { // 폭을 안 주면 분기 라인(이브이)이 넘쳐 팝오버 콘텐츠 전체가 좌우로 잘린다. EvoLineView(nodes: store.lineNodes, mysteryLabel: store.l.unknownNextEvolution, language: store.language, shiny: store.currentIsShiny, - maxWidth: PopoverMetrics.contentWidth) + maxWidth: PopoverMetrics.contentWidth, + accentColor: store.activeTheme.accentColor) } if let g = store.justGraduated { Text(store.l.graduated(g)) @@ -1059,7 +1061,7 @@ private struct PokemonDetailView: View { Text(store.l.rarityLabel(species.rarity)) .font(.caption.weight(.semibold)).foregroundStyle(.secondary) if species.isShiny { Text("✨ \(store.l.dexShinyLabel)").font(.caption2) } - if species.isRaising { Text(store.l.dexRaising).font(.caption2).foregroundStyle(Color.accentColor) } + if species.isRaising { Text(store.l.dexRaising).font(.caption2).foregroundStyle(store.activeTheme.accentColor) } let isRepresentative = store.representativeSpeciesID == species.id RepresentativeFooterButton(localization: store.l, isRepresentative: isRepresentative) { @@ -1140,7 +1142,7 @@ private struct PokemonDetailView: View { private func statRow(name: String, value: Int, iv: Int?, scaleMaximum: Int) -> some View { HStack(spacing: 6) { Text(store.l.statLabel(name)).frame(width: 62, alignment: .leading) - ProgressView(value: Double(value), total: Double(scaleMaximum)).tint(Color.accentColor) + ProgressView(value: Double(value), total: Double(scaleMaximum)).tint(store.activeTheme.accentColor) Text("\(value)").monospacedDigit().frame(width: 28, alignment: .trailing) if let iv { Text("IV \(iv)").foregroundStyle(.secondary).frame(width: 34, alignment: .trailing) } } @@ -1155,7 +1157,7 @@ private struct PokemonDetailView: View { PokemonNameLabel(.type, type, language: store.language).textCase(.uppercase) .font(.system(size: 9, weight: .bold)) .padding(.horizontal, 6).padding(.vertical, 3) - .background(Color.accentColor.opacity(0.16), in: Capsule()) + .background(store.activeTheme.accentColor.opacity(0.16), in: Capsule()) } } HStack(spacing: 14) { @@ -1269,13 +1271,13 @@ private struct DexSpeciesCell: View { .padding(3) // 대표 = 영속적인 accent 배경, 방금 클릭한 칸 = 기존 accent 테두리. // 서로 다른 카드여도 같은 강조 두 개가 선택된 것처럼 보이지 않는다. - .background(isRepresentative ? Color.accentColor.opacity(0.16) + .background(isRepresentative ? store.activeTheme.accentColor.opacity(0.16) : Color.secondary.opacity(isSelected ? 0.16 : 0.06)) .clipShape(RoundedRectangle(cornerRadius: 8)) .overlay { if isSelected { RoundedRectangle(cornerRadius: 8) - .strokeBorder(Color.accentColor, lineWidth: 1.5) + .strokeBorder(store.activeTheme.accentColor, lineWidth: 1.5) } } } @@ -1304,7 +1306,7 @@ private struct DexSpeciesCell: View { if isRepresentative { Image(systemName: "star.fill") .font(.system(size: 6, weight: .bold)) - .foregroundStyle(Color.accentColor) + .foregroundStyle(store.activeTheme.accentColor) .accessibilityHidden(true) } } @@ -1320,8 +1322,8 @@ private struct DexSpeciesCell: View { Text(store.l.dexRaising.uppercased()) .font(.system(size: 8, weight: .bold)) .padding(.horizontal, 5).padding(.vertical, 1) - .foregroundStyle(Color.accentColor) - .background(Color.accentColor.opacity(0.14), in: Capsule()) + .foregroundStyle(store.activeTheme.accentColor) + .background(store.activeTheme.accentColor.opacity(0.14), in: Capsule()) .background(.regularMaterial, in: Capsule()) } @@ -1361,8 +1363,8 @@ private struct DexEntryRow: View { 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) + .background(store.activeTheme.accentColor.opacity(0.14)) + .foregroundStyle(store.activeTheme.accentColor) .clipShape(Capsule()) } else if entry.isReleased { // 놓아준 개체 — 종은 도감에 남지만 이 개체는 끝까지 키우지 않았다. @@ -1388,7 +1390,8 @@ private struct DexEntryRow: View { EvoLineView(nodes: entry.chainOrder.map { EvoLineItem(.species($0), .done) }, mysteryLabel: store.l.unknownNextEvolution, language: store.language, thumb: 56, shiny: entry.isShiny, names: names, - maxWidth: PopoverMetrics.contentWidth - Self.cardPadding * 2) + maxWidth: PopoverMetrics.contentWidth - Self.cardPadding * 2, + accentColor: store.activeTheme.accentColor) if let caughtAt = entry.caughtAt { Text(caughtAt, style: .relative).font(.system(size: 9)).foregroundStyle(.tertiary) } diff --git a/Sources/PokeTokenBar/UI/PopoverView.swift b/Sources/PokeTokenBar/UI/PopoverView.swift index 8d6619e62..711030e6e 100644 --- a/Sources/PokeTokenBar/UI/PopoverView.swift +++ b/Sources/PokeTokenBar/UI/PopoverView.swift @@ -81,6 +81,9 @@ struct PopoverView: View { } } .frame(width: PopoverMetrics.width) + .background(companion.activeTheme.windowBackground) + .tint(companion.activeTheme.accentColor) + .accentColor(companion.activeTheme.accentColor) .environment(\.locale, companion.language.displayLocale) } @@ -102,7 +105,7 @@ struct PopoverView: View { } } .padding(8) - .background(Color.accentColor.opacity(0.12)) + .background(companion.activeTheme.accentColor.opacity(0.12)) .clipShape(RoundedRectangle(cornerRadius: 8)) } } @@ -178,7 +181,8 @@ struct PopoverView: View { MonthDailyTrend(series: store.monthDailyTotals, showsCost: store.showsCost, today: LocalUsageReader.todayKey(), - l: l) + l: l, + accentColor: companion.activeTheme.accentColor) // 연결된 서비스가 2개 이상이면 작은 탭으로 서비스별 상세를 넘나든다 // (합계는 위에 유지 — 상세·한도만 탭 스코프). @@ -201,6 +205,7 @@ struct PopoverView: View { ProviderTabBar( snapshots: store.snapshots, selectedID: selectedSnapshot?.providerID, + accentColor: companion.activeTheme.accentColor, onSelect: { nav.providerID = $0 }) } @@ -815,6 +820,7 @@ struct MonthDailyTrend: View { /// 오늘의 `localDay` 키 — 강조할 막대를 뷰가 시계를 다시 읽어 고르지 않게 주입한다. let today: String let l: L + var accentColor: Color = .accentColor /// 마우스가 올라간 막대. 캡션의 리드아웃이 이걸 따라가고, 벗어나면 오늘로 돌아온다. /// 툴팁(`.help`)과 달리 **지연이 없고, 안 올려도 오늘 값이 항상 보인다** — 날짜를 알려면 @@ -864,7 +870,7 @@ struct MonthDailyTrend: View { // 않는다 — 색을 한 단계 흐리게 해 "안 쓴 날"과 "조금 쓴 날"을 갈라준다. let isEmptyDay = day.totalTokens == 0 RoundedRectangle(cornerRadius: 1, style: .continuous) - .fill(isToday ? Color.accentColor + .fill(isToday ? accentColor : Color.secondary.opacity(isEmptyDay ? 0.18 : 0.45)) .frame(height: DailyTrendMetrics.barHeight(tokens: day.totalTokens, peak: peak)) .frame(maxWidth: .infinity) @@ -900,7 +906,7 @@ struct MonthDailyTrend: View { Text(label) .font(.caption2) .monospacedDigit() - .foregroundStyle(day.date == today ? Color.accentColor : Color.secondary) + .foregroundStyle(day.date == today ? accentColor : Color.secondary) .fixedSize(horizontal: true, vertical: false) } else { Color.clear.frame(height: 1) @@ -1005,6 +1011,7 @@ enum DailyTrendMetrics { struct ProviderTabBar: View { let snapshots: [ProviderSnapshot] let selectedID: String? + var accentColor: Color = .accentColor let onSelect: (String) -> Void var body: some View { @@ -1019,8 +1026,8 @@ struct ProviderTabBar: View { .font(.caption.weight(isSelected ? .semibold : .regular)) .padding(.horizontal, 10) .padding(.vertical, 4) - .background(isSelected ? Color.accentColor.opacity(0.18) : Color.secondary.opacity(0.08)) - .foregroundStyle(isSelected ? Color.accentColor : Color.secondary) + .background(isSelected ? accentColor.opacity(0.18) : Color.secondary.opacity(0.08)) + .foregroundStyle(isSelected ? accentColor : Color.secondary) .clipShape(Capsule()) } .buttonStyle(.plain) diff --git a/Sources/PokeTokenBar/UI/SettingsView.swift b/Sources/PokeTokenBar/UI/SettingsView.swift index 4056d879d..df248b33f 100644 --- a/Sources/PokeTokenBar/UI/SettingsView.swift +++ b/Sources/PokeTokenBar/UI/SettingsView.swift @@ -67,6 +67,7 @@ struct SettingsView: View { ScrollView { VStack(alignment: .leading, spacing: 18) { generalGroup(store) + themeGroup difficultyGroup menuBarGroup(store) floatingPetGroup(store) @@ -110,7 +111,7 @@ struct SettingsView: View { } } .buttonStyle(.plain) - .foregroundStyle(Color.accentColor) + .foregroundStyle(companion.activeTheme.accentColor) .keyboardShortcut(.cancelAction) Spacer() Text(l.settings).font(.headline) @@ -242,6 +243,38 @@ struct SettingsView: View { } } + @ViewBuilder + private var themeGroup: some View { + settingsSection(l.appThemesSection) { + groupRow { + VStack(alignment: .leading, spacing: 2) { + Text(l.activeThemeLabel) + Text("\(companion.unlockedThemes.count)/\(AppThemeKind.allCases.count) \(l.unlockedThemesCount)") + .font(.caption2) + .foregroundStyle(.secondary) + } + Spacer() + HStack(spacing: 6) { + Circle() + .fill(companion.activeTheme.accentColor) + .frame(width: 10, height: 10) + Picker(l.activeThemeLabel, selection: Binding( + get: { companion.activeTheme }, + set: { companion.setActiveTheme($0) } + )) { + ForEach(AppThemeKind.allCases.filter { companion.unlockedThemes.contains($0.rawValue) }, id: \.self) { theme in + Text(l.themeName(theme)).tag(theme) + } + } + .labelsHidden() + .accessibilityLabel(l.activeThemeLabel) + .pickerStyle(.menu) + .fixedSize() + } + } + } + } + private var difficultyGroup: some View { DifficultySettingsSection(companion: companion) } diff --git a/Sources/PokeTokenBar/UI/ShopView.swift b/Sources/PokeTokenBar/UI/ShopView.swift index cb33399d5..c3871a7b7 100644 --- a/Sources/PokeTokenBar/UI/ShopView.swift +++ b/Sources/PokeTokenBar/UI/ShopView.swift @@ -8,21 +8,44 @@ struct ShopView: View { let store: CompanionStore let nav: PopoverNavigation + enum Tab: Int, CaseIterable { + case shop + case casino + } + + @State private var tab: Tab = .shop + var body: some View { let l = store.l - // 고정 높이 — 컬렉션/가방과 동일(팝오버 재오픈 시 fitting size 축소 방지). - ScrollView { - VStack(alignment: .leading, spacing: 10) { - walletHeader(l) - // shopEntries = 판매 아이템 + 알 3종(보증 없음·고급 이상·희귀 이상)을 가격 오름차순으로 - // 병합한 단일 목록. 알은 항상 포함되고(즉시 액션이라 ItemKind 가 아님), 알 상태에선 - // EggCard 가 구매만 비활성으로 보여준다. - ForEach(store.shopEntries, id: \.self) { entry in - switch entry { - case .item(let kind): - ShopItemCard(store: store, kind: kind) - case .egg(let tier): - EggCard(store: store, nav: nav, tier: tier) + VStack(spacing: 8) { + Picker("", selection: $tab) { + Text(l.shop).tag(Tab.shop) + Text(l.gameCorner).tag(Tab.casino) + } + .pickerStyle(.segmented) + .labelsHidden() + + if tab == .casino { + ScrollView(showsIndicators: false) { + CasinoView(store: store, nav: nav) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + } else { + // 고정 높이 — 컬렉션/가방과 동일(팝오버 재오픈 시 fitting size 축소 방지). + ScrollView { + VStack(alignment: .leading, spacing: 10) { + walletHeader(l) + // shopEntries = 판매 아이템 + 알 3종(보증 없음·고급 이상·희귀 이상)을 가격 오름차순으로 + // 병합한 단일 목록. 알은 항상 포함되고(즉시 액션이라 ItemKind 가 아님), 알 상태에선 + // EggCard 가 구매만 비활성으로 보여준다. + ForEach(store.shopEntries, id: \.self) { entry in + switch entry { + case .item(let kind): + ShopItemCard(store: store, kind: kind) + case .egg(let tier): + EggCard(store: store, nav: nav, tier: tier) + } + } } } } diff --git a/Tests/PokeTokenBarTests/CasinoTests.swift b/Tests/PokeTokenBarTests/CasinoTests.swift new file mode 100644 index 000000000..92f40a599 --- /dev/null +++ b/Tests/PokeTokenBarTests/CasinoTests.swift @@ -0,0 +1,226 @@ +import XCTest +@testable import PokeTokenBar + +private struct CasinoDummyProvider: PokeProviding { + func line(baseSpeciesID: Int) async throws -> EvoLine { throw URLError(.notConnectedToInternet) } + func baseSpeciesIndex() async throws -> [BaseSpecies] { [] } + func baseSpecies(id: Int) async throws -> BaseSpecies? { nil } +} + +@MainActor +final class CasinoTests: XCTestCase { + private let now = Date(timeIntervalSince1970: 1_700_000_000) + + private func store(used: Int = 100_000_000, spent: Int = 0, casinoCoins: Int = 0, + active: MonState? = nil, dex: [DexEntry] = [], + starPrisms: Int = 0) -> CompanionStore { + let url = FileManager.default.temporaryDirectory.appendingPathComponent("casino-\(UUID().uuidString).json") + var state = CompanionState() + state.installBaselineSet = true + state.usedSinceInstall = used + state.spentTokens = spent + state.casinoCoins = casinoCoins + state.active = active + state.dex = dex + if starPrisms > 0 { + state.inventory[ItemKind.starPrism.rawValue] = starPrisms + } + try? JSONEncoder().encode(state).write(to: url) + return CompanionStore(provider: CasinoDummyProvider(), clock: { self.now }, fileURL: url, rng: SeededRNG(seed: 42)) + } + + // MARK: - Coin Packages + + func testCoinPackages() { + XCTAssertEqual(CoinPackage.all.count, 6) + let p50 = CoinPackage.all[0] + XCTAssertEqual(p50.coins, 50) + XCTAssertEqual(p50.tokenCost, 35_000_000) + + let p100 = CoinPackage.all[1] + XCTAssertEqual(p100.coins, 100) + XCTAssertEqual(p100.tokenCost, 65_000_000) + XCTAssertEqual(p100.bonusPercent, 8) + + let p500 = CoinPackage.all[2] + XCTAssertEqual(p500.coins, 500) + XCTAssertEqual(p500.tokenCost, 300_000_000) + + let p1000 = CoinPackage.all[3] + XCTAssertEqual(p1000.coins, 1_000) + XCTAssertEqual(p1000.tokenCost, 550_000_000) + + let p5000 = CoinPackage.all[4] + XCTAssertEqual(p5000.coins, 5_000) + XCTAssertEqual(p5000.tokenCost, 2_500_000_000) + + let p10000 = CoinPackage.all[5] + XCTAssertEqual(p10000.coins, 10_000) + XCTAssertEqual(p10000.tokenCost, 4_800_000_000) + } + + func testBuyCoinsDeductsTokensAndAddsCoins() { + let s = store(used: 500_000_000, spent: 0, casinoCoins: 100) + let pkg = CoinPackage.all[2] // 500 coins for 300M tokens + let success = s.buyCasinoCoins(package: pkg) + XCTAssertTrue(success) + XCTAssertEqual(s.casinoCoins, 600) + XCTAssertEqual(s.availableTokens, 500_000_000 - 300_000_000) + } + + func testBuyCoinsFailsWhenInsufficientTokens() { + let s = store(used: 10_000, spent: 0, casinoCoins: 0) + let pkg = CoinPackage.all[0] // 35M tokens needed + let success = s.buyCasinoCoins(package: pkg) + XCTAssertFalse(success) + XCTAssertEqual(s.casinoCoins, 0) + } + + func testThemeRarityAndRandomRoll() { + var unlocked: Set = [AppThemeKind.classic.rawValue] + + // Rolling with only classic unlocked should return one of the other 5 themes + let rolled1 = AppThemeKind.rollRandomTheme(excluding: unlocked) + XCTAssertNotNil(rolled1) + XCTAssertNotEqual(rolled1, .classic) + + // Add all themes to unlocked + for theme in AppThemeKind.allCases { + unlocked.insert(theme.rawValue) + } + // When all themes are unlocked, rolling returns nil + let rolledAll = AppThemeKind.rollRandomTheme(excluding: unlocked) + XCTAssertNil(rolledAll) + } + + // MARK: - Slot Machine Engine + + func testSlotMachineEngineSpinOutput() { + let res = SlotMachineEngine.spin(lines: 5, multiplier: 1) + XCTAssertEqual(res.grid.count, 3) + XCTAssertEqual(res.grid[0].count, 3) + XCTAssertEqual(res.grid[1].count, 3) + XCTAssertEqual(res.grid[2].count, 3) + } + + func testSlotSymbols() { + XCTAssertEqual(SlotSymbol.seven.lineMultiplier, 300) + XCTAssertEqual(SlotSymbol.bar.lineMultiplier, 100) + XCTAssertEqual(SlotSymbol.jigglypuff.lineMultiplier, 30) + XCTAssertEqual(SlotSymbol.cherry.lineMultiplier, 25) + XCTAssertEqual(SlotSymbol.pikachu.lineMultiplier, 20) + XCTAssertEqual(SlotSymbol.pokeBall.lineMultiplier, 10) + XCTAssertEqual(SlotSymbol.voltorb.lineMultiplier, 0) + } + + func testSlotMachineOddsDistribution() { + // Run 1000 spins on max lines (5 lines) to verify hit frequency is realistic (~15% - 30%) + // and not overflowing (~70% as was the case before rebalancing) + var wins = 0 + let totalSpins = 1000 + + for _ in 0.. 0 { + wins += 1 + } + } + + let hitRate = Double(wins) / Double(totalSpins) + XCTAssertGreaterThan(hitRate, 0.10, "Hit rate should be at least 10% to be engaging") + XCTAssertLessThan(hitRate, 0.35, "Hit rate should stay under 35% so wins feel genuine") + } + + func testSlotMachineMultiplierScaling() { + let res1 = SlotMachineEngine.spin(lines: 5, multiplier: 1) + XCTAssertGreaterThanOrEqual(res1.totalWin, 0) + + // Spin with 10x multiplier + let res10 = SlotMachineEngine.spin(lines: 5, multiplier: 10) + if res10.totalWin > 0 { + // Wins must be multiples of 10 + XCTAssertEqual(res10.totalWin % 10, 0) + } + } + + // MARK: - App Themes + + func testThemesDefaultAndUnlocking() { + let s = store(casinoCoins: 10_000) + XCTAssertEqual(s.activeTheme, .classic) + XCTAssertTrue(s.unlockedThemes.contains(AppThemeKind.classic.rawValue)) + + // Unlock Game Boy 1989 theme via achievement + let unlocked = s.unlockCasinoTheme(.gameBoy1989) + XCTAssertTrue(unlocked) + XCTAssertTrue(s.unlockedThemes.contains(AppThemeKind.gameBoy1989.rawValue)) + + // Switch to Game Boy 1989 + s.setActiveTheme(.gameBoy1989) + XCTAssertEqual(s.activeTheme, .gameBoy1989) + + // Re-unlocking should return false (already unlocked) + let duplicate = s.unlockCasinoTheme(.gameBoy1989) + XCTAssertFalse(duplicate) + + // Switch back to classic + s.setActiveTheme(.classic) + XCTAssertEqual(s.activeTheme, .classic) + } + + // MARK: - Porygon Exclusion & Prize Corner + + func testPorygonIsCasinoExclusive() { + XCTAssertTrue(CasinoConstants.exclusiveSpeciesIDs.contains(137)) + let porygonPrize = CasinoPrizeItem.all.first { $0.speciesID == 137 } + XCTAssertNotNil(porygonPrize) + XCTAssertEqual(porygonPrize?.coinCost, 9_999) + } + + func testBuyCasinoPokemonDeductCoinsAndPreparesHatch() { + let s = store(casinoCoins: 15_000) + let success = s.buyCasinoPokemon(speciesID: 137, coinCost: 9_999) + XCTAssertTrue(success) + XCTAssertEqual(s.casinoCoins, 5_001) + XCTAssertEqual(s.state.pendingHatchID, 137) + XCTAssertEqual(s.state.eggUsage, PokemonBalance.eggHatchThreshold) + } + + // MARK: - Star Prism (Prisme Étoilé) + + func testStarPrismPurchaseAndUsage() { + let active = MonState(baseID: 4, pathIDs: [4, 5, 6], + stageIndex: 0, usedAtStage: 0, + rarity: .uncommon, totalForms: 3, isShiny: false, + nature: .hardy, profile: nil, hasGrowthBoost: false) + let dexCharizard = DexEntry(baseID: 4, finalID: 6, chainOrder: [4, 5, 6], + rarity: .uncommon, caughtAt: now, isShiny: false) + + let s = store(casinoCoins: 60_000, active: active, dex: [dexCharizard], starPrisms: 0) + + // Cannot use when none owned + XCTAssertFalse(s.canUseStarPrism) + + // Buy Star Prism (50,000 coins) + let bought = s.buyStarPrism() + XCTAssertTrue(bought) + XCTAssertEqual(s.casinoCoins, 10_000) + XCTAssertEqual(s.itemCount(.starPrism), 1) + XCTAssertTrue(s.canUseStarPrism) + + // Use Star Prism + let used = s.useStarPrism() + XCTAssertTrue(used) + XCTAssertEqual(s.itemCount(.starPrism), 0) + XCTAssertTrue(s.state.active?.isShiny == true) + + // All chain dex entries are now shiny! + XCTAssertTrue(s.state.dex[0].isShiny) + XCTAssertTrue(s.state.ownsShinySpecies(4)) + XCTAssertTrue(s.state.ownsShinySpecies(5)) + XCTAssertTrue(s.state.ownsShinySpecies(6)) + + // Cannot use again if already shiny + XCTAssertFalse(s.canUseStarPrism) + } +} diff --git a/Tests/PokeTokenBarTests/SaveTransferTests.swift b/Tests/PokeTokenBarTests/SaveTransferTests.swift index ddc44905d..80b856496 100644 --- a/Tests/PokeTokenBarTests/SaveTransferTests.swift +++ b/Tests/PokeTokenBarTests/SaveTransferTests.swift @@ -529,10 +529,10 @@ final class SaveTransferTests: XCTestCase { // eggTier(알 등급 보증) = 진행 — 산 물건이지 이 기기의 장부가 아니라 기기를 옮겨도 따라간다. let progress: Set = ["usedSinceInstall", "spentTokens", "eggUsage", "eggTier", "pendingHatchID", "active", "representativeSpeciesID", "dex", - "collectedFinals", "inventory"] + "collectedFinals", "inventory", "casinoCoins", "unlockedThemes"] let deviceLedger: Set = ["installBaselineSet", "claimedTodayTokensByProvider", "lastDate"] let accountLedger: Set = ["candyGrantTier", "candyFeatureSeeded"] - let devicePreference: Set = ["language"] + let devicePreference: Set = ["language", "activeTheme"] let classified = progress.union(deviceLedger).union(accountLedger).union(devicePreference) let actual = Set(Mirror(reflecting: CompanionState()).children.compactMap(\.label)) From 58f5fc97a10e5c0a2f394658def2e9bb388b9431 Mon Sep 17 00:00:00 2001 From: Killian HERZER Date: Wed, 16 Sep 2026 16:02:16 +0200 Subject: [PATCH 2/2] feat(trainer-card): add exportable retro pixel-art Trainer Card with rich stats and theme variants --- PULL_REQUEST.md | 91 + Sources/PokeTokenBar/Core/CasinoModel.swift | 35 +- .../PokeTokenBar/Core/CompanionStore.swift | 6 +- Sources/PokeTokenBar/Core/Localization.swift | 33 +- .../PokeTokenBar/Core/TrainerCardModel.swift | 70 + Sources/PokeTokenBar/UI/CasinoView.swift | 8 +- Sources/PokeTokenBar/UI/CompanionView.swift | 47 +- Sources/PokeTokenBar/UI/PopoverView.swift | 22 + Sources/PokeTokenBar/UI/TrainerCardView.swift | 1469 +++++++++++++++++ .../PokeTokenBarTests/TrainerCardTests.swift | 130 ++ 10 files changed, 1881 insertions(+), 30 deletions(-) create mode 100644 PULL_REQUEST.md create mode 100644 Sources/PokeTokenBar/Core/TrainerCardModel.swift create mode 100644 Sources/PokeTokenBar/UI/TrainerCardView.swift create mode 100644 Tests/PokeTokenBarTests/TrainerCardTests.swift diff --git a/PULL_REQUEST.md b/PULL_REQUEST.md new file mode 100644 index 000000000..dcedab902 --- /dev/null +++ b/PULL_REQUEST.md @@ -0,0 +1,91 @@ +# Pull Request: Celadon Casino, Mini-Games & Exportable Trainer Card + +**Target upstream branch:** `chattymin:PokeTokenBar:main` +**Source branch:** `KillianHzr:PokeTokenBar:feat/celadon-casino` +**Direct creation link:** [Open Pull Request on GitHub](https://github.com/chattymin/PokeTokenBar/compare/main...KillianHzr:PokeTokenBar:feat/celadon-casino?expand=1) + +--- + +## Suggested PR Title + +```text +feat(casino): add Celadon Casino with slot machine, themes, and exportable Trainer Card +``` + +--- + +## PR Description (Copy & Paste below into GitHub) + +## Summary + +This PR adds the Celadon Casino (Game Corner) feature, complete with an industry-calibrated slot machine, exclusive Pokémon prizes, collectible app visual themes, the Star Prism item, and an exportable retro pixel-art Trainer Card with rich usage and Pokémon progression telemetry: + +1. **Casino & Mini-Games Integration**: + - Integrated inside the Shop view using a top segmented picker: `[ Shop | Casino ]`, keeping the popover navigation bar clean and compact. + - Dedicated coin economy: exchange spendable tokens for casino coins across 6 tiered packages (50, 100, 500, 1,000, 5,000, 10,000 coins). + +2. **Calibrated Slot Machine**: + - 3-reel slot machine calibrated to real-world online casino math standards (95.8% RTP, 23.5% hit frequency): + - Jackpot 777 (300x) + - BAR (100x) + - Jigglypuff (30x + Theme roll trigger) + - Cherry (25x for 3, 7x for 2 on reels 1 & 2) + - Pikachu (20x) + - Poké Ball (10x) + - Voltorb blanks (0x, authentic house edge) + - Bet sizes: 1 coin (1 center line), 2 coins (3 horizontal lines), or 3 coins (3 horizontal + 2 diagonal lines). Multiplier selector (1x to 10x). + - Lining up 3 Jigglypuffs triggers a weighted random lottery for unowned app themes based on rarity (Common, Uncommon, Rare, Epic, Legendary), or grants a 250-coin bonus when all themes are unlocked. + +3. **Prize Corner**: + - **Porygon (#137)** (9,999 coins): Strictly exclusive to the Casino Prize Corner; excluded from standard egg hatching in `chooseBase()` and `chooseBaseViaREST()`. + - Other iconic prize Pokémon: Dratini (#147), Scyther (#123), Abra (#63), and Cleffa (#173). Purchasing a Pokémon safely archives the current companion into the Pokédex and hatches the new species with fresh egg progress. + - **Star Prism (Prisme Étoilé)** (50,000 coins): Ultra-rare item used from the Bag to permanently transmute the active companion and its entire evolutionary line in the Pokédex into Shiny. + +4. **App Visual Themes & Settings**: + - 6 full app visual themes (Classic, Game Boy 1989, Celadon Neon, Team Rocket Dark, Indigo Plateau, Master Ball) tinting the popover window background and accent colors. + - Theme switcher added under Settings with active preview and unlock counter. + +5. **Exportable Retro Pixel-Art Trainer Card**: + - Dedicated Trainer Card view accessible from the Companion header, Pokédex, and individual Pokémon detail view. + - Pixel-art layout (332 x 206 pt) adapting dynamically to each of the 6 unlocked casino themes with custom watermarks, borders, and color palettes. + - User can choose between the active companion or any registered Pokédex species (with shiny toggle support if the shiny variant is owned). + - 2x3 telemetry grid displaying core usage and collection stats: + - Lifetime tokens used + - Today's tokens used + - Pokédex progress (e.g. 42 / 151) + - Graduated Pokémon count + - Shiny count + - Casino coins balance + - Direct export via high-resolution retina rendering (scale 3.0): Copy Image to clipboard (`NSPasteboard`) and Save PNG (`NSSavePanel`). + +6. **State Persistence & Save Transfer**: + - `casinoCoins` and `unlockedThemes` classified under `progress` (preserved across machines). + - `activeTheme` classified under `devicePreference` (kept local to each device). + - Fully sanitized in `SaveTransfer.rebasedForThisDevice` and verified in unit tests. + +## Type of change + +- [ ] Bug fix +- [x] New feature +- [ ] Refactor / cleanup +- [ ] Documentation +- [ ] Other: + +## UI changes + +| Before | After | +| ------ | ----- | +| **Shop View**: Single vertical list of items and egg re-rolls with token balance header. | **Shop View**: Top segmented picker switching between `[ Shop | Casino ]`. | +| **Casino**: Non-existent. | **Casino View**: Integrated subview with coin balance, expandable coin exchange drawer, 3-reel slot machine with animated reels, payout table, and Prize Corner. | +| **Settings View**: General, Difficulty, Menu Bar, Floating Pet, Notifications, Updates, Transfer, Advanced sections. | **Settings View**: New "App Themes" section with active theme picker, accent color indicator, and unlocked count. | +| **Bag View**: Rare Candy, Mint, and Shiny Charm cards. | **Bag View**: Star Prism card with one-click transmutation action on active companion. | +| **Popover View**: System standard window background and accent tint. | **Popover View**: Dynamically tinted by active app visual theme. | +| **Trainer Card**: Non-existent. | **Trainer Card**: Retro pixel-art card with 6 casino theme variants, customizable Pokémon, stats telemetry grid, and copy/export actions. | + +## Checklist + +- [x] `swift build` and `./scripts/build-app.sh` pass locally +- [x] PR title and description are written in English +- [x] UI changes are described above (before/after — images optional) +- [x] No copyrighted assets, secrets, or private tooling references are committed (see [CONTRIBUTING](../CONTRIBUTING.md)) +- [x] Tests were added or updated for this change diff --git a/Sources/PokeTokenBar/Core/CasinoModel.swift b/Sources/PokeTokenBar/Core/CasinoModel.swift index 1dfc42a0a..69229dc9c 100644 --- a/Sources/PokeTokenBar/Core/CasinoModel.swift +++ b/Sources/PokeTokenBar/Core/CasinoModel.swift @@ -210,42 +210,42 @@ public enum SlotMachineEngine { public enum AppThemeKind: String, CaseIterable, Codable, Sendable { case classic = "classic" - case gameBoy1989 = "gameboy_1989" case celadonNeon = "celadon_neon" case teamRocket = "team_rocket" case indigoPlateau = "indigo_plateau" case masterBall = "master_ball" + case gameBoy1989 = "gameboy_1989" public var itemSprite: String { switch self { case .classic: return "poke-ball" - case .gameBoy1989: return "town-map" case .celadonNeon: return "coin-case" case .teamRocket: return "silph-scope" case .indigoPlateau: return "ss-ticket" case .masterBall: return "master-ball" + case .gameBoy1989: return "town-map" } } public var accentColor: Color { switch self { case .classic: return .accentColor - case .gameBoy1989: return Color(red: 0.35, green: 0.45, blue: 0.15) case .celadonNeon: return Color(red: 0.0, green: 0.85, blue: 0.85) case .teamRocket: return Color(red: 0.90, green: 0.15, blue: 0.20) case .indigoPlateau: return Color(red: 0.85, green: 0.65, blue: 0.15) case .masterBall: return Color(red: 0.70, green: 0.20, blue: 0.85) + case .gameBoy1989: return Color(red: 0.35, green: 0.45, blue: 0.15) } } public var secondaryAccentColor: Color { switch self { case .classic: return .orange - case .gameBoy1989: return Color(red: 0.20, green: 0.30, blue: 0.10) case .celadonNeon: return Color(red: 0.95, green: 0.30, blue: 0.85) case .teamRocket: return Color(red: 0.30, green: 0.30, blue: 0.35) case .indigoPlateau: return Color(red: 0.20, green: 0.40, blue: 0.85) case .masterBall: return Color(red: 0.95, green: 0.40, blue: 0.70) + case .gameBoy1989: return Color(red: 0.20, green: 0.30, blue: 0.10) } } @@ -253,8 +253,6 @@ public enum AppThemeKind: String, CaseIterable, Codable, Sendable { switch self { case .classic: return .clear - case .gameBoy1989: - return Color(red: 0.55, green: 0.65, blue: 0.25).opacity(0.12) case .celadonNeon: return Color(red: 0.05, green: 0.08, blue: 0.18).opacity(0.25) case .teamRocket: @@ -263,17 +261,19 @@ public enum AppThemeKind: String, CaseIterable, Codable, Sendable { return Color(red: 0.06, green: 0.10, blue: 0.25).opacity(0.20) case .masterBall: return Color(red: 0.18, green: 0.06, blue: 0.22).opacity(0.22) + case .gameBoy1989: + return Color(red: 0.55, green: 0.65, blue: 0.25).opacity(0.12) } } public var cardBackground: Color { switch self { case .classic: return Color.secondary.opacity(0.08) - case .gameBoy1989: return Color(red: 0.60, green: 0.70, blue: 0.30).opacity(0.18) case .celadonNeon: return Color(red: 0.05, green: 0.12, blue: 0.22).opacity(0.40) case .teamRocket: return Color(red: 0.15, green: 0.15, blue: 0.18).opacity(0.50) case .indigoPlateau: return Color(red: 0.10, green: 0.18, blue: 0.35).opacity(0.35) case .masterBall: return Color(red: 0.25, green: 0.10, blue: 0.30).opacity(0.40) + case .gameBoy1989: return Color(red: 0.60, green: 0.70, blue: 0.30).opacity(0.18) } } @@ -288,11 +288,22 @@ public enum AppThemeKind: String, CaseIterable, Codable, Sendable { public var rarityWeight: Int { switch self { case .classic: return 0 // Default unlocked - case .gameBoy1989: return 40 // Common - case .celadonNeon: return 30 // Uncommon - case .teamRocket: return 18 // Rare - case .indigoPlateau: return 9 // Epic - case .masterBall: return 3 // Legendary + case .celadonNeon: return 38 // Common + case .teamRocket: return 28 // Uncommon + case .indigoPlateau: return 18 // Rare + case .masterBall: return 10 // Epic + case .gameBoy1989: return 4 // Legendary (rarest) + } + } + + public var rarityColor: Color { + switch self { + case .classic: return .secondary + case .celadonNeon: return Color(red: 0.10, green: 0.85, blue: 0.70) + case .teamRocket: return Color(red: 0.90, green: 0.25, blue: 0.30) + case .indigoPlateau: return Color(red: 0.40, green: 0.60, blue: 0.95) + case .masterBall: return Color(red: 0.78, green: 0.30, blue: 0.88) + case .gameBoy1989: return Color(red: 0.95, green: 0.75, blue: 0.15) // Legendary Gold } } diff --git a/Sources/PokeTokenBar/Core/CompanionStore.swift b/Sources/PokeTokenBar/Core/CompanionStore.swift index e276b9385..f87b258fa 100644 --- a/Sources/PokeTokenBar/Core/CompanionStore.swift +++ b/Sources/PokeTokenBar/Core/CompanionStore.swift @@ -973,7 +973,8 @@ final class CompanionStore { // MARK: - 카지노 (Casino) var casinoCoins: Int { state.casinoCoins } - var unlockedThemes: Set { state.unlockedThemes } + /// 임시로 사용자가 모든 테마를 확인하고 피드백할 수 있도록 전체 테마 해금 + var unlockedThemes: Set { Set(AppThemeKind.allCases.map(\.rawValue)) } var activeTheme: AppThemeKind { AppThemeKind(rawValue: state.activeTheme) ?? .classic } /// 동전 획득 (미니게임 승리 / 잭팟 등) @@ -1034,7 +1035,8 @@ final class CompanionStore { /// 활성 테마 설정 func setActiveTheme(_ theme: AppThemeKind) { - guard state.unlockedThemes.contains(theme.rawValue) else { return } + guard unlockedThemes.contains(theme.rawValue) else { return } + state.unlockedThemes.insert(theme.rawValue) state.activeTheme = theme.rawValue save() } diff --git a/Sources/PokeTokenBar/Core/Localization.swift b/Sources/PokeTokenBar/Core/Localization.swift index ff2acb5b2..a8738c57d 100644 --- a/Sources/PokeTokenBar/Core/Localization.swift +++ b/Sources/PokeTokenBar/Core/Localization.swift @@ -1103,11 +1103,11 @@ struct L { func themeName(_ kind: AppThemeKind) -> String { switch kind { case .classic: return t("클래식", "Classic", "クラシック", "Clásico", "Classique", "Clássico", "Klassisch") - case .gameBoy1989: return t("게임보이 1989", "Game Boy 1989", "ゲームボーイ 1989", "Game Boy 1989", "Game Boy 1989", "Game Boy 1989", "Game Boy 1989") case .celadonNeon: return t("무지개 네온", "Celadon Neon", "タマムシネオン", "Azulona Neón", "Néon Céladopole", "Celadon Neon", "Prismania Neon") case .teamRocket: return t("로켓단 다크", "Team Rocket Dark", "ロケット団ダーク", "Team Rocket Oscuro", "Team Rocket Noir", "Equipe Rocket Escuro", "Team Rocket Dunkel") case .indigoPlateau: return t("석영고원 챔피언", "Indigo Plateau", "セキエイ高原", "Meseta Añil", "Plateau Indigo", "Planalto Índigo", "Indigo Plateau") case .masterBall: return t("마스터볼", "Master Ball", "マスターボール", "Master Ball", "Master Ball", "Master Ball", "Meisterball") + case .gameBoy1989: return t("게임보이 1989", "Game Boy 1989", "ゲームボーイ 1989", "Game Boy 1989", "Game Boy 1989", "Game Boy 1989", "Game Boy 1989") } } var newThemeUnlocked: String { t("새 테마 해금!", "New Theme Unlocked!", "新テーマ解放!", "¡Nuevo Tema Desbloqueado!", "Nouveau Thème Débloqué !", "Novo Tema Desbloqueado!", "Neues Design Freigeschaltet!") } @@ -1118,15 +1118,15 @@ struct L { switch kind { case .classic: return t("기본", "Base", "基本", "Base", "Base", "Base", "Basis") - case .gameBoy1989: - return t("일반", "Common", "コモン", "Común", "Commun", "Comum", "Gewöhnlich") case .celadonNeon: - return t("고급", "Uncommon", "アンコモン", "Poco Común", "Peu commun", "Incomum", "Ungewöhnlich") + return t("일반", "Common", "コモン", "Común", "Commun", "Comum", "Gewöhnlich") case .teamRocket: - return t("희귀", "Rare", "レア", "Raro", "Rare", "Raro", "Selten") + return t("고급", "Uncommon", "アンコモン", "Poco Común", "Peu commun", "Incomum", "Ungewöhnlich") case .indigoPlateau: - return t("에픽", "Epic", "エピック", "Épico", "Épique", "Épico", "Episch") + return t("희귀", "Rare", "レア", "Raro", "Rare", "Raro", "Selten") case .masterBall: + return t("에픽", "Epic", "エピック", "Épico", "Épique", "Épico", "Episch") + case .gameBoy1989: return t("전설", "Legendary", "レジェンド", "Legendario", "Légendaire", "Lendário", "Legendär") } } @@ -1140,9 +1140,28 @@ struct L { } func linesCountLabel(_ count: Int) -> String { switch count { - case 1: return t("1라인", "1 Line", "1ライン", "1 Línea", "1 Ligne", "1 Linha", "1 Linie") + case 1: return t("1라인", "1 Line", "1라인", "1 Línea", "1 Ligne", "1 Linha", "1 Linie") case 3: return t("3라인", "3 Lines", "3ライン", "3 Líneas", "3 Lignes", "3 Linhas", "3 Linien") default: return t("5라인", "5 Lines", "5ライン", "5 Líneas", "5 Lignes", "5 Linhas", "5 Linien") } } + + // MARK: - Trainer Card + var trainerCard: String { t("트레이너 카드", "Trainer Card", "トレーナーカード", "Tarjeta de Entrenador", "Carte de Dresseur", "Cartão de Treinador", "Trainerkarte") } + var trainerCardTitle: String { t("트레이너 카드", "Trainer Card", "トレーナーカード", "Tarjeta de Entrenador", "Carte de Dresseur", "Cartão de Treinador", "Trainerkarte") } + var trainerCardCopyImage: String { t("이미지 복사", "Copy Image", "画像をコピー", "Copiar Imagen", "Copier l'image", "Copiar Imagem", "Bild kopieren") } + var trainerCardSaveImage: String { t("PNG 저장", "Save PNG", "PNGを保存", "Guardar PNG", "Enregistrer PNG", "Salvar PNG", "Als PNG speichern") } + var trainerCardCopied: String { t("클립보드에 복사됨!", "Copied to clipboard!", "クリップボードにコピーしました!", "¡Copiado al portapapeles!", "Copié dans le presse-papier !", "Copiado para a área de transferência!", "In Zwischenablage kopiert!") } + var trainerCardActiveMon: String { t("현재 파트너", "Current Companion", "現在のパートナー", "Compañero Actual", "Pokémon Actif", "Companheiro Atual", "Aktueller Begleiter") } + var trainerCardDexMon: String { t("도감에서 선택", "From Pokédex", "図鑑から選択", "De la Pokédex", "Depuis le Pokédex", "Da Pokédex", "Aus dem Pokédex") } + var trainerCardShiny: String { t("이로치", "Shiny", "色違い", "Variocolor", "Chromatique", "Brilhante", "Schillernd") } + var trainerCardPokemonSource: String { t("표시할 포켓몬", "Displayed Pokémon", "表示するポケモン", "Pokémon mostrado", "Pokémon affiché", "Pokémon exibido", "Angezeigtes Pokémon") } + var trainerCardCasinoHint: String { t("카지노에서 새 테마를 해금하여 전용 카드 디자인을 적용해 보세요!", "Unlock new themes at the Casino for exclusive Trainer Card styles!", "カジノで新しいテーマを解放して限定カードスタイルを使おう!", "¡Desbloquea nuevos temas en el Casino para estilos de tarjeta exclusivos!", "Débloquez de nouveaux thèmes au Casino pour obtenir des styles de carte exclusifs !", "Desbloqueie novos temas no Cassino para estilos de cartão exclusivos!", "Schalte neue Designs im Casino frei für exklusive Kartendesigns!") } + var trainerCardLifetimeTokens: String { t("누적 토큰", "Lifetime", "累計", "Total", "Cumul", "Total", "Gesamt") } + var trainerCardTodayTokens: String { t("오늘 토큰", "Today", "今日", "Hoy", "Aujourd'hui", "Hoje", "Heute") } + var trainerCardPokedexLabel: String { t("도감 등록", "Pokédex", "図鑑", "Pokédex", "Pokédex", "Pokédex", "Pokédex") } + var trainerCardGraduatedLabel: String { t("졸업 포켓몬", "Graduated", "殿堂入り", "Graduados", "Diplômés", "Graduados", "Absolventen") } + var trainerCardShiniesLabel: String { t("색이 다른", "Shinies", "色違い", "Variocolor", "Chromatiques", "Brilhantes", "Schillernd") } + var trainerCardCasinoCoinsLabel: String { t("카지노 코인", "Coins", "コイン", "Fichas", "Pièces", "Moedas", "Münzen") } + var trainerCardTrainerLabel: String { t("이름", "Name", "名前", "Nombre", "Nom", "Nome", "Name") } } diff --git a/Sources/PokeTokenBar/Core/TrainerCardModel.swift b/Sources/PokeTokenBar/Core/TrainerCardModel.swift new file mode 100644 index 000000000..6a37a762d --- /dev/null +++ b/Sources/PokeTokenBar/Core/TrainerCardModel.swift @@ -0,0 +1,70 @@ +import Foundation +import SwiftUI + +/// 트레이너 카드 데이터 스냅샷 +public struct TrainerCardData: Sendable { + public let trainerName: String + public let trainerID: String + public let theme: AppThemeKind + public let pokemonID: Int? + public let pokemonName: String + public let pokemonSubtitle: String? + public let isShiny: Bool + public let totalTokens: Int + public let todayTokens: Int + public let pokedexCount: Int + public let pokedexTotal: Int + public let graduatedCount: Int + public let shinyCount: Int + public let casinoCoins: Int + + public init( + trainerName: String, + trainerID: String, + theme: AppThemeKind, + pokemonID: Int?, + pokemonName: String, + pokemonSubtitle: String? = nil, + isShiny: Bool, + totalTokens: Int, + todayTokens: Int = 0, + pokedexCount: Int, + pokedexTotal: Int = 151, + graduatedCount: Int = 0, + shinyCount: Int, + casinoCoins: Int + ) { + self.trainerName = trainerName + self.trainerID = trainerID + self.theme = theme + self.pokemonID = pokemonID + self.pokemonName = pokemonName + self.pokemonSubtitle = pokemonSubtitle + self.isShiny = isShiny + self.totalTokens = totalTokens + self.todayTokens = todayTokens + self.pokedexCount = pokedexCount + self.pokedexTotal = pokedexTotal + self.graduatedCount = graduatedCount + self.shinyCount = shinyCount + self.casinoCoins = casinoCoins + } + + /// 기본 트레이너 이름 생성 + public static func defaultTrainerName() -> String { + let full = NSFullUserName() + if !full.isEmpty { return full } + let user = NSUserName() + if !user.isEmpty { return user } + return "RED" + } + + /// 안정적인 5자리 트레이너 ID 생성 + public static func deterministicTrainerID(from name: String) -> String { + var hash: UInt32 = 5381 + for byte in name.utf8 { + hash = ((hash << 5) &+ hash) &+ UInt32(byte) + } + return String(format: "%05d", hash % 100000) + } +} diff --git a/Sources/PokeTokenBar/UI/CasinoView.swift b/Sources/PokeTokenBar/UI/CasinoView.swift index 7b2244995..967e6df80 100644 --- a/Sources/PokeTokenBar/UI/CasinoView.swift +++ b/Sources/PokeTokenBar/UI/CasinoView.swift @@ -233,8 +233,8 @@ private struct SlotMachineView: View { .font(.caption2.weight(.bold)) .foregroundStyle(.yellow) Text(l.themeRarityLabel(wonTheme)) - .font(.system(size: 8)) - .foregroundStyle(.secondary) + .font(.system(size: 8, weight: .bold)) + .foregroundStyle(wonTheme.rarityColor) } Spacer() Button(l.themeApplyAction) { @@ -778,10 +778,10 @@ private struct ThemeRowTile: View { if theme != .classic { Text(l.themeRarityLabel(theme)) .font(.system(size: 8, weight: .bold)) - .foregroundStyle(.secondary) + .foregroundStyle(theme.rarityColor) .padding(.horizontal, 4) .padding(.vertical, 1) - .background(Color.secondary.opacity(0.12)) + .background(theme.rarityColor.opacity(0.15)) .clipShape(Capsule()) } diff --git a/Sources/PokeTokenBar/UI/CompanionView.swift b/Sources/PokeTokenBar/UI/CompanionView.swift index c2aa3cf00..cd09a0436 100644 --- a/Sources/PokeTokenBar/UI/CompanionView.swift +++ b/Sources/PokeTokenBar/UI/CompanionView.swift @@ -452,6 +452,7 @@ struct EvoLineView: View { @MainActor struct CompanionHeader: View { let store: CompanionStore + @Environment(PopoverNavigation.self) private var nav // 연출 상태 — 부화/진화 순간 흰 플래시 + 스프링 스케일(본가 진화 신 오마주) @State private var flashOpacity: Double = 0 @State private var celebScale: CGFloat = 1 @@ -525,6 +526,16 @@ struct CompanionHeader: View { .background(rarityColor(r)).foregroundStyle(.white) .clipShape(Capsule()) } + Spacer() + Button { + nav.openTrainerCard() + } label: { + Image(systemName: "person.text.rectangle") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(store.activeTheme.accentColor) + } + .buttonStyle(.plain) + .help(store.l.trainerCardTitle) } if store.hasActive { // 단계 + 성격(부화 시 확정된 개체 아이덴티티) @@ -769,12 +780,28 @@ struct CollectionView: View { emptyState // 둘 다 비어 있으니 세그먼트를 그리지 않는다 } else { VStack(alignment: .leading, spacing: 8) { - Picker("", selection: $nav.showingCollectionLog) { - Text(store.l.dexTitle).tag(false) - Text(store.l.catchLogTitle).tag(true) + HStack(spacing: 8) { + Picker("", selection: $nav.showingCollectionLog) { + Text(store.l.dexTitle).tag(false) + Text(store.l.catchLogTitle).tag(true) + } + .pickerStyle(.segmented) + .labelsHidden() + + Button { + nav.openTrainerCard() + } label: { + Image(systemName: "person.text.rectangle") + .font(.system(size: 12, weight: .semibold)) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(store.activeTheme.accentColor.opacity(0.14)) + .foregroundStyle(store.activeTheme.accentColor) + .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) + } + .buttonStyle(.plain) + .help(store.l.trainerCardTitle) } - .pickerStyle(.segmented) - .labelsHidden() if nav.showingCollectionLog { catchLog } else { DexGridView(store: store) } } .frame(height: Self.contentHeight) @@ -1002,6 +1029,7 @@ private struct PokemonDetailView: View { let store: CompanionStore let species: CompanionStore.DexSpecies let onBack: () -> Void + @Environment(PopoverNavigation.self) private var nav @State private var selectedInstanceID = "" private var individuals: [DexEntry] { store.pokemonIndividuals(speciesID: species.id) } @@ -1017,6 +1045,15 @@ private struct PokemonDetailView: View { } .buttonStyle(.borderless) Spacer() + Button { + nav.openTrainerCard(speciesID: species.id) + } label: { + Label(store.l.trainerCardTitle, systemImage: "person.text.rectangle") + .font(.caption.weight(.semibold)) + } + .buttonStyle(.plain) + .foregroundStyle(store.activeTheme.accentColor) + Text("#\(species.id)").font(.caption).foregroundStyle(.secondary) } ScrollView { diff --git a/Sources/PokeTokenBar/UI/PopoverView.swift b/Sources/PokeTokenBar/UI/PopoverView.swift index 711030e6e..c3824657d 100644 --- a/Sources/PokeTokenBar/UI/PopoverView.swift +++ b/Sources/PokeTokenBar/UI/PopoverView.swift @@ -19,6 +19,8 @@ enum PopoverMetrics { @Observable final class PopoverNavigation { var showSettings = false + var showTrainerCard = false + var trainerCardPreselectedSpeciesID: Int? var tab: PopoverTab = .home /// 일반적인 컬렉션 재진입에는 마지막 세그먼트를 유지하되, 대표 포켓몬 선택 진입점은 도감으로 강제한다. var showingCollectionLog = false @@ -30,6 +32,8 @@ final class PopoverNavigation { func reset() { showSettings = false + showTrainerCard = false + trainerCardPreselectedSpeciesID = nil expandAdvancedOnOpen = false tab = .home } @@ -47,6 +51,13 @@ final class PopoverNavigation { showingCollectionLog = false tab = .collection } + + /// 트레이너 카드 화면을 연다 (특정 종 지정 가능). + func openTrainerCard(speciesID: Int? = nil) { + showSettings = false + showTrainerCard = true + trainerCardPreselectedSpeciesID = speciesID + } } @MainActor @@ -76,6 +87,17 @@ struct PopoverView: View { .environment(store) .environment(companion) .environment(updater) + } else if nav.showTrainerCard { + TrainerCardModalView( + store: companion, + usageStore: store, + nav: nav, + initialSpeciesID: nav.trainerCardPreselectedSpeciesID, + onClose: { + nav.showTrainerCard = false + nav.trainerCardPreselectedSpeciesID = nil + } + ) } else { mainContent } diff --git a/Sources/PokeTokenBar/UI/TrainerCardView.swift b/Sources/PokeTokenBar/UI/TrainerCardView.swift new file mode 100644 index 000000000..472c413c4 --- /dev/null +++ b/Sources/PokeTokenBar/UI/TrainerCardView.swift @@ -0,0 +1,1469 @@ +import SwiftUI +import AppKit +import UniformTypeIdentifiers + +// MARK: - Trainer Card View (Render Target) + +@MainActor +struct TrainerCardView: View { + let data: TrainerCardData + let l: L + + static let cardWidth: CGFloat = 332 + static let cardHeight: CGFloat = 206 + + var body: some View { + Group { + switch data.theme { + case .classic: classicLayout + case .masterBall: masterBallLayout + case .gameBoy1989: gameBoyLayout + case .celadonNeon: neonLayout + case .teamRocket: rocketLayout + case .indigoPlateau: plateauLayout + } + } + .frame(width: Self.cardWidth, height: Self.cardHeight) + .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .strokeBorder(borderStyle, lineWidth: borderWidth) + ) + .shadow(color: .black.opacity(0.3), radius: 5, x: 0, y: 2) + } + + // MARK: - Corner Radius / Border + + private var cornerRadius: CGFloat { + switch data.theme { + case .gameBoy1989: return 14 + case .celadonNeon: return 6 + case .teamRocket: return 4 + default: return 12 + } + } + + private var borderWidth: CGFloat { + switch data.theme { + case .celadonNeon, .indigoPlateau, .masterBall: return 2.0 + case .gameBoy1989: return 2.5 + default: return 1.5 + } + } + + private var borderStyle: AnyShapeStyle { + switch data.theme { + case .classic: + return AnyShapeStyle(Color(red: 0.20, green: 0.20, blue: 0.22)) + case .gameBoy1989: + return AnyShapeStyle(Color(red: 0.55, green: 0.55, blue: 0.50)) + case .celadonNeon: + return AnyShapeStyle(LinearGradient( + colors: [Color(red: 0.06, green: 0.88, blue: 0.75), Color(red: 0.10, green: 0.94, blue: 0.45)], + startPoint: .topLeading, endPoint: .bottomTrailing + )) + case .teamRocket: + return AnyShapeStyle(Color(red: 0.75, green: 0.10, blue: 0.18)) + case .indigoPlateau: + return AnyShapeStyle(LinearGradient( + colors: [Color(red: 0.95, green: 0.80, blue: 0.25), Color(red: 0.85, green: 0.65, blue: 0.15)], + startPoint: .topLeading, endPoint: .bottomTrailing + )) + case .masterBall: + return AnyShapeStyle(LinearGradient( + colors: [Color(red: 0.50, green: 0.15, blue: 0.68), Color(red: 0.90, green: 0.30, blue: 0.72), Color(red: 0.95, green: 0.78, blue: 0.25)], + startPoint: .topLeading, endPoint: .bottomTrailing + )) + } + } + + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + // MARK: - 1) Classic Layout (Pokeball Split — Red / Seam / White) + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + private var classicLayout: some View { + let primary = Color(red: 0.12, green: 0.12, blue: 0.14) + let label = Color(red: 0.40, green: 0.40, blue: 0.45) + let tileBg = Color.black.opacity(0.04) + let tileBorder = Color.black.opacity(0.12) + let frameBg = Color(red: 0.98, green: 0.98, blue: 0.99) + let frameBorder = Color(red: 0.75, green: 0.75, blue: 0.78) + + return VStack(spacing: 0) { + // Red dome with header + ZStack { + LinearGradient( + colors: [Color(red: 0.85, green: 0.15, blue: 0.18), Color(red: 0.72, green: 0.10, blue: 0.14)], + startPoint: .topLeading, endPoint: .bottomTrailing + ) + HStack(alignment: .center) { + HStack(spacing: 5) { + Circle().fill(Color.white.opacity(0.9)).frame(width: 7, height: 7) + Text(l.trainerCard.uppercased()) + .font(.system(size: 10.5, weight: .black, design: .monospaced)) + .foregroundStyle(.white) + } + Spacer() + Text("IDNo. \(data.trainerID)") + .font(.system(size: 9.5, weight: .bold, design: .monospaced)) + .foregroundStyle(Color.white.opacity(0.85)) + } + .padding(.horizontal, 14) + } + .frame(height: 31) + + // Seam + Rectangle().fill(Color(red: 0.15, green: 0.15, blue: 0.16)).frame(height: 2) + + // White zone — content centered + ZStack { + LinearGradient( + colors: [Color(red: 0.98, green: 0.98, blue: 0.99), Color(red: 0.92, green: 0.92, blue: 0.94)], + startPoint: .top, endPoint: .bottom + ) + + // Pokeball watermark + pokeBallWatermark(stroke: Color.black.opacity(0.06), fill: Color.black.opacity(0.08)) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing) + .padding(6) + + // Content centered in white zone + VStack(alignment: .leading, spacing: 5) { + trainerNameRow(primary: primary, label: label) + HStack(alignment: .top, spacing: 10) { + pokemonFrame(bg: frameBg, border: frameBorder, nameColor: primary, subColor: label, subBg: tileBg) + statsGrid(label: label, value: primary, tileBg: tileBg, tileBorder: tileBorder) + } + } + .padding(.horizontal, 10) + } + } + } + + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + // MARK: - 2) Master Ball Layout (The Ball Itself) + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + private var masterBallLayout: some View { + let purpleTop = Color(red: 0.38, green: 0.10, blue: 0.55) + let purpleDark = Color(red: 0.22, green: 0.04, blue: 0.35) + let pink = Color(red: 0.94, green: 0.24, blue: 0.68) + let pinkDim = Color(red: 0.76, green: 0.14, blue: 0.52) + let bottomLight = Color(red: 0.96, green: 0.95, blue: 0.98) + let bottomDark = Color(red: 0.86, green: 0.84, blue: 0.90) + let seamColor = Color(red: 0.12, green: 0.08, blue: 0.16) + let ringOuter = Color(red: 0.88, green: 0.86, blue: 0.92) + let ringInner = Color(red: 0.62, green: 0.58, blue: 0.68) + let textOnPurple: Color = .white + let textOnLight = Color(red: 0.16, green: 0.08, blue: 0.25) + let textDimOnLight = Color(red: 0.48, green: 0.36, blue: 0.58) + + return ZStack { + // ── Master Ball background: top purple, seam, bottom white ── + VStack(spacing: 0) { + // Purple top hemisphere (height 100) + ZStack { + LinearGradient(colors: [purpleTop, purpleDark], + startPoint: .top, endPoint: .bottom) + + // Two iconic magenta capsule bumps + HStack { + Capsule() + .fill(LinearGradient(colors: [pink, pinkDim], + startPoint: .top, endPoint: .bottom)) + .frame(width: 34, height: 12) + .rotationEffect(.degrees(-15)) + .offset(x: 28, y: 12) + Spacer() + Capsule() + .fill(LinearGradient(colors: [pink, pinkDim], + startPoint: .top, endPoint: .bottom)) + .frame(width: 34, height: 12) + .rotationEffect(.degrees(15)) + .offset(x: -28, y: 12) + } + + // Iconic white "M" symbol above the center button + Text("M") + .font(.system(size: 22, weight: .black, design: .rounded)) + .foregroundStyle(Color.white.opacity(0.92)) + .shadow(color: Color.black.opacity(0.3), radius: 2, y: 1) + .offset(y: 22) + } + .frame(height: 100) + + // Dark seam band + ZStack { + Rectangle().fill(seamColor).frame(height: 6) + Rectangle().fill(Color.white.opacity(0.15)).frame(height: 1) + .offset(y: -2.5) + } + + // Pearlescent light bottom hemisphere (height 100) + LinearGradient(colors: [bottomLight, bottomDark], + startPoint: .top, endPoint: .bottom) + } + + // ── Center circle (Master Ball core button) with Pokemon ── + ZStack { + // Outer chrome metallic bezel + Circle() + .fill(LinearGradient( + colors: [ringOuter, ringInner, ringOuter], + startPoint: .topLeading, endPoint: .bottomTrailing + )) + .frame(width: 96, height: 96) + .shadow(color: Color.black.opacity(0.40), radius: 6, y: 2) + + // Inner black seam + Circle() + .strokeBorder(seamColor, lineWidth: 3) + .frame(width: 90, height: 90) + + // Inner portal lens with cosmic violet gradient + Circle() + .fill(RadialGradient( + colors: [Color(red: 0.18, green: 0.10, blue: 0.28), + Color(red: 0.08, green: 0.04, blue: 0.14)], + center: .center, startRadius: 5, endRadius: 44 + )) + .frame(width: 84, height: 84) + + // Magenta glow accent ring inside lens + Circle() + .strokeBorder(pink.opacity(0.35), lineWidth: 1.5) + .frame(width: 82, height: 82) + + // Pokemon sprite + SpriteView(speciesID: data.pokemonID, size: 76, animated: false, shiny: data.isShiny) + .frame(width: 80, height: 80) + + // Shiny badge + if data.isShiny { + Image(systemName: "sparkles") + .font(.system(size: 8, weight: .bold)) + .foregroundStyle(Color(red: 0.95, green: 0.82, blue: 0.25)) + .padding(3) + .background(Color.black.opacity(0.7), in: Circle()) + .frame(width: 84, height: 84, alignment: .topTrailing) + .offset(x: -2, y: 2) + } + } + + // ── 4 Corners Info Layout ── + + // Top-Left: Trainer header & Name + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 3) { + Circle().fill(Color(red: 0.95, green: 0.82, blue: 0.25)).frame(width: 5, height: 5) + Text("MASTER BALL") + .font(.system(size: 7.5, weight: .black, design: .monospaced)) + .foregroundStyle(Color(red: 0.95, green: 0.82, blue: 0.25)) + } + HStack(spacing: 3) { + Text(l.trainerCardTrainerLabel.uppercased() + ":") + .font(.system(size: 6.5, weight: .bold, design: .monospaced)) + .foregroundStyle(textOnPurple.opacity(0.70)) + Text(data.trainerName) + .font(.system(size: 9.5, weight: .heavy, design: .monospaced)) + .foregroundStyle(textOnPurple) + .lineLimit(1) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .padding(.leading, 12).padding(.top, 10) + + // Top-Right: IDNo. & Casino Coins + VStack(alignment: .trailing, spacing: 2) { + Text("IDNo. \(data.trainerID)") + .font(.system(size: 8.5, weight: .bold, design: .monospaced)) + .foregroundStyle(textOnPurple.opacity(0.90)) + HStack(spacing: 3) { + Image(systemName: "suit.diamond.fill") + .font(.system(size: 6.5)) + .foregroundStyle(Color(red: 0.95, green: 0.82, blue: 0.25)) + Text(data.casinoCoins.formatted()) + .font(.system(size: 7.5, weight: .black, design: .monospaced)) + .foregroundStyle(Color(red: 0.95, green: 0.82, blue: 0.25)) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing) + .padding(.trailing, 12).padding(.top, 10) + + // Bottom-Left: Pokemon info & Token stats + VStack(alignment: .leading, spacing: 2) { + Text(data.pokemonName) + .font(.system(size: 10, weight: .black, design: .monospaced)) + .foregroundStyle(textOnLight) + .lineLimit(1) + if let sub = data.pokemonSubtitle, !sub.isEmpty { + Text(sub) + .font(.system(size: 6.5, weight: .bold, design: .monospaced)) + .foregroundStyle(textDimOnLight) + .lineLimit(1) + } + HStack(spacing: 6) { + mbCornerStat(label: l.trainerCardLifetimeTokens, value: TokenFormatter.compact(data.totalTokens), labelColor: textDimOnLight, valColor: textOnLight) + mbCornerStat(label: l.trainerCardTodayTokens, value: TokenFormatter.compact(data.todayTokens), labelColor: textDimOnLight, valColor: textOnLight) + } + .padding(.top, 2) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading) + .padding(.leading, 12).padding(.bottom, 10) + + // Bottom-Right: Pokedex stats + VStack(alignment: .trailing, spacing: 2) { + HStack(spacing: 6) { + mbCornerStat(label: l.trainerCardPokedexLabel, value: "\(data.pokedexCount)/\(data.pokedexTotal)", labelColor: textDimOnLight, valColor: textOnLight) + mbCornerStat(label: l.trainerCardGraduatedLabel, value: "\(data.graduatedCount)", labelColor: textDimOnLight, valColor: textOnLight) + mbCornerStat(label: l.trainerCardShiniesLabel, value: "\(data.shinyCount)", labelColor: textDimOnLight, valColor: textOnLight) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing) + .padding(.trailing, 12).padding(.bottom, 10) + } + } + + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + // MARK: - 3) Game Boy 1989 Layout (Hardware Shell + Green LCD Screen) + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + private var gameBoyLayout: some View { + let shell = Color(red: 0.78, green: 0.78, blue: 0.72) + let shellDark = Color(red: 0.70, green: 0.70, blue: 0.64) + let bezel = Color(red: 0.26, green: 0.27, blue: 0.30) + let screenBg = Color(red: 0.54, green: 0.67, blue: 0.06) + let screenLight = Color(red: 0.60, green: 0.73, blue: 0.10) + let ink = Color(red: 0.06, green: 0.16, blue: 0.02) + let inkMid = Color(red: 0.18, green: 0.30, blue: 0.06) + + return ZStack { + // Shell + LinearGradient(colors: [shell, shellDark], startPoint: .top, endPoint: .bottom) + + VStack(spacing: 0) { + // Top shell: power LED + branding text + HStack(alignment: .center, spacing: 6) { + Circle() + .fill(Color(red: 0.70, green: 0.10, blue: 0.18)) + .frame(width: 5, height: 5) + .shadow(color: Color.red.opacity(0.4), radius: 2) + Spacer() + Text(l.trainerCard.uppercased()) + .font(.system(size: 7, weight: .heavy, design: .monospaced)) + .foregroundStyle(Color(red: 0.42, green: 0.42, blue: 0.38)) + .tracking(2) + Spacer() + Text("IDNo.\(data.trainerID)") + .font(.system(size: 6, weight: .bold, design: .monospaced)) + .foregroundStyle(Color(red: 0.50, green: 0.50, blue: 0.46)) + } + .padding(.horizontal, 14) + .frame(height: 18) + + // Screen area: bezel → screen → content + ZStack { + // Dark bezel + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(bezel) + .padding(.horizontal, 8) + + // Green LCD + RoundedRectangle(cornerRadius: 5, style: .continuous) + .fill(LinearGradient(colors: [screenBg, screenLight], startPoint: .topLeading, endPoint: .bottomTrailing)) + .padding(.horizontal, 12) + .padding(.vertical, 4) + + // Scanlines overlay + VStack(spacing: 0) { + ForEach(0..<80, id: \.self) { _ in + Rectangle().fill(Color.black.opacity(0.025)).frame(height: 0.5) + Spacer(minLength: 0) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 4) + .clipShape(RoundedRectangle(cornerRadius: 5)) + + // Screen content + HStack(alignment: .top, spacing: 8) { + // Left: Pokemon + VStack(spacing: 2) { + ZStack(alignment: .topTrailing) { + RoundedRectangle(cornerRadius: 4) + .fill(screenBg.opacity(0.7)) + .frame(width: 72, height: 72) + .overlay( + RoundedRectangle(cornerRadius: 4) + .strokeBorder(ink.opacity(0.3), lineWidth: 1) + ) + SpriteView(speciesID: data.pokemonID, size: 68, animated: false, shiny: data.isShiny) + .frame(width: 72, height: 72) + if data.isShiny { + Image(systemName: "sparkles") + .font(.system(size: 7, weight: .bold)) + .foregroundStyle(ink) + .padding(2) + } + } + Text(data.pokemonName) + .font(.system(size: 8, weight: .bold, design: .monospaced)) + .foregroundStyle(ink) + .lineLimit(1) + .frame(width: 72) + if let sub = data.pokemonSubtitle, !sub.isEmpty { + Text(sub) + .font(.system(size: 6, weight: .bold, design: .monospaced)) + .foregroundStyle(inkMid) + .lineLimit(1) + .minimumScaleFactor(0.7) + .frame(width: 72) + } + } + + // Right: trainer + compact stats + VStack(alignment: .leading, spacing: 2.5) { + HStack(spacing: 3) { + Text(l.trainerCardTrainerLabel.uppercased() + ":") + .font(.system(size: 7, weight: .bold, design: .monospaced)) + .foregroundStyle(inkMid) + Text(data.trainerName) + .font(.system(size: 8, weight: .black, design: .monospaced)) + .foregroundStyle(ink) + .lineLimit(1) + } + Rectangle().fill(ink.opacity(0.20)).frame(height: 1) + + gbStatLine(l.trainerCardLifetimeTokens, TokenFormatter.compact(data.totalTokens), ink: ink, mid: inkMid) + gbStatLine(l.trainerCardTodayTokens, TokenFormatter.compact(data.todayTokens), ink: ink, mid: inkMid) + gbStatLine(l.trainerCardPokedexLabel, "\(data.pokedexCount)/\(data.pokedexTotal)", ink: ink, mid: inkMid) + gbStatLine(l.trainerCardGraduatedLabel, "\(data.graduatedCount)", ink: ink, mid: inkMid) + gbStatLine(l.trainerCardShiniesLabel, "\(data.shinyCount)", ink: ink, mid: inkMid) + gbStatLine(l.trainerCardCasinoCoinsLabel, data.casinoCoins.formatted(), ink: ink, mid: inkMid) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.horizontal, 18) + .padding(.vertical, 10) + } + + // Bottom shell: decorative dots (d-pad / buttons hint) + HStack(spacing: 0) { + // D-pad hint + ZStack { + RoundedRectangle(cornerRadius: 1).fill(Color(red: 0.28, green: 0.28, blue: 0.30)) + .frame(width: 18, height: 6) + RoundedRectangle(cornerRadius: 1).fill(Color(red: 0.28, green: 0.28, blue: 0.30)) + .frame(width: 6, height: 18) + } + .padding(.leading, 20) + + Spacer() + + // A/B buttons + HStack(spacing: 5) { + Circle().fill(Color(red: 0.60, green: 0.12, blue: 0.22)).frame(width: 10, height: 10) + Circle().fill(Color(red: 0.60, green: 0.12, blue: 0.22)).frame(width: 10, height: 10) + } + .rotationEffect(.degrees(-25)) + .padding(.trailing, 20) + } + .frame(height: 24) + } + } + } + + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + // MARK: - 4) Celadon Neon Layout (Cyberpunk Arcade) + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + private var neonLayout: some View { + let neon = Color(red: 0.06, green: 0.88, blue: 0.72) + let neonGreen = Color(red: 0.12, green: 0.95, blue: 0.50) + let bg = Color(red: 0.04, green: 0.06, blue: 0.14) + let textBright: Color = .white + let textDim = Color.white.opacity(0.65) + + return ZStack { + bg + + VStack(spacing: 0) { + // Top: trainer info + pokemon + HStack(alignment: .top, spacing: 10) { + // Pokemon with neon glow ring + VStack(spacing: 3) { + ZStack(alignment: .topTrailing) { + Circle() + .strokeBorder(neon.opacity(0.35), lineWidth: 2) + .frame(width: 84, height: 84) + .shadow(color: neon.opacity(0.25), radius: 6) + + RoundedRectangle(cornerRadius: 8) + .fill(bg) + .frame(width: 78, height: 78) + + SpriteView(speciesID: data.pokemonID, size: 74, animated: false, shiny: data.isShiny) + .frame(width: 78, height: 78) + + if data.isShiny { + Image(systemName: "sparkles") + .font(.system(size: 7, weight: .bold)) + .foregroundStyle(.yellow) + .padding(3) + .background(Color.black.opacity(0.6), in: Circle()) + .offset(x: -1, y: 1) + } + } + Text(data.pokemonName) + .font(.system(size: 9.5, weight: .bold, design: .monospaced)) + .foregroundStyle(neon) + .lineLimit(1) + .frame(width: 84) + if let sub = data.pokemonSubtitle, !sub.isEmpty { + Text(sub) + .font(.system(size: 6.5, weight: .bold, design: .monospaced)) + .foregroundStyle(neonGreen.opacity(0.7)) + .lineLimit(1) + .minimumScaleFactor(0.7) + .frame(width: 84) + } + } + + // Right: trainer info + header + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(l.trainerCard.uppercased()) + .font(.system(size: 10, weight: .black, design: .monospaced)) + .foregroundStyle(neon) + Spacer() + Text("IDNo.\(data.trainerID)") + .font(.system(size: 8, weight: .bold, design: .monospaced)) + .foregroundStyle(textDim) + } + + Rectangle().fill(neon.opacity(0.4)).frame(height: 1) + + HStack(spacing: 3) { + Text(l.trainerCardTrainerLabel.uppercased() + ":") + .font(.system(size: 7.5, weight: .bold, design: .monospaced)) + .foregroundStyle(textDim) + Text(data.trainerName) + .font(.system(size: 9, weight: .heavy, design: .monospaced)) + .foregroundStyle(textBright) + .lineLimit(1) + } + + Spacer(minLength: 0) + } + .padding(.top, 2) + } + .padding(.horizontal, 12) + .padding(.top, 10) + + Spacer(minLength: 4) + + // Neon separator + Rectangle().fill( + LinearGradient(colors: [neon.opacity(0), neon.opacity(0.5), neon.opacity(0)], + startPoint: .leading, endPoint: .trailing) + ).frame(height: 1) + + // Bottom stat strip: 3x2 grid with neon dividers + HStack(spacing: 0) { + neonStatCell(l.trainerCardLifetimeTokens, TokenFormatter.compact(data.totalTokens), accent: neon, dim: textDim, bright: textBright) + neonDivider(neon) + neonStatCell(l.trainerCardTodayTokens, TokenFormatter.compact(data.todayTokens), accent: neonGreen, dim: textDim, bright: textBright) + neonDivider(neon) + neonStatCell(l.trainerCardPokedexLabel, "\(data.pokedexCount)/\(data.pokedexTotal)", accent: neon, dim: textDim, bright: textBright) + } + .frame(height: 32) + + HStack(spacing: 0) { + neonStatCell(l.trainerCardGraduatedLabel, "\(data.graduatedCount)", accent: neonGreen, dim: textDim, bright: textBright) + neonDivider(neon) + neonStatCell(l.trainerCardShiniesLabel, "\(data.shinyCount)", accent: neon, dim: textDim, bright: textBright) + neonDivider(neon) + neonStatCell(l.trainerCardCasinoCoinsLabel, data.casinoCoins.formatted(), accent: neonGreen, dim: textDim, bright: textBright) + } + .frame(height: 32) + .padding(.bottom, 6) + } + } + } + + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + // MARK: - 5) Team Rocket Layout (Classified Dossier) + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + private var rocketLayout: some View { + let bg = Color(red: 0.10, green: 0.10, blue: 0.12) + let red = Color(red: 0.90, green: 0.12, blue: 0.22) + let redDim = Color(red: 0.75, green: 0.10, blue: 0.18) + let textBright: Color = .white + let textDim = Color.white.opacity(0.60) + + return ZStack { + // Background + LinearGradient( + colors: [Color(red: 0.10, green: 0.10, blue: 0.12), Color(red: 0.14, green: 0.06, blue: 0.08)], + startPoint: .top, endPoint: .bottom + ) + + // Large "R" watermark + Text("R") + .font(.system(size: 140, weight: .black, design: .serif)) + .italic() + .foregroundStyle(red.opacity(0.07)) + .offset(x: 60, y: 10) + + VStack(spacing: 0) { + // Header bar + HStack { + Rectangle().fill(red).frame(width: 3, height: 14) + Text(l.trainerCard.uppercased()) + .font(.system(size: 10, weight: .black, design: .monospaced)) + .foregroundStyle(red) + Spacer() + Text("IDNo.\(data.trainerID)") + .font(.system(size: 8, weight: .bold, design: .monospaced)) + .foregroundStyle(textDim) + } + .padding(.horizontal, 12) + .padding(.top, 10) + + Rectangle().fill(red.opacity(0.4)).frame(height: 1).padding(.horizontal, 12).padding(.top, 4) + + // Main content: photo + dossier fields + HStack(alignment: .top, spacing: 10) { + // Photo with red border + VStack(spacing: 3) { + ZStack(alignment: .topTrailing) { + RoundedRectangle(cornerRadius: 4) + .fill(bg) + .frame(width: 80, height: 80) + .overlay( + RoundedRectangle(cornerRadius: 4) + .strokeBorder(redDim, lineWidth: 1.5) + ) + SpriteView(speciesID: data.pokemonID, size: 72, animated: false, shiny: data.isShiny) + .frame(width: 80, height: 80) + if data.isShiny { + Image(systemName: "sparkles") + .font(.system(size: 7, weight: .bold)) + .foregroundStyle(.yellow) + .padding(3) + .background(Color.black.opacity(0.6), in: Circle()) + .offset(x: -2, y: 2) + } + } + Text(data.pokemonName) + .font(.system(size: 8.5, weight: .bold, design: .monospaced)) + .foregroundStyle(textBright) + .lineLimit(1) + .frame(width: 80) + } + + // Dossier fields + VStack(alignment: .leading, spacing: 4) { + rocketField(label: l.trainerCardTrainerLabel.uppercased(), value: data.trainerName, red: red, dim: textDim, bright: textBright) + + if let sub = data.pokemonSubtitle, !sub.isEmpty { + rocketField(label: "STATUS", value: sub, red: red, dim: textDim, bright: textBright) + } + + Rectangle().fill(red.opacity(0.2)).frame(height: 1).padding(.top, 2) + + // Compact stat pairs + HStack(spacing: 8) { + rocketStat(l.trainerCardLifetimeTokens, TokenFormatter.compact(data.totalTokens), dim: textDim, bright: textBright) + rocketStat(l.trainerCardTodayTokens, TokenFormatter.compact(data.todayTokens), dim: textDim, bright: textBright) + } + HStack(spacing: 8) { + rocketStat(l.trainerCardPokedexLabel, "\(data.pokedexCount)/\(data.pokedexTotal)", dim: textDim, bright: textBright) + rocketStat(l.trainerCardGraduatedLabel, "\(data.graduatedCount)", dim: textDim, bright: textBright) + } + HStack(spacing: 8) { + rocketStat(l.trainerCardShiniesLabel, "\(data.shinyCount)", dim: textDim, bright: textBright) + rocketStat(l.trainerCardCasinoCoinsLabel, data.casinoCoins.formatted(), dim: textDim, bright: textBright) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.horizontal, 12) + .padding(.top, 6) + + Spacer(minLength: 0) + } + } + } + + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + // MARK: - 6) Indigo Plateau Layout (Hall of Fame) + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + private var plateauLayout: some View { + let goldPrimary = Color(red: 0.96, green: 0.82, blue: 0.32) + let goldDark = Color(red: 0.72, green: 0.54, blue: 0.16) + let goldBright = Color(red: 1.0, green: 0.92, blue: 0.55) + let bgMidnight = Color(red: 0.05, green: 0.06, blue: 0.20) + let bgDeep = Color(red: 0.09, green: 0.08, blue: 0.28) + let bgDark = Color(red: 0.03, green: 0.04, blue: 0.14) + let textDim = Color.white.opacity(0.65) + + return ZStack { + // Royal velvet midnight background + LinearGradient( + colors: [bgMidnight, bgDeep, bgDark], + startPoint: .topLeading, endPoint: .bottomTrailing + ) + + // Fine gold inner frame + RoundedRectangle(cornerRadius: 10, style: .continuous) + .strokeBorder( + LinearGradient( + colors: [goldBright, goldPrimary, goldDark, goldPrimary], + startPoint: .topLeading, endPoint: .bottomTrailing + ), + lineWidth: 1.5 + ) + .padding(3) + + RoundedRectangle(cornerRadius: 7, style: .continuous) + .strokeBorder(goldPrimary.opacity(0.25), lineWidth: 0.6) + .padding(6) + + VStack(spacing: 0) { + // ── Luxury Ribbon Header ── + ZStack { + LinearGradient( + colors: [goldPrimary.opacity(0.22), goldPrimary.opacity(0.08), goldPrimary.opacity(0.22)], + startPoint: .leading, endPoint: .trailing + ) + + HStack(spacing: 4) { + Image(systemName: "star.fill") + .font(.system(size: 7)) + .foregroundStyle(goldBright) + Text(l.trainerCard.uppercased()) + .font(.system(size: 9.5, weight: .black, design: .monospaced)) + .foregroundStyle(goldBright) + .tracking(2) + Image(systemName: "star.fill") + .font(.system(size: 7)) + .foregroundStyle(goldBright) + + Spacer() + + Text("IDNo. \(data.trainerID)") + .font(.system(size: 8.5, weight: .bold, design: .monospaced)) + .foregroundStyle(goldPrimary) + } + .padding(.horizontal, 14) + } + .frame(height: 24) + + // Fine gold divider line + Rectangle() + .fill(LinearGradient( + colors: [goldDark.opacity(0.2), goldPrimary, goldBright, goldPrimary, goldDark.opacity(0.2)], + startPoint: .leading, endPoint: .trailing + )) + .frame(height: 1) + + // ── Main Content Area ── + HStack(alignment: .center, spacing: 10) { + // Left: Pokemon Cameo Frame + VStack(spacing: 3) { + ZStack(alignment: .topTrailing) { + // Gold frame + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(RadialGradient( + colors: [Color(red: 0.15, green: 0.16, blue: 0.40), Color(red: 0.06, green: 0.07, blue: 0.22)], + center: .center, startRadius: 5, endRadius: 46 + )) + .frame(width: 82, height: 82) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .strokeBorder( + LinearGradient( + colors: [goldBright, goldPrimary, goldDark], + startPoint: .topLeading, endPoint: .bottomTrailing + ), + lineWidth: 1.8 + ) + ) + + SpriteView(speciesID: data.pokemonID, size: 74, animated: false, shiny: data.isShiny) + .frame(width: 82, height: 82) + + if data.isShiny { + Image(systemName: "sparkles") + .font(.system(size: 8, weight: .bold)) + .foregroundStyle(goldBright) + .padding(3) + .background(Color.black.opacity(0.65), in: Circle()) + .offset(x: -2, y: 2) + } + } + + Text(data.pokemonName) + .font(.system(size: 9.5, weight: .black, design: .monospaced)) + .foregroundStyle(goldBright) + .lineLimit(1) + .frame(width: 84) + + if let sub = data.pokemonSubtitle, !sub.isEmpty { + Text(sub) + .font(.system(size: 6.5, weight: .bold, design: .monospaced)) + .foregroundStyle(goldPrimary.opacity(0.9)) + .lineLimit(1) + .minimumScaleFactor(0.7) + .padding(.horizontal, 4) + .padding(.vertical, 1.5) + .background(Color.black.opacity(0.35), in: RoundedRectangle(cornerRadius: 3)) + .frame(width: 84) + } + } + + // Right: Champion Header + Hall of Fame Telemetry Grid + VStack(alignment: .leading, spacing: 4) { + // Champion & Trainer + HStack(spacing: 4) { + Text("CHAMPION") + .font(.system(size: 7, weight: .black, design: .monospaced)) + .foregroundStyle(goldDark) + .padding(.horizontal, 4) + .padding(.vertical, 1.5) + .background(goldPrimary.opacity(0.15), in: RoundedRectangle(cornerRadius: 3)) + + Text(data.trainerName) + .font(.system(size: 11, weight: .black, design: .monospaced)) + .foregroundStyle(.white) + .lineLimit(1) + } + + // 2x3 Luxury Telemetry Plaque + VStack(spacing: 3.5) { + HStack(spacing: 4) { + hofTile(icon: "chart.bar.fill", label: l.trainerCardLifetimeTokens, value: TokenFormatter.compact(data.totalTokens), gold: goldPrimary, bright: goldBright, dim: textDim) + hofTile(icon: "bolt.fill", label: l.trainerCardTodayTokens, value: TokenFormatter.compact(data.todayTokens), gold: goldPrimary, bright: goldBright, dim: textDim) + } + HStack(spacing: 4) { + hofTile(icon: "book.closed.fill", label: l.trainerCardPokedexLabel, value: "\(data.pokedexCount)/\(data.pokedexTotal)", gold: goldPrimary, bright: goldBright, dim: textDim) + hofTile(icon: "graduationcap.fill", label: l.trainerCardGraduatedLabel, value: "\(data.graduatedCount)", gold: goldPrimary, bright: goldBright, dim: textDim) + } + HStack(spacing: 4) { + hofTile(icon: "sparkles", label: l.trainerCardShiniesLabel, value: "\(data.shinyCount)", gold: goldPrimary, bright: goldBright, dim: textDim) + hofTile(icon: "suit.diamond.fill", label: l.trainerCardCasinoCoinsLabel, value: data.casinoCoins.formatted(), gold: goldPrimary, bright: goldBright, dim: textDim) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.horizontal, 14) + .padding(.vertical, 6) + + Spacer(minLength: 0) + + // Footer Line + HStack(spacing: 4) { + Rectangle().fill(goldDark.opacity(0.35)).frame(height: 0.5) + Text("HALL OF FAME INDUCTEE") + .font(.system(size: 6, weight: .bold, design: .monospaced)) + .foregroundStyle(goldPrimary.opacity(0.6)) + .tracking(1) + Rectangle().fill(goldDark.opacity(0.35)).frame(height: 0.5) + } + .padding(.horizontal, 16) + .padding(.bottom, 6) + } + } + } + + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + // MARK: - Shared Components + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + // -- Pokeball watermark (Classic / Master Ball) + private func pokeBallWatermark(stroke: Color, fill: Color) -> some View { + ZStack { + Circle() + .strokeBorder(stroke, lineWidth: 10) + .frame(width: 100, height: 100) + Rectangle() + .fill(stroke) + .frame(width: 100, height: 10) + Circle() + .strokeBorder(fill, lineWidth: 6) + .frame(width: 36, height: 36) + } + } + + // -- Trainer name row (Classic / Master Ball) + private func trainerNameRow(primary: Color, label: Color) -> some View { + HStack(spacing: 4) { + Text(l.trainerCardTrainerLabel.uppercased() + ":") + .font(.system(size: 8.5, weight: .bold, design: .monospaced)) + .foregroundStyle(label) + Text(data.trainerName) + .font(.system(size: 10, weight: .heavy, design: .monospaced)) + .foregroundStyle(primary) + .lineLimit(1) + Spacer(minLength: 0) + } + .padding(.horizontal, 2) + } + + // -- Pokemon frame with name/subtitle (Classic / Master Ball) + private func pokemonFrame(bg: Color, border: Color, nameColor: Color, subColor: Color, subBg: Color) -> some View { + VStack(spacing: 3) { + ZStack(alignment: .topTrailing) { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(bg) + .frame(width: 86, height: 86) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .strokeBorder(border, lineWidth: 1.5) + ) + SpriteView(speciesID: data.pokemonID, size: 76, animated: false, shiny: data.isShiny) + .frame(width: 86, height: 86) + if data.isShiny { + Image(systemName: "sparkles") + .font(.system(size: 8, weight: .bold)) + .foregroundStyle(.yellow) + .padding(3) + .background(Color.black.opacity(0.60), in: Circle()) + .offset(x: -3, y: 3) + } + } + Text(data.pokemonName) + .font(.system(size: 9.5, weight: .bold, design: .monospaced)) + .foregroundStyle(nameColor) + .lineLimit(1) + .frame(width: 86) + if let subtitle = data.pokemonSubtitle, !subtitle.isEmpty { + Text(subtitle) + .font(.system(size: 7, weight: .bold, design: .monospaced)) + .foregroundStyle(subColor) + .lineLimit(1) + .minimumScaleFactor(0.8) + .padding(.horizontal, 4) + .padding(.vertical, 2) + .background(subBg) + .clipShape(RoundedRectangle(cornerRadius: 3, style: .continuous)) + .frame(width: 86) + } + } + } + + // -- 2x3 stat grid with tile style (Classic / Master Ball) + private func statsGrid(label: Color, value: Color, tileBg: Color, tileBorder: Color) -> some View { + VStack(spacing: 4) { + HStack(spacing: 5) { + statTile(icon: "chart.bar.fill", label: l.trainerCardLifetimeTokens, value: TokenFormatter.compact(data.totalTokens), + labelColor: label, valueColor: value, bg: tileBg, border: tileBorder) + statTile(icon: "bolt.fill", label: l.trainerCardTodayTokens, value: TokenFormatter.compact(data.todayTokens), + labelColor: label, valueColor: value, bg: tileBg, border: tileBorder) + } + HStack(spacing: 5) { + statTile(icon: "book.closed.fill", label: l.trainerCardPokedexLabel, value: "\(data.pokedexCount) / \(data.pokedexTotal)", + labelColor: label, valueColor: value, bg: tileBg, border: tileBorder) + statTile(icon: "graduationcap.fill", label: l.trainerCardGraduatedLabel, value: "\(data.graduatedCount)", + labelColor: label, valueColor: value, bg: tileBg, border: tileBorder) + } + HStack(spacing: 5) { + statTile(icon: "sparkles", label: l.trainerCardShiniesLabel, value: "\(data.shinyCount)", + labelColor: label, valueColor: value, bg: tileBg, border: tileBorder) + statTile(icon: "suit.diamond.fill", label: l.trainerCardCasinoCoinsLabel, value: data.casinoCoins.formatted(), + labelColor: label, valueColor: value, bg: tileBg, border: tileBorder) + } + } + } + + // -- Single tile stat (Classic / Master Ball) + private func statTile(icon: String, label: String, value: String, + labelColor: Color, valueColor: Color, + bg: Color, border: Color) -> some View { + VStack(alignment: .leading, spacing: 1.5) { + HStack(spacing: 3) { + Image(systemName: icon) + .font(.system(size: 7, weight: .bold)) + .foregroundStyle(labelColor) + Text(label.uppercased()) + .font(.system(size: 7, weight: .bold, design: .monospaced)) + .foregroundStyle(labelColor) + .lineLimit(1) + } + Text(value) + .font(.system(size: 10, weight: .black, design: .monospaced)) + .foregroundStyle(valueColor) + .lineLimit(1) + .minimumScaleFactor(0.85) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 6) + .padding(.vertical, 3.5) + .background(bg) + .clipShape(RoundedRectangle(cornerRadius: 5, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 5, style: .continuous) + .strokeBorder(border, lineWidth: 0.9) + ) + } + + // -- Game Boy compact stat line + private func gbStatLine(_ label: String, _ value: String, ink: Color, mid: Color) -> some View { + HStack(spacing: 0) { + Text(label.uppercased()) + .font(.system(size: 6.5, weight: .bold, design: .monospaced)) + .foregroundStyle(mid) + .lineLimit(1) + Spacer(minLength: 2) + Text(value) + .font(.system(size: 7.5, weight: .black, design: .monospaced)) + .foregroundStyle(ink) + .lineLimit(1) + } + } + + // -- Neon stat cell + private func neonStatCell(_ label: String, _ value: String, accent: Color, dim: Color, bright: Color) -> some View { + VStack(spacing: 1) { + Text(label.uppercased()) + .font(.system(size: 6, weight: .bold, design: .monospaced)) + .foregroundStyle(dim) + .lineLimit(1) + .minimumScaleFactor(0.7) + Text(value) + .font(.system(size: 10, weight: .black, design: .monospaced)) + .foregroundStyle(accent) + .lineLimit(1) + .minimumScaleFactor(0.8) + } + .frame(maxWidth: .infinity) + } + + // -- Neon vertical divider + private func neonDivider(_ color: Color) -> some View { + Rectangle() + .fill(color.opacity(0.25)) + .frame(width: 1) + .padding(.vertical, 6) + } + + // -- Team Rocket dossier field + private func rocketField(label: String, value: String, red: Color, dim: Color, bright: Color) -> some View { + HStack(spacing: 4) { + Text(label + ":") + .font(.system(size: 7, weight: .bold, design: .monospaced)) + .foregroundStyle(red.opacity(0.8)) + Text(value) + .font(.system(size: 8.5, weight: .heavy, design: .monospaced)) + .foregroundStyle(bright) + .lineLimit(1) + } + } + + // -- Team Rocket compact stat + private func rocketStat(_ label: String, _ value: String, dim: Color, bright: Color) -> some View { + VStack(alignment: .leading, spacing: 0.5) { + Text(label.uppercased()) + .font(.system(size: 6, weight: .bold, design: .monospaced)) + .foregroundStyle(dim) + .lineLimit(1) + .minimumScaleFactor(0.7) + Text(value) + .font(.system(size: 8.5, weight: .black, design: .monospaced)) + .foregroundStyle(bright) + .lineLimit(1) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + // -- Master Ball corner stat + private func mbCornerStat(label: String, value: String, labelColor: Color, valColor: Color) -> some View { + VStack(alignment: .leading, spacing: 1) { + Text(label.uppercased()) + .font(.system(size: 5.5, weight: .bold, design: .monospaced)) + .foregroundStyle(labelColor) + .lineLimit(1) + .minimumScaleFactor(0.7) + Text(value) + .font(.system(size: 8, weight: .black, design: .monospaced)) + .foregroundStyle(valColor) + .lineLimit(1) + } + } + + // -- Hall of Fame luxury plaque tile + private func hofTile(icon: String, label: String, value: String, gold: Color, bright: Color, dim: Color) -> some View { + HStack(spacing: 3) { + Image(systemName: icon) + .font(.system(size: 6.5, weight: .bold)) + .foregroundStyle(gold) + VStack(alignment: .leading, spacing: 0.5) { + Text(label.uppercased()) + .font(.system(size: 5.5, weight: .bold, design: .monospaced)) + .foregroundStyle(dim) + .lineLimit(1) + .minimumScaleFactor(0.7) + Text(value) + .font(.system(size: 8.5, weight: .black, design: .monospaced)) + .foregroundStyle(bright) + .lineLimit(1) + .minimumScaleFactor(0.8) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 5) + .padding(.vertical, 3) + .background(Color(red: 0.12, green: 0.14, blue: 0.36).opacity(0.50)) + .clipShape(RoundedRectangle(cornerRadius: 4, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 4, style: .continuous) + .strokeBorder(gold.opacity(0.30), lineWidth: 0.7) + ) + } +} + +// MARK: - Trainer Card Modal View + +@MainActor +struct TrainerCardModalView: View { + let store: CompanionStore + let usageStore: UsageStore? + let nav: PopoverNavigation + let onClose: () -> Void + + @State private var selectedTheme: AppThemeKind + @State private var useActiveMon: Bool = true + @State private var selectedDexSpeciesID: Int + @State private var showAsShiny: Bool = false + @State private var copyFeedback: Bool = false + + // MARK: - Persistence keys + private static let udKeyUseActiveMon = "PTB_TrainerCard_useActiveMon" + private static let udKeySpeciesID = "PTB_TrainerCard_selectedSpeciesID" + private static let udKeyShowAsShiny = "PTB_TrainerCard_showAsShiny" + private static let udKeySelectedTheme = "PTB_TrainerCard_selectedTheme" + + init( + store: CompanionStore, + usageStore: UsageStore? = nil, + nav: PopoverNavigation, + initialSpeciesID: Int? = nil, + onClose: @escaping () -> Void + ) { + self.store = store + self.usageStore = usageStore + self.nav = nav + self.onClose = onClose + + let ud = UserDefaults.standard + + if let initialSpeciesID { + // Opened from Pokedex tap — override with explicit species + _useActiveMon = State(initialValue: false) + _selectedDexSpeciesID = State(initialValue: initialSpeciesID) + _showAsShiny = State(initialValue: store.state.ownsShinySpecies(initialSpeciesID)) + _selectedTheme = State(initialValue: store.activeTheme) + } else if ud.object(forKey: Self.udKeyUseActiveMon) != nil { + // Restore previous session + let savedUseActive = ud.bool(forKey: Self.udKeyUseActiveMon) + let savedSpeciesID = ud.integer(forKey: Self.udKeySpeciesID) + let savedShiny = ud.bool(forKey: Self.udKeyShowAsShiny) + let savedThemeRaw = ud.string(forKey: Self.udKeySelectedTheme) ?? "" + let savedTheme = AppThemeKind(rawValue: savedThemeRaw) ?? store.activeTheme + + _useActiveMon = State(initialValue: savedUseActive) + _selectedDexSpeciesID = State(initialValue: savedSpeciesID > 0 ? savedSpeciesID : (store.currentSpeciesID ?? 25)) + _showAsShiny = State(initialValue: savedShiny) + _selectedTheme = State(initialValue: savedTheme) + } else { + // First open ever — defaults + let activeID = store.currentSpeciesID ?? 25 + _useActiveMon = State(initialValue: true) + _selectedDexSpeciesID = State(initialValue: activeID) + _showAsShiny = State(initialValue: store.currentIsShiny) + _selectedTheme = State(initialValue: store.activeTheme) + } + } + + private var l: L { store.l } + + private var currentPokemonData: (pokemonID: Int?, pokemonName: String, subtitle: String?, isShiny: Bool) { + if useActiveMon, let active = store.state.active { + var subtitleParts: [String] = [] + if active.totalForms > 1 { + subtitleParts.append(l.stage(active.stageIndex + 1, active.totalForms).uppercased()) + } else { + subtitleParts.append(l.rarityLabel(active.rarity).uppercased()) + } + if let nature = active.nature { + subtitleParts.append(nature.name(store.language).uppercased()) + } + let sub = subtitleParts.isEmpty ? nil : subtitleParts.joined(separator: " · ") + return (active.currentID, store.displayName, sub, active.isShiny) + } else if let species = store.dexSpecies.first(where: { $0.id == selectedDexSpeciesID }) { + let entry = store.state.dex.first { $0.chainOrder.contains(species.id) } + var subtitleParts: [String] = [] + if let entry { + subtitleParts.append(l.rarityLabel(entry.rarity).uppercased()) + if let nature = entry.nature { + subtitleParts.append(nature.name(store.language).uppercased()) + } + } else { + subtitleParts.append(l.rarityLabel(species.rarity).uppercased()) + } + let sub = subtitleParts.isEmpty ? nil : subtitleParts.joined(separator: " · ") + return (species.id, species.name, sub, showAsShiny) + } else { + return (store.currentSpeciesID, store.displayName, nil, store.currentIsShiny) + } + } + + private var cardData: TrainerCardData { + let mon = currentPokemonData + let name = TrainerCardData.defaultTrainerName() + let id = TrainerCardData.deterministicTrainerID(from: name) + let todayTokens = usageStore?.todayTotalTokens ?? 0 + let graduated = store.state.dex.filter { !$0.isReleased }.count + + return TrainerCardData( + trainerName: name, + trainerID: id, + theme: selectedTheme, + pokemonID: mon.pokemonID, + pokemonName: mon.pokemonName, + pokemonSubtitle: mon.subtitle, + isShiny: mon.isShiny, + totalTokens: store.state.usedSinceInstall, + todayTokens: todayTokens, + pokedexCount: store.dexSpecies.count, + pokedexTotal: 151, + graduatedCount: graduated, + shinyCount: store.state.dex.filter(\.isShiny).count + (store.currentIsShiny ? 1 : 0), + casinoCoins: store.casinoCoins + ) + } + + var body: some View { + VStack(spacing: 10) { + topBar + ScrollView(showsIndicators: false) { + VStack(spacing: 12) { + cardPreviewSection + exportButtonsRow + controlsSection + } + .padding(.bottom, 16) + } + } + .frame(width: PopoverMetrics.width) + } + + private var topBar: some View { + HStack { + Button(action: onClose) { + Label(l.back, systemImage: "chevron.left") + .font(.caption.weight(.semibold)) + } + .buttonStyle(.plain) + .foregroundStyle(store.activeTheme.accentColor) + + Spacer() + + Text(l.trainerCardTitle) + .font(.headline) + + Spacer() + + Text(l.back) + .font(.caption) + .opacity(0) + } + .padding(.horizontal, PopoverMetrics.padding) + .padding(.top, 12) + } + + private var cardPreviewSection: some View { + TrainerCardView(data: cardData, l: l) + .padding(.top, 4) + } + + private var exportButtonsRow: some View { + HStack(spacing: 10) { + Button { + copyCard() + } label: { + HStack(spacing: 5) { + Image(systemName: copyFeedback ? "checkmark" : "doc.on.doc") + Text(copyFeedback ? l.trainerCardCopied : l.trainerCardCopyImage) + } + .font(.caption.weight(.semibold)) + .frame(maxWidth: .infinity) + .padding(.vertical, 7) + .background(copyFeedback ? Color.green.opacity(0.18) : store.activeTheme.accentColor.opacity(0.14)) + .foregroundStyle(copyFeedback ? Color.green : store.activeTheme.accentColor) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + .buttonStyle(.plain) + + Button { + saveCardPNG() + } label: { + HStack(spacing: 5) { + Image(systemName: "square.and.arrow.down") + Text(l.trainerCardSaveImage) + } + .font(.caption.weight(.semibold)) + .frame(maxWidth: .infinity) + .padding(.vertical, 7) + .background(Color.secondary.opacity(0.12)) + .foregroundStyle(.primary) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + .buttonStyle(.plain) + } + .padding(.horizontal, PopoverMetrics.padding) + } + + private var controlsSection: some View { + VStack(alignment: .leading, spacing: 10) { + themePickerBlock + pokemonSourceBlock + casinoHintBlock + } + .padding(PopoverMetrics.padding) + .background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 10)) + .padding(.horizontal, PopoverMetrics.padding) + } + + private var themePickerBlock: some View { + VStack(alignment: .leading, spacing: 4) { + Text(l.activeThemeLabel) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + + Picker(l.activeThemeLabel, selection: $selectedTheme) { + ForEach(AppThemeKind.allCases.filter { store.unlockedThemes.contains($0.rawValue) }, id: \.self) { theme in + Text(l.themeName(theme)).tag(theme) + } + } + .labelsHidden() + .accessibilityLabel(l.activeThemeLabel) + .pickerStyle(.menu) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + private var pokemonSourceBlock: some View { + VStack(alignment: .leading, spacing: 6) { + Text(l.trainerCardPokemonSource) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + + Picker(l.trainerCardPokemonSource, selection: $useActiveMon) { + Text(l.trainerCardActiveMon).tag(true) + Text(l.trainerCardDexMon).tag(false) + } + .pickerStyle(.segmented) + .labelsHidden() + .accessibilityLabel(l.trainerCardPokemonSource) + + if !useActiveMon { + HStack(spacing: 8) { + Picker(l.trainerCardDexMon, selection: $selectedDexSpeciesID) { + ForEach(store.dexSpecies) { species in + Text(store.state.ownsShinySpecies(species.id) ? "\u{2605} \(species.name)" : species.name) + .tag(species.id) + } + } + .labelsHidden() + .accessibilityLabel(l.trainerCardDexMon) + .pickerStyle(.menu) + .onChange(of: selectedDexSpeciesID) { _, newID in + if store.state.ownsShinySpecies(newID) { + showAsShiny = true + } else { + showAsShiny = false + } + UserDefaults.standard.set(newID, forKey: Self.udKeySpeciesID) + } + + let canShiny = store.state.ownsShinySpecies(selectedDexSpeciesID) + Toggle(isOn: $showAsShiny) { + Text(l.trainerCardShiny) + .font(.caption) + } + .toggleStyle(.checkbox) + .disabled(!canShiny) + .onChange(of: showAsShiny) { _, newVal in + UserDefaults.standard.set(newVal, forKey: Self.udKeyShowAsShiny) + } + } + } + } + .onChange(of: useActiveMon) { _, newVal in + UserDefaults.standard.set(newVal, forKey: Self.udKeyUseActiveMon) + } + .onChange(of: selectedTheme) { _, newVal in + UserDefaults.standard.set(newVal.rawValue, forKey: Self.udKeySelectedTheme) + } + } + + @ViewBuilder + private var casinoHintBlock: some View { + if store.unlockedThemes.count < AppThemeKind.allCases.count { + HStack(alignment: .top, spacing: 6) { + Image(systemName: "sparkles") + .font(.caption) + .foregroundStyle(store.activeTheme.accentColor) + Text(l.trainerCardCasinoHint) + .font(.caption2) + .foregroundStyle(.secondary) + } + .padding(8) + .background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 8)) + } + } + + // MARK: - Actions + + private func copyCard() { + let renderer = ImageRenderer(content: TrainerCardView(data: cardData, l: l)) + renderer.scale = 3.0 + guard let image = renderer.nsImage else { return } + + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.writeObjects([image]) + + withAnimation(.easeInOut(duration: 0.2)) { + copyFeedback = true + } + Task { + try? await Task.sleep(for: .seconds(2)) + withAnimation(.easeInOut(duration: 0.2)) { + copyFeedback = false + } + } + } + + private func saveCardPNG() { + let renderer = ImageRenderer(content: TrainerCardView(data: cardData, l: l)) + renderer.scale = 3.0 + guard let image = renderer.nsImage, + let tiff = image.tiffRepresentation, + let rep = NSBitmapImageRep(data: tiff), + let pngData = rep.representation(using: .png, properties: [:]) else { return } + + let panel = NSSavePanel() + panel.allowedContentTypes = [.png] + let speciesPart = cardData.pokemonID.map(String.init) ?? "egg" + panel.nameFieldStringValue = "trainer-card-\(speciesPart).png" + if panel.runModal() == .OK, let url = panel.url { + try? pngData.write(to: url) + } + } +} diff --git a/Tests/PokeTokenBarTests/TrainerCardTests.swift b/Tests/PokeTokenBarTests/TrainerCardTests.swift new file mode 100644 index 000000000..d3fe8b4f4 --- /dev/null +++ b/Tests/PokeTokenBarTests/TrainerCardTests.swift @@ -0,0 +1,130 @@ +import XCTest +import SwiftUI +@testable import PokeTokenBar + +@MainActor +final class TrainerCardTests: XCTestCase { + func testDeterministicTrainerID() { + let id1 = TrainerCardData.deterministicTrainerID(from: "RED") + let id2 = TrainerCardData.deterministicTrainerID(from: "RED") + XCTAssertEqual(id1, id2) + XCTAssertEqual(id1.count, 5) + + let idOther = TrainerCardData.deterministicTrainerID(from: "BLUE") + XCTAssertEqual(idOther.count, 5) + } + + func testTrainerCardNavigationLifecycle() { + let nav = PopoverNavigation() + XCTAssertFalse(nav.showTrainerCard) + XCTAssertNil(nav.trainerCardPreselectedSpeciesID) + + nav.openTrainerCard(speciesID: 137) + XCTAssertTrue(nav.showTrainerCard) + XCTAssertEqual(nav.trainerCardPreselectedSpeciesID, 137) + XCTAssertFalse(nav.showSettings) + + nav.reset() + XCTAssertFalse(nav.showTrainerCard) + XCTAssertNil(nav.trainerCardPreselectedSpeciesID) + XCTAssertEqual(nav.tab, .home) + } + + func testTrainerCardDataProps() { + let card = TrainerCardData( + trainerName: "Killian", + trainerID: "12345", + theme: .celadonNeon, + pokemonID: 137, + pokemonName: "Porygon", + pokemonSubtitle: "RARE · ADAMANT", + isShiny: true, + totalTokens: 300_000_000, + todayTokens: 15_000_000, + pokedexCount: 50, + pokedexTotal: 151, + graduatedCount: 8, + shinyCount: 2, + casinoCoins: 1_200 + ) + + XCTAssertEqual(card.trainerName, "Killian") + XCTAssertEqual(card.trainerID, "12345") + XCTAssertEqual(card.theme, .celadonNeon) + XCTAssertEqual(card.pokemonID, 137) + XCTAssertEqual(card.pokemonName, "Porygon") + XCTAssertEqual(card.pokemonSubtitle, "RARE · ADAMANT") + XCTAssertTrue(card.isShiny) + XCTAssertEqual(card.totalTokens, 300_000_000) + XCTAssertEqual(card.todayTokens, 15_000_000) + XCTAssertEqual(card.pokedexCount, 50) + XCTAssertEqual(card.pokedexTotal, 151) + XCTAssertEqual(card.graduatedCount, 8) + XCTAssertEqual(card.shinyCount, 2) + XCTAssertEqual(card.casinoCoins, 1_200) + } + + func testTrainerCardViewRendersAllThemes() { + let cardData = TrainerCardData( + trainerName: "RED", + trainerID: "00001", + theme: .classic, + pokemonID: 25, + pokemonName: "Pikachu", + pokemonSubtitle: "BASIC · JOLLY", + isShiny: false, + totalTokens: 50_000_000, + todayTokens: 2_500_000, + pokedexCount: 30, + pokedexTotal: 151, + graduatedCount: 5, + shinyCount: 1, + casinoCoins: 800 + ) + + for theme in AppThemeKind.allCases { + let themedData = TrainerCardData( + trainerName: cardData.trainerName, + trainerID: cardData.trainerID, + theme: theme, + pokemonID: cardData.pokemonID, + pokemonName: cardData.pokemonName, + pokemonSubtitle: cardData.pokemonSubtitle, + isShiny: cardData.isShiny, + totalTokens: cardData.totalTokens, + todayTokens: cardData.todayTokens, + pokedexCount: cardData.pokedexCount, + pokedexTotal: cardData.pokedexTotal, + graduatedCount: cardData.graduatedCount, + shinyCount: cardData.shinyCount, + casinoCoins: cardData.casinoCoins + ) + let view = TrainerCardView(data: themedData, l: L(.en)) + let renderer = ImageRenderer(content: view) + XCTAssertNotNil(renderer.nsImage, "Rendering must succeed for theme \(theme.rawValue)") + } + } + + func testTrainerCardLocalizationKeys() { + for lang in AppLanguage.allCases { + let l = L(lang) + XCTAssertFalse(l.trainerCard.isEmpty) + XCTAssertFalse(l.trainerCardTitle.isEmpty) + XCTAssertFalse(l.trainerCardCopyImage.isEmpty) + XCTAssertFalse(l.trainerCardSaveImage.isEmpty) + XCTAssertFalse(l.trainerCardCopied.isEmpty) + XCTAssertFalse(l.trainerCardActiveMon.isEmpty) + XCTAssertFalse(l.trainerCardDexMon.isEmpty) + XCTAssertFalse(l.trainerCardShiny.isEmpty) + XCTAssertFalse(l.trainerCardPokemonSource.isEmpty) + XCTAssertFalse(l.trainerCardCasinoHint.isEmpty) + XCTAssertFalse(l.trainerCardLifetimeTokens.isEmpty) + XCTAssertFalse(l.trainerCardTodayTokens.isEmpty) + XCTAssertFalse(l.trainerCardPokedexLabel.isEmpty) + XCTAssertFalse(l.trainerCardGraduatedLabel.isEmpty) + XCTAssertFalse(l.trainerCardShiniesLabel.isEmpty) + XCTAssertFalse(l.trainerCardCasinoCoinsLabel.isEmpty) + XCTAssertFalse(l.trainerCardTrainerLabel.isEmpty) + } + } +}