diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2bf2816 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +# macOS +.DS_Store + +# Xcode +xcuserdata/ +*.xcuserstate +DerivedData/ +build/ diff --git a/pokemon/.gitignore b/pokemon/.gitignore new file mode 100644 index 0000000..687b6ff --- /dev/null +++ b/pokemon/.gitignore @@ -0,0 +1,17 @@ +# macOS +.DS_Store + +# Xcode user data +xcuserdata/ +*.xcuserstate +*.xcscheme + +# Build +DerivedData/ +build/ +*.o +*.d + +# Swift Package Manager +.build/ +.swiftpm/ diff --git a/pokemon/README.md b/pokemon/README.md new file mode 100644 index 0000000..38f80d3 --- /dev/null +++ b/pokemon/README.md @@ -0,0 +1,110 @@ +# Pokémon Evolution Dex + +> An iOS Pokédex app built on the PokeAPI. +> A learning project for **Swift Concurrency** (parallel data loading) and **Swift Testing** (verifying async code). + +A SwiftUI app that groups evolution chains (families) into a single card and lays the evolution stages out horizontally. Multiple families and the detail of each stage are **loaded in parallel using nested TaskGroups**. + +--- + +## 1. Challenge Statement + +### Concurrency Lab +> Build a Pokédex app on the PokeAPI to show how the core concepts of Swift Concurrency (`async/await`, `TaskGroup`, `@MainActor`) actually behave in a real iOS app. + +### Testing Lab +> Use Swift Testing to verify that async network code behaves correctly across various situations — **success / failure / timeout / cancellation**. Apply **mock objects** and a **protocol-based dependency injection (DI)** pattern to build and document a design that is testable without a real server. + +--- + +## 2. Features + +- Displays Pokémon by evolution chain (family) as cards (e.g. Bulbasaur → Ivysaur → Venusaur) +- Loads 50 families plus each stage's detail **in parallel** (nested TaskGroups) + +--- + +## 3. Architecture + +``` +ContentView ──→ PokemonViewModel ──→ «protocol» PokemonService + (View) (@MainActor) ▲ + │ implements + PokemonServiceImpl ──→ PokeAPI + (network) +``` + +- **The ViewModel depends only on the protocol (`PokemonService`)** — it never knows the concrete implementation, so a mock can be injected here later for testing. +- `PokemonServiceImpl` decodes the PokeAPI's messy JSON into **DTOs** and converts them into the clean domain model (`Pokemon`). + +### Data models + +| Model | Description | +| --- | --- | +| `Pokemon` | id, name, imageURL, types (`Identifiable`) | +| `PokemonFamily` | id (evolution-chain id) + `stages: [Pokemon]` (in evolution order) | + +### Service contract + +```swift +protocol PokemonService: Sendable { + func fetchEvolutionChain(id: Int) async throws -> [Int] // ids in evolution order + func fetchDetail(id: Int) async throws -> Pokemon // includes image & types +} +``` + +--- + +## 4. Concurrency + +Because the data has two layers ("family → stage"), the **TaskGroups are nested two levels deep**. + +``` +Outer TaskGroup ─ 50 families concurrently + └ Each family: fetchEvolutionChain (fetch the chain first) + └ Inner TaskGroup ─ that family's stages concurrently (fetchDetail × N) +``` + +- **Across families: concurrent** — all 50 overlap in time +- **Within one family: sequential** — the chain must be fetched first to know the ids before calling detail (data dependency) +- ~150 concurrent requests. Sequentially this would take ~45s; in parallel it's on the order of ~0.6s. + +### Preserving order +TaskGroup completion order is scrambled, so results are first collected into an `[id: Pokemon]` dictionary, then **reordered by the chain order (`speciesIDs`) via `compactMap`** to restore evolution order. + +```swift +var byID: [Int: Pokemon] = [:] +for try await p in group { byID[p.id] = p } // store regardless of arrival order +let ordered = speciesIDs.compactMap { byID[$0] } // restore order +``` + +### Concurrency safety +| Keyword | Role | +| --- | --- | +| `@MainActor` | Always updates UI state (`families`, `isLoading`) on the main thread | +| `Sendable` | Safely passes the service into tasks on other threads | +| `static` + passed args | Keeps `loadFamily` from capturing `self` (the MainActor) | + +--- + +## 5. Project Structure + +``` +pokemon/ +├── pokemon.xcodeproj +└── pokemon/ + ├── pokemonApp.swift # App entry point (font & nav bar setup) + ├── Model/ + │ └── Pokemon.swift # Pokemon, PokemonFamily + ├── Service/ + │ ├── PokemonService.swift # protocol + │ └── PokemonServiceImpl.swift # real implementation + DTOs + ├── ViewModel/ + │ └── PokemonViewModel.swift # @MainActor, nested TaskGroups + ├── View/ + │ ├── ContentView.swift # card UI + │ ├── PokemonTypeStyle.swift # type colors + │ └── Font+Pretendard.swift # font helper + └── Fonts/ + └── PretendardVariable.ttf +``` diff --git a/pokemon/pokemon.xcodeproj/project.pbxproj b/pokemon/pokemon.xcodeproj/project.pbxproj new file mode 100644 index 0000000..3e36ac3 --- /dev/null +++ b/pokemon/pokemon.xcodeproj/project.pbxproj @@ -0,0 +1,339 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXFileReference section */ + F12E27C32FBEF27600F14D80 /* pokemon.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = pokemon.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + F12E27C52FBEF27600F14D80 /* pokemon */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = pokemon; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + F12E27C02FBEF27600F14D80 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + F12E27BA2FBEF27600F14D80 = { + isa = PBXGroup; + children = ( + F12E27C52FBEF27600F14D80 /* pokemon */, + F12E27C42FBEF27600F14D80 /* Products */, + ); + sourceTree = ""; + }; + F12E27C42FBEF27600F14D80 /* Products */ = { + isa = PBXGroup; + children = ( + F12E27C32FBEF27600F14D80 /* pokemon.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + F12E27C22FBEF27600F14D80 /* pokemon */ = { + isa = PBXNativeTarget; + buildConfigurationList = F12E27CE2FBEF27700F14D80 /* Build configuration list for PBXNativeTarget "pokemon" */; + buildPhases = ( + F12E27BF2FBEF27600F14D80 /* Sources */, + F12E27C02FBEF27600F14D80 /* Frameworks */, + F12E27C12FBEF27600F14D80 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + F12E27C52FBEF27600F14D80 /* pokemon */, + ); + name = pokemon; + packageProductDependencies = ( + ); + productName = pokemon; + productReference = F12E27C32FBEF27600F14D80 /* pokemon.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + F12E27BB2FBEF27600F14D80 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2640; + LastUpgradeCheck = 2640; + TargetAttributes = { + F12E27C22FBEF27600F14D80 = { + CreatedOnToolsVersion = 26.4; + }; + }; + }; + buildConfigurationList = F12E27BE2FBEF27600F14D80 /* Build configuration list for PBXProject "pokemon" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = F12E27BA2FBEF27600F14D80; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = F12E27C42FBEF27600F14D80 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + F12E27C22FBEF27600F14D80 /* pokemon */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + F12E27C12FBEF27600F14D80 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + F12E27BF2FBEF27600F14D80 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + F12E27CC2FBEF27700F14D80 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.4; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + F12E27CD2FBEF27700F14D80 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.4; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + F12E27CF2FBEF27700F14D80 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = M7F8AM92TP; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = techmap.pokemon; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + F12E27D02FBEF27700F14D80 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = M7F8AM92TP; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = techmap.pokemon; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + F12E27BE2FBEF27600F14D80 /* Build configuration list for PBXProject "pokemon" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + F12E27CC2FBEF27700F14D80 /* Debug */, + F12E27CD2FBEF27700F14D80 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + F12E27CE2FBEF27700F14D80 /* Build configuration list for PBXNativeTarget "pokemon" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + F12E27CF2FBEF27700F14D80 /* Debug */, + F12E27D02FBEF27700F14D80 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = F12E27BB2FBEF27600F14D80 /* Project object */; +} diff --git a/pokemon/pokemon.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/pokemon/pokemon.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/pokemon/pokemon.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/pokemon/pokemon/Assets.xcassets/AccentColor.colorset/Contents.json b/pokemon/pokemon/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/pokemon/pokemon/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/pokemon/pokemon/Assets.xcassets/AppIcon.appiconset/Contents.json b/pokemon/pokemon/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..2305880 --- /dev/null +++ b/pokemon/pokemon/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/pokemon/pokemon/Assets.xcassets/Contents.json b/pokemon/pokemon/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/pokemon/pokemon/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/pokemon/pokemon/Fonts/PretendardVariable.ttf b/pokemon/pokemon/Fonts/PretendardVariable.ttf new file mode 100644 index 0000000..32b0811 Binary files /dev/null and b/pokemon/pokemon/Fonts/PretendardVariable.ttf differ diff --git a/pokemon/pokemon/Model/Pokemon.swift b/pokemon/pokemon/Model/Pokemon.swift new file mode 100644 index 0000000..b76cf70 --- /dev/null +++ b/pokemon/pokemon/Model/Pokemon.swift @@ -0,0 +1,17 @@ +import Foundation + +/// A fully-detailed Pokémon. +/// `Identifiable`: required by SwiftUI `List`/`ForEach` to tell items apart (uses the id property automatically). +struct Pokemon: Identifiable { + let id: Int + let name: String + let imageURL: String + let types: [String] +} + +/// A single evolution chain (family). Displayed as one card. +/// e.g. [Bulbasaur, Ivysaur, Venusaur] +struct PokemonFamily: Identifiable { + let id: Int // evolution-chain id + let stages: [Pokemon] // stages in evolution order +} diff --git a/pokemon/pokemon/Service/PokemonService.swift b/pokemon/pokemon/Service/PokemonService.swift new file mode 100644 index 0000000..b34a16b --- /dev/null +++ b/pokemon/pokemon/Service/PokemonService.swift @@ -0,0 +1,10 @@ +import Foundation + +protocol PokemonService: Sendable { + /// Fetches an evolution chain. Returns Pokémon ids in evolution order. + /// e.g. chain 1 -> [1, 2, 3] (Bulbasaur -> Ivysaur -> Venusaur) + func fetchEvolutionChain(id: Int) async throws -> [Int] + + /// Fetches detail for a specific Pokémon. (includes image & types) + func fetchDetail(id: Int) async throws -> Pokemon +} diff --git a/pokemon/pokemon/Service/PokemonServiceImpl.swift b/pokemon/pokemon/Service/PokemonServiceImpl.swift new file mode 100644 index 0000000..945b2bf --- /dev/null +++ b/pokemon/pokemon/Service/PokemonServiceImpl.swift @@ -0,0 +1,87 @@ +import Foundation + +/// Real service implementation that calls the PokeAPI. +struct PokemonServiceImpl: PokemonService { + + /// Injectable for testing or configuration. (defaults to .shared) + let session: URLSession + + init(session: URLSession = .shared) { + self.session = session + } + + private let baseURL = "https://pokeapi.co/api/v2" + + // MARK: - PokemonService + + func fetchEvolutionChain(id: Int) async throws -> [Int] { + let url = URL(string: "\(baseURL)/evolution-chain/\(id)")! + let (data, _) = try await session.data(from: url) + let decoded = try JSONDecoder().decode(EvolutionChainResponse.self, from: data) + + // Flatten the tree (species + evolves_to) into evolution order. + var ids: [Int] = [] + func walk(_ link: EvolutionChainResponse.ChainLink) { + ids.append(link.species.extractedID) + for next in link.evolves_to { + walk(next) + } + } + walk(decoded.chain) + return ids + } + + func fetchDetail(id: Int) async throws -> Pokemon { + let url = URL(string: "\(baseURL)/pokemon/\(id)")! + let (data, _) = try await session.data(from: url) + let decoded = try JSONDecoder().decode(DetailResponse.self, from: data) + return Pokemon( + id: decoded.id, + name: decoded.name, + imageURL: decoded.sprites.front_default ?? "", + types: decoded.types.map { $0.type.name } + ) + } +} + +// MARK: - DTOs for decoding PokeAPI responses (mirror the external JSON shape) + +private extension PokemonServiceImpl { + + /// GET /evolution-chain/{id} response (recursive tree structure) + struct EvolutionChainResponse: Decodable { + let chain: ChainLink + + struct ChainLink: Decodable { + let species: Species + let evolves_to: [ChainLink] // next evolution stages (can branch) + } + struct Species: Decodable { + let name: String + let url: String // e.g. ".../pokemon-species/1/" + + /// Extracts the trailing number in the url as the id. + var extractedID: Int { + url.split(separator: "/").last.flatMap { Int($0) } ?? -1 + } + } + } + + /// GET /pokemon/{id} response + struct DetailResponse: Decodable { + let id: Int + let name: String + let sprites: Sprites + let types: [TypeEntry] + + struct Sprites: Decodable { + let front_default: String? + } + struct TypeEntry: Decodable { + let type: TypeInfo + } + struct TypeInfo: Decodable { + let name: String + } + } +} diff --git a/pokemon/pokemon/View/ContentView.swift b/pokemon/pokemon/View/ContentView.swift new file mode 100644 index 0000000..2166e9d --- /dev/null +++ b/pokemon/pokemon/View/ContentView.swift @@ -0,0 +1,158 @@ +import SwiftUI + +struct ContentView: View { + @StateObject private var viewModel = PokemonViewModel() + + var body: some View { + NavigationStack { + ScrollView { + LazyVStack(spacing: 16) { + ForEach(viewModel.families) { family in + FamilyCard(family: family) + } + } + .padding() + } + .background(Color.white) + .overlay { + if viewModel.isLoading { + LoadingView() + } + } + .navigationTitle("Pokédex") + .task { + await viewModel.loadFamilies() + } + } + .preferredColorScheme(.light) + } +} + +/// A card showing one evolution family, tinted by the family's primary type. +private struct FamilyCard: View { + let family: PokemonFamily + + /// Tint comes from the first stage's primary type. + private var tint: Color { + family.stages.first.map(PokemonTypeStyle.primaryColor(for:)) ?? .gray + } + + var body: some View { + Group { + if family.stages.count >= 4 { + // 4+ stages: scroll horizontally so side padding stays intact + // instead of squeezing the line edge to edge. + ScrollView(.horizontal, showsIndicators: false) { + stagesRow(fixedWidth: true) + .padding(16) + } + } else { + // 1–3 stages: fill the card width evenly, no scrolling. + stagesRow(fixedWidth: false) + .padding(16) + } + } + .background( + LinearGradient( + colors: [tint.opacity(0.45), tint.opacity(0.9)], + startPoint: .bottomLeading, + endPoint: .topTrailing + ) + ) + .clipShape(RoundedRectangle(cornerRadius: 20)) + .shadow(color: tint.opacity(0.3), radius: 8, y: 4) + } + + @ViewBuilder + private func stagesRow(fixedWidth: Bool) -> some View { + HStack(spacing: 8) { + ForEach(Array(family.stages.enumerated()), id: \.element.id) { index, stage in + if index > 0 { + Image(systemName: "chevron.right") + .font(.caption.weight(.bold)) + .foregroundStyle(.white.opacity(0.85)) + } + StageView(pokemon: stage) + .frame(maxWidth: fixedWidth ? nil : .infinity) + .frame(width: fixedWidth ? 104 : nil) + } + } + } +} + +/// A single evolution stage: Pokédex number, sprite on a frosted disc, name, type badges. +private struct StageView: View { + let pokemon: Pokemon + + var body: some View { + VStack(spacing: 6) { + Text(String(format: "#%03d", pokemon.id)) + .font(.pretendard(11, weight: .bold, relativeTo: .caption2)) + .foregroundStyle(.white.opacity(0.8)) + + AsyncImage(url: URL(string: pokemon.imageURL)) { image in + image.resizable().scaledToFit() + } placeholder: { + ProgressView() + .tint(.white) + } + .frame(width: 76, height: 76) + .padding(6) + .background(Circle().fill(.white.opacity(0.18))) + + Text(pokemon.name.capitalized) + .font(.pretendard(15, weight: .semibold, relativeTo: .subheadline)) + .foregroundStyle(.white) + .lineLimit(1) + .minimumScaleFactor(0.7) + + // Badges stack vertically so each chip stays on a single line. + VStack(spacing: 4) { + ForEach(pokemon.types, id: \.self) { type in + TypeBadge(type: type) + } + } + } + } +} + +/// A colored capsule showing a single type name (always one line). +private struct TypeBadge: View { + let type: String + + var body: some View { + Text(type.capitalized) + .font(.pretendard(10, weight: .bold, relativeTo: .caption2)) + .foregroundStyle(.white) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background( + Capsule() + .fill(PokemonTypeStyle.color(for: type)) + .overlay(Capsule().stroke(.white.opacity(0.5), lineWidth: 1)) + ) + } +} + +/// Pokéball-style loading indicator. +private struct LoadingView: View { + var body: some View { + VStack(spacing: 12) { + Image(systemName: "circle.circle.fill") + .font(.system(size: 44)) + .foregroundStyle(.red) + .symbolEffect(.pulse) + Text("Loading…") + .font(.pretendard(15, weight: .medium, relativeTo: .subheadline)) + .foregroundStyle(.secondary) + } + .padding(24) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16)) + } +} + +#Preview { + ContentView() +} diff --git a/pokemon/pokemon/View/Font+Pretendard.swift b/pokemon/pokemon/View/Font+Pretendard.swift new file mode 100644 index 0000000..2ab4179 --- /dev/null +++ b/pokemon/pokemon/View/Font+Pretendard.swift @@ -0,0 +1,35 @@ +import SwiftUI +import UIKit + +/// Family name registered by the Pretendard variable font. +private let pretendardFamily = "Pretendard Variable" +/// PostScript name of the font's default instance. +private let pretendardPostScript = "PretendardVariable-Regular" + +extension Font { + /// Pretendard font that scales with Dynamic Type. + /// - Parameters: + /// - size: point size at the default content size. + /// - weight: weight along the variable font's wght axis. + /// - textStyle: the text style to scale relative to. + static func pretendard( + _ size: CGFloat, + weight: Font.Weight = .regular, + relativeTo textStyle: Font.TextStyle = .body + ) -> Font { + .custom(pretendardFamily, size: size, relativeTo: textStyle).weight(weight) + } +} + +extension UIFont { + /// Pretendard `UIFont` (for UIKit-backed surfaces like the navigation bar). + static func pretendard(_ size: CGFloat, weight: UIFont.Weight = .regular) -> UIFont { + let base = UIFont(name: pretendardPostScript, size: size) + ?? .systemFont(ofSize: size, weight: weight) + // Drive the variable font's weight axis via the weight trait. + let descriptor = base.fontDescriptor.addingAttributes([ + .traits: [UIFontDescriptor.TraitKey.weight: weight] + ]) + return UIFont(descriptor: descriptor, size: size) + } +} diff --git a/pokemon/pokemon/View/PokemonTypeStyle.swift b/pokemon/pokemon/View/PokemonTypeStyle.swift new file mode 100644 index 0000000..1bdc466 --- /dev/null +++ b/pokemon/pokemon/View/PokemonTypeStyle.swift @@ -0,0 +1,46 @@ +import SwiftUI + +/// Official-ish Pokémon type colors and helpers for styling. +enum PokemonTypeStyle { + + /// Maps a type name (e.g. "grass") to its canonical color. + static func color(for type: String) -> Color { + switch type.lowercased() { + case "normal": return Color(hex: 0xA8A77A) + case "fire": return Color(hex: 0xEE8130) + case "water": return Color(hex: 0x6390F0) + case "electric": return Color(hex: 0xF7D02C) + case "grass": return Color(hex: 0x7AC74C) + case "ice": return Color(hex: 0x96D9D6) + case "fighting": return Color(hex: 0xC22E28) + case "poison": return Color(hex: 0xA33EA1) + case "ground": return Color(hex: 0xE2BF65) + case "flying": return Color(hex: 0xA98FF3) + case "psychic": return Color(hex: 0xF95587) + case "bug": return Color(hex: 0xA6B91A) + case "rock": return Color(hex: 0xB6A136) + case "ghost": return Color(hex: 0x735797) + case "dragon": return Color(hex: 0x6F35FC) + case "dark": return Color(hex: 0x705746) + case "steel": return Color(hex: 0xB7B7CE) + case "fairy": return Color(hex: 0xD685AD) + default: return Color(hex: 0xA8A77A) + } + } + + /// Primary color of a Pokémon (from its first type), used for tinting cards. + static func primaryColor(for pokemon: Pokemon) -> Color { + color(for: pokemon.types.first ?? "normal") + } +} + +extension Color { + /// Creates a color from a 0xRRGGBB hex literal. + init(hex: UInt) { + self.init( + red: Double((hex >> 16) & 0xFF) / 255, + green: Double((hex >> 8) & 0xFF) / 255, + blue: Double(hex & 0xFF) / 255 + ) + } +} diff --git a/pokemon/pokemon/ViewModel/PokemonViewModel.swift b/pokemon/pokemon/ViewModel/PokemonViewModel.swift new file mode 100644 index 0000000..c2dedc1 --- /dev/null +++ b/pokemon/pokemon/ViewModel/PokemonViewModel.swift @@ -0,0 +1,70 @@ +import Foundation +import Combine + +@MainActor +class PokemonViewModel: ObservableObject { + @Published var families: [PokemonFamily] = [] + @Published var isLoading: Bool = false + + /// Range of evolution-chain (family) ids to display. + private let chainIDs = Array(1...50) + + private let service: PokemonService + + init(service: PokemonService = PokemonServiceImpl()) { + self.service = service + } + + func loadFamilies() async { + isLoading = true + defer { isLoading = false } + + do { + let service = self.service + + // Outer TaskGroup: load multiple evolution families concurrently. + let loaded = try await withThrowingTaskGroup(of: PokemonFamily.self) { group in + for chainID in chainIDs { + group.addTask { + try await Self.loadFamily(chainID: chainID, service: service) + } + } + + var collected: [PokemonFamily] = [] + for try await family in group { + collected.append(family) + } + return collected + } + + // Families also arrive out of order, so sort by chain id. + families = loaded.sorted { $0.id < $1.id } + } catch { + families = [] + } + } + + /// Builds one evolution family: fetch the chain, then fetch each stage's detail concurrently. + private static func loadFamily(chainID: Int, service: PokemonService) async throws -> PokemonFamily { + // 1. Get the ids in evolution order from the chain. + let speciesIDs = try await service.fetchEvolutionChain(id: chainID) + + // 2. Inner TaskGroup: fetch each stage's detail concurrently. + let details = try await withThrowingTaskGroup(of: Pokemon.self) { group in + for sid in speciesIDs { + group.addTask { + try await service.fetchDetail(id: sid) + } + } + var byID: [Int: Pokemon] = [:] + for try await pokemon in group { + byID[pokemon.id] = pokemon + } + return byID + } + + // 3. Completion order is scrambled, so reorder by evolution order (speciesIDs). + let orderedStages = speciesIDs.compactMap { details[$0] } + return PokemonFamily(id: chainID, stages: orderedStages) + } +} diff --git a/pokemon/pokemon/pokemonApp.swift b/pokemon/pokemon/pokemonApp.swift new file mode 100644 index 0000000..ab6f759 --- /dev/null +++ b/pokemon/pokemon/pokemonApp.swift @@ -0,0 +1,44 @@ +// +// pokemonApp.swift +// pokemon +// +// Created by myone on 5/21/26. +// + +import SwiftUI +import UIKit +import CoreText + +@main +struct pokemonApp: App { + init() { + Self.registerPretendard() + Self.configureNavigationBar() + } + + var body: some Scene { + WindowGroup { + ContentView() + } + } + + /// Registers the bundled Pretendard variable font at runtime, + /// so no Info.plist (UIAppFonts) entry is required. + private static func registerPretendard() { + guard let url = Bundle.main.url(forResource: "PretendardVariable", withExtension: "ttf") else { + print("⚠️ PretendardVariable.ttf not found in bundle") + return + } + CTFontManagerRegisterFontsForURL(url as CFURL, .process, nil) + } + + /// Applies Pretendard to the navigation bar titles. + private static func configureNavigationBar() { + let appearance = UINavigationBarAppearance() + appearance.configureWithDefaultBackground() + appearance.largeTitleTextAttributes = [.font: UIFont.pretendard(34, weight: .bold)] + appearance.titleTextAttributes = [.font: UIFont.pretendard(17, weight: .semibold)] + UINavigationBar.appearance().standardAppearance = appearance + UINavigationBar.appearance().scrollEdgeAppearance = appearance + } +}