diff --git a/iosApp/AI_Prompt.md b/iosApp/AI_Prompt.md new file mode 100644 index 0000000..ac8de85 --- /dev/null +++ b/iosApp/AI_Prompt.md @@ -0,0 +1,171 @@ +# AI Prompts Log + +This document logs every prompt sent to an AI assistant during development of the iOS PokéDex app, along with what was accepted, changed, and why. + +--- + +## Prompt 1 + +> "I'm running `./gradlew :shared:assembleSharedXCFramework` but Gradle fails with _'Cannot locate tasks that match :shared:assembleSharedXCFramework'_. How do I build the shared XCFramework with the current KMP Gradle plugin?" + +**Used:** Yes — adopted the aggregated task names. + +**AI Response Summary:** +Modern Kotlin Multiplatform no longer exposes a single `assembleSharedXCFramework` task. It now generates configuration-specific variants: + +```bash +# Debug +./gradlew :shared:assembleSharedDebugXCFramework + +# Release +./gradlew :shared:assembleSharedReleaseXCFramework +``` + +Platform-specific tasks (e.g. `assembleDebugIosFatFrameworkForSharedXCFramework`) also exist but are unnecessary when the aggregated tasks are available. + +**Changed:** Skipped the platform-specific tasks — the aggregated approach is simpler and aligns with the CocoaPods integration used in this project. + +--- + +## Prompt 2 + +> "The Xcode build phase script fails with _'No such file or directory'_ when trying to run `gradlew`. The script uses `REPO_ROOT=\"$SRCROOT/..\"` and then calls `\"$REPO_ROOT/gradlew\"`. What's wrong?" + +**Used:** Partially — accepted the root-cause diagnosis and the permissions fix, rejected the absolute-path workaround. + +**AI Response Summary:** +Xcode's working directory during a build phase is `iosApp/`, so `$SRCROOT/..` must correctly resolve to the repository root where `gradlew` lives. Two things to verify: + +1. **`gradlew` exists at the repo root** — if the Gradle wrapper was never generated, run: + ```bash + gradle wrapper --gradle-version 8.7 + ``` +2. **`gradlew` is executable** — fix with: + ```bash + chmod +x ./gradlew + ``` + +The AI also suggested hard-coding an absolute path (`REPO_ROOT="/Users/.../Pokedex-assignment"`) as a fallback. + +**Changed:** Ignored the absolute-path suggestion to keep the project portable and repo-friendly. Only applied the wrapper generation and `chmod +x` fix. + +--- + +## Prompt 3 + +> "After fixing the `gradlew` path, the CocoaPods build phase now fails with _'Cannot locate tasks that match :shared:syncFramework as task syncFramework not found in project :shared'_. How do I update the script for the current KMP plugin?" + +**Used:** Yes — replaced the deprecated `syncFramework` invocation with the configuration-aware XCFramework task. + +**AI Response Summary:** +The `syncFramework` task was removed in newer KMP versions. The build-phase script should be updated from: + +```bash +"$REPO_ROOT/gradlew" -p "$REPO_ROOT" :shared:syncFramework \ + -Pkotlin.native.cocoapods.platform=$PLATFORM_NAME \ + -Pkotlin.native.cocoapods.archs="$ARCHS" \ + -Pkotlin.native.cocoapods.configuration="$CONFIGURATION" +``` + +To: + +```bash +"$REPO_ROOT/gradlew" -p "$REPO_ROOT" :shared:assembleShared${CONFIGURATION}XCFramework \ + -Pkotlin.native.cocoapods.platform=$PLATFORM_NAME \ + -Pkotlin.native.cocoapods.archs="$ARCHS" +``` + +`${CONFIGURATION}` resolves to `Debug` or `Release` at build time, so the task name itself encodes the build type and the `-Pkotlin.native.cocoapods.configuration` flag is no longer needed. + +**Changed:** Also removed the now-unnecessary `configuration` flag to keep the script clean. + +--- + +## Prompt 4 + +> "How do I initialise Koin from Swift and resolve `PokemonRepository`? The KMP shared module exposes `doInitKoin(driverFactory:enableNetworkLogs:)` but Koin's `get()` is an inline-reified function — calling it from Swift erases the type to `Any`. How can I get a typed reference to the repository?" + +**Used:** Yes — built the `KoinDependencies` singleton based on the suggested approach. + +**AI Response Summary:** +Because Kotlin's `inline reified` generics are erased when called from Swift, you cannot use `koin.get()` directly. Two approaches: + +1. **Walk the instance registry** — iterate `koin.instanceRegistry.instances`, match on the bean's `primaryType.qualifiedName`, then call the class-based `koin.get(clazz:qualifier:parameters:)` overload. +2. **Add a helper in the shared module** — expose a plain Kotlin function like `fun getPokemonRepository(koin: Koin): PokemonRepository = koin.get()` so the reified call happens on the Kotlin side. + +The AI also recommended capturing the `Koin` instance during initialisation via the `appDeclaration` closure: + +```swift +var koinRef: Koin_coreKoin! + +ModulesKt.doInitKoin( + driverFactory: DatabaseDriverFactory(), + enableNetworkLogs: true, + appDeclaration: { app in + koinRef = app.koin + } +) +``` + +**Changed:** Went with Option 1 (registry walk) since the assignment says _"Do not modify anything inside `shared/`"_. Wrapped everything in a `KoinDependencies` singleton so Koin is initialised exactly once at app launch and the repository is available via `KoinDependencies.shared.repository`. + +--- + +## Prompt 5 + +> "How do I observe a KMP `StateFlow` from SwiftUI? The shared ViewModels expose `val state: StateFlow` but SwiftUI doesn't natively understand Kotlin coroutines. I can't use KMP-NativeCoroutines — what's the manual approach?" + +**Used:** Yes — implemented the `FlowCollector` class and the `observe(as:onChange:)` extension. + +**AI Response Summary:** +Kotlin's `Flow.collect()` is a suspend function that expects a `FlowCollector` protocol implementation. From Swift you can bridge this manually: + +1. **Create a `FlowCollector`** that conforms to `Kotlinx_coroutines_coreFlowCollector`. Its `emit(value:completionHandler:)` method receives each value; calling `completionHandler(nil)` resumes the coroutine for the next emission: + +```swift +final class FlowCollector: NSObject, Kotlinx_coroutines_coreFlowCollector { + private let onEmit: (Any?) -> Void + + init(_ onEmit: @escaping (Any?) -> Void) { + self.onEmit = onEmit + } + + func emit(value: Any?, completionHandler: @escaping (Error?) -> Void) { + onEmit(value) + completionHandler(nil) + } +} +``` + +2. **Add a typed convenience extension** on `Kotlinx_coroutines_coreStateFlow` that creates a collector, casts the emitted value to the expected type, and dispatches to the main queue: + +```swift +extension Kotlinx_coroutines_coreStateFlow { + func observe(as type: T.Type, onChange: @escaping (T) -> Void) { + let collector = FlowCollector { value in + if let typed = value as? T { + DispatchQueue.main.async { onChange(typed) } + } + } + self.collect(collector: collector) { _ in } + } +} +``` + +3. **In each `ObservableObject` store**, call `viewModel.state.observe(as:onChange:)` during init. Use `[weak self]` in the closure to avoid retain cycles. Call `viewModel.onCleared()` in `deinit` to cancel the underlying coroutine scope — this is the only way to stop collection since `StateFlow.collect` never completes on its own. + +The AI also suggested an alternative `AsyncStream`-based bridge using Swift concurrency, but noted it requires more boilerplate for cancellation handling. + +**Changed:** Chose the callback-based approach over `AsyncStream` — it's simpler, has no `Task` lifecycle to manage, and works cleanly with `@Published` properties. Added `DispatchQueue.main.async` inside the collector to guarantee UI updates happen on the main thread. Also loaded the initial `StateFlow` value synchronously via `viewModel.state.value` before starting observation to avoid a blank frame on first render. + +--- + +## Outcome + +All three build issues (Prompts 1–3) stemmed from the KMP Gradle plugin version moving past the APIs in the original template. Prompts 4–5 covered the core KMP ↔ Swift integration layer: + +- XCFramework builds successfully for both Debug and Release +- CocoaPods integration works correctly with the Xcode build phase +- Koin initialisation and dependency resolution works from Swift without modifying the shared module +- KMP `StateFlow` is observable from SwiftUI via a lightweight `FlowCollector` bridge +- The project is aligned with modern KMP + CocoaPods practices \ No newline at end of file diff --git a/iosApp/Podfile b/iosApp/Podfile new file mode 100644 index 0000000..8346545 --- /dev/null +++ b/iosApp/Podfile @@ -0,0 +1,11 @@ +# Uncomment the next line to define a global platform for your project +platform :ios, '16.0' + +target 'iosApp' do + # Comment the next line if you don't want to use dynamic frameworks + use_frameworks! + + # Pods for iosApp + pod 'shared', :path => '../shared' + +end diff --git a/iosApp/iosApp/App/iOSApp.swift b/iosApp/iosApp/App/iOSApp.swift new file mode 100644 index 0000000..2ddbcbd --- /dev/null +++ b/iosApp/iosApp/App/iOSApp.swift @@ -0,0 +1,17 @@ +import SwiftUI +import shared + +@main +struct iOSApp: App { + + init() { + // Triggers Koin initialisation and repository resolution once. + _ = KoinDependencies.shared + } + + var body: some Scene { + WindowGroup { + RootTabView() + } + } +} diff --git a/iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json b/iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..2305880 --- /dev/null +++ b/iosApp/iosApp/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/iosApp/iosApp/Assets.xcassets/Contents.json b/iosApp/iosApp/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/iosApp/iosApp/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iosApp/iosApp/Components/PokemonGridCell.swift b/iosApp/iosApp/Components/PokemonGridCell.swift new file mode 100644 index 0000000..2cfcb11 --- /dev/null +++ b/iosApp/iosApp/Components/PokemonGridCell.swift @@ -0,0 +1,42 @@ +import SwiftUI +import shared + +struct PokemonGridCell: View { + + let pokemon: Pokemon + + var body: some View { + VStack(spacing: 8) { + AsyncImage(url: URL(string: pokemon.imageUrl)) { phase in + switch phase { + case .success(let image): + image + .resizable() + .scaledToFit() + case .failure: + Image(systemName: "photo") + .font(.largeTitle) + .foregroundStyle(.secondary) + default: + ProgressView() + } + } + .frame(width: 100, height: 100) + + Text(PokemonFormatter.displayName(pokemon.name)) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.primary) + .lineLimit(1) + + Text(PokemonFormatter.formattedId(pokemon.id)) + .font(.caption) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .padding(.horizontal, 8) + .background(Color.cardBg) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .shadow(color: .black.opacity(0.08), radius: 4, x: 0, y: 2) + } +} diff --git a/iosApp/iosApp/Components/PokemonImageView.swift b/iosApp/iosApp/Components/PokemonImageView.swift new file mode 100644 index 0000000..5e9b13c --- /dev/null +++ b/iosApp/iosApp/Components/PokemonImageView.swift @@ -0,0 +1,49 @@ +import SwiftUI + +struct PokemonImageView: View { + let url: String + + @State private var uiImage: UIImage? + @State private var failed = false + + var body: some View { + Group { + if let uiImage { + Image(uiImage: uiImage) + .resizable() + .scaledToFit() + } else if failed { + Image(systemName: "photo") + .foregroundStyle(.secondary) + } else { + ProgressView() + } + } + .task(id: url) { + await loadImage() + } + } + + private func loadImage() async { + uiImage = nil + failed = false + + guard let imageURL = URL(string: url) else { + failed = true + return + } + + do { + let (data, _) = try await URLSession.shared.data(from: imageURL) + if let image = UIImage(data: data) { + uiImage = image + } else { + failed = true + } + } catch { + if !Task.isCancelled { + failed = true + } + } + } +} diff --git a/iosApp/iosApp/Components/StatBarView.swift b/iosApp/iosApp/Components/StatBarView.swift new file mode 100644 index 0000000..7d7dd91 --- /dev/null +++ b/iosApp/iosApp/Components/StatBarView.swift @@ -0,0 +1,24 @@ +import SwiftUI + +struct StatBarView: View { + + let label: String + let value: Int32 + + var body: some View { + HStack(spacing: 12) { + Text(label.capitalized) + .font(.caption2.weight(.bold)) + .foregroundStyle(.secondary) + .frame(width: 80, alignment: .leading) + + Text("\(value)") + .font(.caption.weight(.semibold).monospacedDigit()) + .frame(width: 32, alignment: .leading) + + ProgressView(value: Double(value)/100.0) + .progressViewStyle(.linear) + .tint(StatColor.color(for: value)) + } + } +} diff --git a/iosApp/iosApp/Components/TypeChipView.swift b/iosApp/iosApp/Components/TypeChipView.swift new file mode 100644 index 0000000..53d9f25 --- /dev/null +++ b/iosApp/iosApp/Components/TypeChipView.swift @@ -0,0 +1,22 @@ +import SwiftUI + +struct TypeChipView: View { + + let type: String + + var body: some View { + Text(type.capitalized) + .font(.caption.weight(.semibold)) + .foregroundStyle(PokemonTypeColor.color(for: type)) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background( + Capsule() + .stroke(PokemonTypeColor.color(for: type).opacity(0.8), lineWidth: 1.5) + .background( + Capsule() + .fill(PokemonTypeColor.color(for: type).opacity(0.12)) + ) + ) + } +} diff --git a/iosApp/iosApp/Helpers/FlowCollector.swift b/iosApp/iosApp/Helpers/FlowCollector.swift new file mode 100644 index 0000000..1e1287e --- /dev/null +++ b/iosApp/iosApp/Helpers/FlowCollector.swift @@ -0,0 +1,37 @@ +import shared + +/// Bridges a KMP `StateFlow` / `Flow` into a Swift callback. +/// +/// Kotlin's `FlowCollector` protocol requires an `emit(value:completionHandler:)` +/// method. Each call to `completionHandler(nil)` resumes the coroutine so the +/// next value can be emitted. +final class FlowCollector: NSObject, Kotlinx_coroutines_coreFlowCollector { + + private let onEmit: (Any?) -> Void + + init(_ onEmit: @escaping (Any?) -> Void) { + self.onEmit = onEmit + } + + func emit(value: Any?, completionHandler: @escaping (Error?) -> Void) { + onEmit(value) + completionHandler(nil) + } +} + +extension Kotlinx_coroutines_coreStateFlow { + + /// Starts collecting the `StateFlow` and dispatches every new value to the + /// main queue via `onChange`. The collection never completes for a + /// `StateFlow`; call `viewModel.onCleared()` to stop emissions. + func observe(as type: T.Type, onChange: @escaping (T) -> Void) { + let collector = FlowCollector { value in + if let typed = value as? T { + DispatchQueue.main.async { onChange(typed) } + } + } + // `collect` is a suspend function that never returns for StateFlow. + // The completionHandler is only invoked on cancellation / error. + self.collect(collector: collector) { _ in } + } +} diff --git a/iosApp/iosApp/Helpers/KoinDependencies.swift b/iosApp/iosApp/Helpers/KoinDependencies.swift new file mode 100644 index 0000000..470f680 --- /dev/null +++ b/iosApp/iosApp/Helpers/KoinDependencies.swift @@ -0,0 +1,55 @@ +import shared + +/// Singleton that initialises Koin and exposes the `PokemonRepository` +/// resolved from the DI container. +/// +/// Usage: `KoinDependencies.shared.repository` +final class KoinDependencies { + + static let shared = KoinDependencies() + + let repository: PokemonRepository + + private let koin: Koin_coreKoin + + private init() { + var koinRef: Koin_coreKoin! + + ModulesKt.doInitKoin( + driverFactory: DatabaseDriverFactory(), + enableNetworkLogs: true, + appDeclaration: { app in + koinRef = app.koin + } + ) + + guard let koinInstance = koinRef else { + fatalError("Koin failed to initialise") + } + self.koin = koinInstance + + // Resolve PokemonRepository by finding its KClass from Koin's + // instance registry (the inline-reified `get()` erases to Any + // when called from Swift, so we use the class-based overload). + let instances = koin.instanceRegistry.instances + var repoKClass: KotlinKClass? + + for (_, factory) in instances { + let primaryType = factory.beanDefinition.primaryType + if primaryType.qualifiedName == + "com.assignment.pokemon.data.repository.PokemonRepository" { + repoKClass = primaryType + break + } + } + + guard let kclass = repoKClass, + let repo = koin.get(clazz: kclass, qualifier: nil, parameters: nil) + as? PokemonRepository + else { + fatalError("PokemonRepository not found in Koin") + } + + self.repository = repo + } +} diff --git a/iosApp/iosApp/Helpers/PokemonFormatter.swift b/iosApp/iosApp/Helpers/PokemonFormatter.swift new file mode 100644 index 0000000..40696dc --- /dev/null +++ b/iosApp/iosApp/Helpers/PokemonFormatter.swift @@ -0,0 +1,14 @@ +import Foundation + +/// Shared presentation formatters used by ViewModels and Components. +/// Keeps formatting logic out of Views. +enum PokemonFormatter { + + static func formattedId(_ id: Int32) -> String { + String(format: "#%03d", id) + } + + static func displayName(_ name: String) -> String { + name.capitalized + } +} diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist new file mode 100644 index 0000000..11845e1 --- /dev/null +++ b/iosApp/iosApp/Info.plist @@ -0,0 +1,8 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + + diff --git a/iosApp/iosApp/Preview Content/Preview Assets.xcassets/Contents.json b/iosApp/iosApp/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/iosApp/iosApp/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iosApp/iosApp/Theme/PokemonTypeColor.swift b/iosApp/iosApp/Theme/PokemonTypeColor.swift new file mode 100644 index 0000000..199540a --- /dev/null +++ b/iosApp/iosApp/Theme/PokemonTypeColor.swift @@ -0,0 +1,53 @@ +import SwiftUI + +// MARK: - App Theme Colors + +extension Color { + static let pokeBallRed = Color(red: 0.86, green: 0.21, blue: 0.27) + static let pokeBallDark = Color(red: 0.17, green: 0.17, blue: 0.17) + static let cardBg = Color(.systemBackground) + static let screenBg = Color(.systemGroupedBackground) +} + +// MARK: - Pokémon Type → Color mapping + +enum PokemonTypeColor { + + static func color(for type: String) -> Color { + switch type.lowercased() { + case "normal": return Color(red: 0.66, green: 0.65, blue: 0.48) + case "fire": return Color(red: 0.93, green: 0.51, blue: 0.19) + case "water": return Color(red: 0.39, green: 0.56, blue: 0.94) + case "electric": return Color(red: 0.97, green: 0.82, blue: 0.17) + case "grass": return Color(red: 0.47, green: 0.78, blue: 0.30) + case "ice": return Color(red: 0.59, green: 0.85, blue: 0.84) + case "fighting": return Color(red: 0.76, green: 0.18, blue: 0.16) + case "poison": return Color(red: 0.63, green: 0.24, blue: 0.63) + case "ground": return Color(red: 0.89, green: 0.75, blue: 0.40) + case "flying": return Color(red: 0.66, green: 0.56, blue: 0.95) + case "psychic": return Color(red: 0.98, green: 0.33, blue: 0.53) + case "bug": return Color(red: 0.65, green: 0.73, blue: 0.10) + case "rock": return Color(red: 0.71, green: 0.63, blue: 0.21) + case "ghost": return Color(red: 0.45, green: 0.34, blue: 0.60) + case "dragon": return Color(red: 0.44, green: 0.21, blue: 0.99) + case "dark": return Color(red: 0.44, green: 0.34, blue: 0.27) + case "steel": return Color(red: 0.72, green: 0.72, blue: 0.82) + case "fairy": return Color(red: 0.84, green: 0.52, blue: 0.68) + default: return .gray + } + } +} + +// MARK: - Stat bar colour + +enum StatColor { + + static func color(for value: Int32) -> Color { + switch value { + case ..<50: return .red + case ..<80: return .orange + case ..<100: return .yellow + default: return .green + } + } +} diff --git a/iosApp/iosApp/ViewModels/FavoritesStore.swift b/iosApp/iosApp/ViewModels/FavoritesStore.swift new file mode 100644 index 0000000..a253218 --- /dev/null +++ b/iosApp/iosApp/ViewModels/FavoritesStore.swift @@ -0,0 +1,37 @@ +import shared + +/// ObservableObject wrapper around the KMP `FavoritesViewModel`. +/// Publishes a live list of favourite Pokémon backed by the local database. +/// +@MainActor +final class FavoritesStore: ObservableObject { + + @Published private(set) var favorites: [Pokemon] = [] + + private let viewModel: FavoritesViewModel + + init(repository: PokemonRepository = KoinDependencies.shared.repository) { + viewModel = FavoritesViewModel(repository: repository) + loadFavorites() + observeFavorites() + } + + private func loadFavorites() { + if let fav = viewModel.favorites.value as? [Pokemon] { + favorites = fav + } + } + + private func observeFavorites() { + viewModel.favorites.observe(as: [Pokemon].self) { [weak self] list in + for p in list { + print("⭐ FAV: id=\(p.id) name=\(p.name) imageUrl=\(p.imageUrl)") + } + self?.favorites = list + } + } + + deinit { + viewModel.onCleared() + } +} diff --git a/iosApp/iosApp/ViewModels/PokemonDetailStore.swift b/iosApp/iosApp/ViewModels/PokemonDetailStore.swift new file mode 100644 index 0000000..a980cef --- /dev/null +++ b/iosApp/iosApp/ViewModels/PokemonDetailStore.swift @@ -0,0 +1,65 @@ +import Foundation +import shared + +/// ObservableObject wrapper around the KMP `PokemonDetailViewModel`. +/// All presentation / formatting logic lives here — views only read & render. +@MainActor +final class PokemonDetailStore: ObservableObject { + + @Published private(set) var state: PokemonDetailState = PokemonDetailState.Loading() + + private let viewModel: PokemonDetailViewModel + + init(pokemonName: String, repository: PokemonRepository = KoinDependencies.shared.repository) { + viewModel = PokemonDetailViewModel(pokemonName: pokemonName, repository: repository) + loadInitialState() + observeState() + } + + + private func loadInitialState() { + if let initial = viewModel.state.value as? PokemonDetailState { + state = initial + } + } + + private func observeState() { + viewModel.state.observe(as: PokemonDetailState.self) { [weak self] newState in + self?.state = newState + } + } + + + func toggleFavorite() { viewModel.toggleFavorite() } + func retry() { viewModel.retry() } + + func formattedId(_ id: Int32) -> String { + PokemonFormatter.formattedId(id) + } + + func formattedHeight(_ raw: Int32) -> String { + String(format: "%.1f m", Double(raw) / 10.0) + } + + func formattedWeight(_ raw: Int32) -> String { + String(format: "%.1f kg", Double(raw) / 10.0) + } + + func shortStatName(_ name: String) -> String { + switch name.lowercased() { + case "hp": return "HP" + case "attack": return name + case "defense": return name + case "special-attack": return "Sp. Attack" + case "special-defense": return "Sp. Defence" + case "speed": return name + default: return String(name.prefix(3)).uppercased() + } + } + + func formattedAbility(_ ability: String) -> String { + ability.replacingOccurrences(of: "-", with: " ").capitalized + } + + deinit { viewModel.onCleared() } +} diff --git a/iosApp/iosApp/ViewModels/PokemonListStore.swift b/iosApp/iosApp/ViewModels/PokemonListStore.swift new file mode 100644 index 0000000..5c20244 --- /dev/null +++ b/iosApp/iosApp/ViewModels/PokemonListStore.swift @@ -0,0 +1,44 @@ +import shared + +/// ObservableObject wrapper around the KMP `PokemonListViewModel`. +/// All pagination and search logic lives here +@MainActor +final class PokemonListStore: ObservableObject { + + @Published private(set) var state: PokemonListState = PokemonListState.Loading() + + private static let loadMoreThreshold = 3 + + private let viewModel: PokemonListViewModel + + init(repository: PokemonRepository = KoinDependencies.shared.repository) { + viewModel = PokemonListViewModel(repository: repository) + loadInitialState() + observeState() + } + + private func loadInitialState() { + if let initial = viewModel.state.value as? PokemonListState { + state = initial + } + } + + private func observeState() { + viewModel.state.observe(as: PokemonListState.self) { [weak self] newState in + self?.state = newState + } + } + + + /// Triggers the next page load when approaching the end of the list. + func onItemAppeared(index: Int, totalCount: Int, canLoadMore: Bool) { + if index >= totalCount - Self.loadMoreThreshold && canLoadMore { + viewModel.loadNextPage() + } + } + + func search(_ query: String) { viewModel.search(query: query) } + func refresh() { viewModel.refresh() } + + deinit { viewModel.onCleared() } +} diff --git a/iosApp/iosApp/Views/Favourites/Components/FavouriteEmpty.swift b/iosApp/iosApp/Views/Favourites/Components/FavouriteEmpty.swift new file mode 100644 index 0000000..20601ea --- /dev/null +++ b/iosApp/iosApp/Views/Favourites/Components/FavouriteEmpty.swift @@ -0,0 +1,28 @@ +// +// FavouriteEmpty.swift +// iosApp +// +// Created by vishwas on 4/3/26. +// + +import SwiftUI + +struct FavoritesEmptyStateView: View { + + var body: some View { + VStack(spacing: 12) { + Image(systemName: "heart.slash") + .font(.system(size: 48)) + .foregroundStyle(.secondary) + + Text("No Favourites Yet") + .font(.title3.weight(.semibold)) + + Text("Tap the heart on a Pokémon's\ndetail page to add it here.") + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} diff --git a/iosApp/iosApp/Views/Favourites/Components/FavouriteRow.swift b/iosApp/iosApp/Views/Favourites/Components/FavouriteRow.swift new file mode 100644 index 0000000..12f3efe --- /dev/null +++ b/iosApp/iosApp/Views/Favourites/Components/FavouriteRow.swift @@ -0,0 +1,43 @@ +// +// FavouriteRow.swift +// iosApp +// +// Created by vishwas on 4/3/26. +// +import SwiftUI +import shared + +struct FavoriteRowView: View { + let pokemon: Pokemon + + var body: some View { + HStack(spacing: 14) { + PokemonImageView(url: pokemon.imageUrl) + .frame(width: 56, height: 56) + .background(Color(.systemGray6)) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + + VStack(alignment: .leading, spacing: 2) { + Text(PokemonFormatter.displayName(pokemon.name)) + .font(.body.weight(.semibold)) + + Text(PokemonFormatter.formattedId(pokemon.id)) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + Image(systemName: "heart.fill") + .foregroundStyle(.red) + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(Color.cardBg) + ) + .padding(.horizontal) + .padding(.vertical, 4) + .shadow(color: .black.opacity(0.04), radius: 4, y: 2) + } +} diff --git a/iosApp/iosApp/Views/Favourites/FavoritesView.swift b/iosApp/iosApp/Views/Favourites/FavoritesView.swift new file mode 100644 index 0000000..32d6bf0 --- /dev/null +++ b/iosApp/iosApp/Views/Favourites/FavoritesView.swift @@ -0,0 +1,39 @@ +// +// FavoritesView.swift +// iosApp +// +// Created by vishwas on 4/2/26. +// + + +import SwiftUI +import shared + +struct FavoritesView: View { + + @StateObject private var store = FavoritesStore() + + var body: some View { + ZStack { + Color.screenBg.ignoresSafeArea() + + if store.favorites.isEmpty { + FavoritesEmptyStateView() + } else { + ScrollView { + LazyVStack(spacing: 8) { + ForEach(store.favorites, id: \.id) { pokemon in + NavigationLink(value: pokemon.name) { + FavoriteRowView(pokemon: pokemon) + } + .buttonStyle(.plain) + } + } + .padding(.top, 8) + .padding(.bottom, 16) + } + } + } + .navigationTitle("Favourites") + } +} diff --git a/iosApp/iosApp/Views/PokemonDetail/Components/PokemonAbilities.swift b/iosApp/iosApp/Views/PokemonDetail/Components/PokemonAbilities.swift new file mode 100644 index 0000000..2d5ebe6 --- /dev/null +++ b/iosApp/iosApp/Views/PokemonDetail/Components/PokemonAbilities.swift @@ -0,0 +1,38 @@ +// +// PokemonAbilities.swift +// iosApp +// +// Created by vishwas on 4/3/26. +// +import SwiftUI +import shared + +struct PokemonAbilitiesSection: View { + let detail: PokemonDetail + let store: PokemonDetailStore + + private let columns = [ + GridItem(.adaptive(minimum: 100), spacing: 8) + ] + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Abilities") + .font(.headline) + .padding(.horizontal) + + LazyVGrid(columns: columns, alignment: .leading, spacing: 8) { + ForEach(detail.abilities, id: \.self) { ability in + Text(store.formattedAbility(ability)) + .font(.subheadline) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background(Color(.systemGray5)) + .clipShape(Capsule()) + } + } + .padding(.horizontal) + } + .padding(.bottom, 32) + } +} diff --git a/iosApp/iosApp/Views/PokemonDetail/Components/PokemonError.swift b/iosApp/iosApp/Views/PokemonDetail/Components/PokemonError.swift new file mode 100644 index 0000000..a6480bc --- /dev/null +++ b/iosApp/iosApp/Views/PokemonDetail/Components/PokemonError.swift @@ -0,0 +1,33 @@ +// +// PokemonError.swift +// iosApp +// +// Created by vishwas on 4/3/26. +// + +import SwiftUI +import shared + +struct PokemonErrorView: View { + let message: String + let onRetry: () -> Void + + var body: some View { + VStack(spacing: 16) { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 48)) + .foregroundStyle(.secondary) + + Text(message) + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + + Button("Retry", action: onRetry) + .buttonStyle(.borderedProminent) + .tint(.pokeBallRed) + } + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} diff --git a/iosApp/iosApp/Views/PokemonDetail/Components/PokemonHeader.swift b/iosApp/iosApp/Views/PokemonDetail/Components/PokemonHeader.swift new file mode 100644 index 0000000..541bcee --- /dev/null +++ b/iosApp/iosApp/Views/PokemonDetail/Components/PokemonHeader.swift @@ -0,0 +1,54 @@ +// +// PokemonHeaderView.swift +// iosApp +// +// Created by vishwas on 4/3/26. +// + +import SwiftUI +import shared + +struct PokemonHeaderView: View { + let detail: PokemonDetail + let store: PokemonDetailStore + + var body: some View { + VStack(spacing: 12) { + let bgColor = detail.types.first + .map { PokemonTypeColor.color(for: $0) } ?? .gray + + VStack { + AsyncImage(url: URL(string: detail.imageUrl)) { phase in + switch phase { + case .success(let image): + image.resizable().scaledToFit().frame(height: 200) + + case .failure: + Image(systemName: "photo") + .font(.system(size: 60)) + .foregroundStyle(.secondary) + + default: + ProgressView() + } + } + } + .frame(maxWidth: .infinity) + .frame(height: 260) + .background(bgColor.opacity(0.15)) + .clipShape(RoundedRectangle(cornerRadius: 25)) + .padding() + + Text(store.formattedId(detail.id)) + .font(.subheadline.weight(.medium)) + .foregroundStyle(.secondary) + + HStack(spacing: 8) { + ForEach(detail.types, id: \.self) { + TypeChipView(type: $0) + } + } + } + .padding(.bottom, 16) + } +} diff --git a/iosApp/iosApp/Views/PokemonDetail/Components/PokemonInfo.swift b/iosApp/iosApp/Views/PokemonDetail/Components/PokemonInfo.swift new file mode 100644 index 0000000..8896950 --- /dev/null +++ b/iosApp/iosApp/Views/PokemonDetail/Components/PokemonInfo.swift @@ -0,0 +1,39 @@ +// +// PokemonInfo.swift +// iosApp +// +// Created by vishwas on 4/3/26. +// + +import SwiftUI +import shared + +struct PokemonInfoSection: View { + let detail: PokemonDetail + let store: PokemonDetailStore + + var body: some View { + HStack(spacing: 0) { + infoTile(title: "Height", value: store.formattedHeight(detail.height)) + Divider().frame(height: 40) + infoTile(title: "Weight", value: store.formattedWeight(detail.weight)) + } + .padding() + .background(Color.cardBg) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .padding(.horizontal) + .padding(.bottom, 16) + } + + private func infoTile(title: String, value: String) -> some View { + VStack(spacing: 4) { + Text(title) + .font(.caption) + .foregroundStyle(.secondary) + + Text(value) + .font(.title3.weight(.semibold)) + } + .frame(maxWidth: .infinity) + } +} diff --git a/iosApp/iosApp/Views/PokemonDetail/Components/PokemonStats.swift b/iosApp/iosApp/Views/PokemonDetail/Components/PokemonStats.swift new file mode 100644 index 0000000..0f06163 --- /dev/null +++ b/iosApp/iosApp/Views/PokemonDetail/Components/PokemonStats.swift @@ -0,0 +1,36 @@ +// +// PokemonStats.swift +// iosApp +// +// Created by vishwas on 4/3/26. +// + +import SwiftUI +import shared + +struct PokemonStatsSection: View { + let detail: PokemonDetail + let store: PokemonDetailStore + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Base Stats") + .font(.headline) + .padding(.horizontal) + + VStack(spacing: 6) { + ForEach(detail.stats, id: \.name) { stat in + StatBarView( + label: store.shortStatName(stat.name), + value: stat.value + ) + } + } + .padding() + .background(Color.cardBg) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .padding(.horizontal) + } + .padding(.bottom, 16) + } +} diff --git a/iosApp/iosApp/Views/PokemonDetail/PokemonDetailView.swift b/iosApp/iosApp/Views/PokemonDetail/PokemonDetailView.swift new file mode 100644 index 0000000..4c84b3f --- /dev/null +++ b/iosApp/iosApp/Views/PokemonDetail/PokemonDetailView.swift @@ -0,0 +1,63 @@ +import SwiftUI +import shared + +/// Full detail screen for a single Pokémon. +/// All formatting is delegated to `PokemonDetailStore` — this view only renders. +struct PokemonDetailView: View { + + @StateObject private var store: PokemonDetailStore + + init(pokemonName: String) { + _store = StateObject(wrappedValue: PokemonDetailStore(pokemonName: pokemonName)) + } + + var body: some View { + ZStack { + Color.screenBg.ignoresSafeArea() + Group { + switch store.state { + case is PokemonDetailState.Loading: + ProgressView("Loading…") + .frame(maxWidth: .infinity, maxHeight: .infinity) + + case let success as PokemonDetailState.Success: + content(success.pokemon) + + case let error as PokemonDetailState.Error: + PokemonErrorView( + message: error.message, + onRetry: store.retry + ) + + default: + EmptyView() + } + } + } + .navigationBarTitleDisplayMode(.inline) + .toolbar(.hidden, for: .tabBar) + } + + private func content(_ detail: PokemonDetail) -> some View { + ScrollView { + VStack(spacing: 0) { + PokemonHeaderView(detail: detail, store: store) + PokemonInfoSection(detail: detail, store: store) + PokemonStatsSection(detail: detail, store: store) + PokemonAbilitiesSection(detail: detail, store: store) + } + } + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + store.toggleFavorite() + } label: { + Image(systemName: detail.isFavorite ? "heart.fill" : "heart") + .foregroundStyle(detail.isFavorite ? .red : .secondary) + .font(.title3) + } + } + } + .navigationTitle(detail.name.capitalized) + } +} diff --git a/iosApp/iosApp/Views/PokemonList/Components/PokemonEmptyState.swift b/iosApp/iosApp/Views/PokemonList/Components/PokemonEmptyState.swift new file mode 100644 index 0000000..068d415 --- /dev/null +++ b/iosApp/iosApp/Views/PokemonList/Components/PokemonEmptyState.swift @@ -0,0 +1,27 @@ +// +// EmptyState.swift +// iosApp +// +// Created by vishwas on 4/3/26. +// + +import SwiftUI +import shared + +struct PokemonEmptyStateView: View { + var body: some View { + VStack(spacing: 12) { + Image(systemName: "magnifyingglass") + .font(.system(size: 40)) + .foregroundStyle(.secondary) + + Text("No Pokémon found") + .font(.title3.weight(.semibold)) + + Text("Try a different search term.") + .font(.subheadline) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} diff --git a/iosApp/iosApp/Views/PokemonList/Components/PokemonGrid.swift b/iosApp/iosApp/Views/PokemonList/Components/PokemonGrid.swift new file mode 100644 index 0000000..9158444 --- /dev/null +++ b/iosApp/iosApp/Views/PokemonList/Components/PokemonGrid.swift @@ -0,0 +1,49 @@ +// +// PokemonGrid.swift +// iosApp +// +// Created by vishwas on 4/3/26. +// + +import SwiftUI +import shared + +struct PokemonGridView: View { + let success: PokemonListState.Success + let columns: [GridItem] + let store: PokemonListStore + + var body: some View { + let pokemonList = success.pokemon + + if pokemonList.isEmpty { + PokemonEmptyStateView() + } else { + ScrollView(showsIndicators: false) { + LazyVGrid(columns: columns, spacing: 12) { + ForEach(Array(pokemonList.enumerated()), id: \.element.id) { index, pokemon in + NavigationLink(value: pokemon.name) { + PokemonGridCell(pokemon: pokemon) + } + .buttonStyle(.plain) + .onAppear { + store.onItemAppeared( + index: index, + totalCount: pokemonList.count, + canLoadMore: success.canLoadMore + ) + } + } + } + .padding(.horizontal, 12) + .padding(.top, 8) + + if success.isLoadingMore { + ProgressView() + .padding(.vertical, 16) + } + } + .refreshable { store.refresh() } + } + } +} diff --git a/iosApp/iosApp/Views/PokemonList/Components/PokemonListError.swift b/iosApp/iosApp/Views/PokemonList/Components/PokemonListError.swift new file mode 100644 index 0000000..ceb703c --- /dev/null +++ b/iosApp/iosApp/Views/PokemonList/Components/PokemonListError.swift @@ -0,0 +1,32 @@ +// +// PokemonListError.swift +// iosApp +// +// Created by vishwas on 4/3/26. +// +import SwiftUI +import shared + +struct PokemonListErrorView: View { + let message: String + let onRetry: () -> Void + + var body: some View { + VStack(spacing: 16) { + Image(systemName: "wifi.exclamationmark") + .font(.system(size: 48)) + .foregroundStyle(.secondary) + + Text(message) + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + + Button("Retry", action: onRetry) + .buttonStyle(.borderedProminent) + .tint(.pokeBallRed) + } + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} diff --git a/iosApp/iosApp/Views/PokemonList/PokemonListView.swift b/iosApp/iosApp/Views/PokemonList/PokemonListView.swift new file mode 100644 index 0000000..bf026a4 --- /dev/null +++ b/iosApp/iosApp/Views/PokemonList/PokemonListView.swift @@ -0,0 +1,52 @@ +import SwiftUI +import shared + +struct PokemonListView: View { + + @StateObject private var store = PokemonListStore() + @State private var searchText = "" + + private let columns = [ + GridItem(.flexible(), spacing: 12), + GridItem(.flexible(), spacing: 12) + ] + + var body: some View { + ZStack { + Color.screenBg.ignoresSafeArea() + + Group { + switch store.state { + case is PokemonListState.Loading: + ProgressView("Loading Pokémon…") + .frame(maxWidth: .infinity, maxHeight: .infinity) + + case let success as PokemonListState.Success: + PokemonGridView( + success: success, + columns: columns, + store: store + ) + + case let error as PokemonListState.Error: + PokemonListErrorView( + message: error.message, + onRetry: store.refresh + ) + + default: + EmptyView() + } + } + } + .navigationTitle("Pokédex") + .searchable( + text: $searchText, + placement: .navigationBarDrawer(displayMode: .always), + prompt: "Search Pokémon" + ) + .onChange(of: searchText) { newValue in + store.search(newValue) + } + } +} diff --git a/iosApp/iosApp/Views/Tab/RootTabView.swift b/iosApp/iosApp/Views/Tab/RootTabView.swift new file mode 100644 index 0000000..3168d84 --- /dev/null +++ b/iosApp/iosApp/Views/Tab/RootTabView.swift @@ -0,0 +1,29 @@ +import SwiftUI + +struct RootTabView: View { + + var body: some View { + TabView { + NavigationStack { + PokemonListView() + .navigationDestination(for: String.self) { name in + PokemonDetailView(pokemonName: name) + } + } + .tabItem { + Label("Pokédex", systemImage: "square.grid.2x2.fill") + } + + NavigationStack { + FavoritesView() + .navigationDestination(for: String.self) { name in + PokemonDetailView(pokemonName: name) + } + } + .tabItem { + Label("Favourites", systemImage: "heart.fill") + } + } + .tint(.pokeBallRed) + } +} diff --git a/iosApp/shared.xcframework/Info.plist b/iosApp/shared.xcframework/Info.plist new file mode 100644 index 0000000..0bcc838 --- /dev/null +++ b/iosApp/shared.xcframework/Info.plist @@ -0,0 +1,44 @@ + + + + + AvailableLibraries + + + BinaryPath + shared.framework/shared + LibraryIdentifier + ios-arm64_x86_64-simulator + LibraryPath + shared.framework + SupportedArchitectures + + arm64 + x86_64 + + SupportedPlatform + ios + SupportedPlatformVariant + simulator + + + BinaryPath + shared.framework/shared + LibraryIdentifier + ios-arm64 + LibraryPath + shared.framework + SupportedArchitectures + + arm64 + + SupportedPlatform + ios + + + CFBundlePackageType + XFWK + XCFrameworkFormatVersion + 1.0 + + diff --git a/iosApp/shared.xcframework/ios-arm64/shared.framework/Info.plist b/iosApp/shared.xcframework/ios-arm64/shared.framework/Info.plist new file mode 100644 index 0000000..b18e241 --- /dev/null +++ b/iosApp/shared.xcframework/ios-arm64/shared.framework/Info.plist @@ -0,0 +1,35 @@ + + + + + CFBundleExecutable + shared + CFBundleIdentifier + com.assignment.pokemon.shared + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + shared + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSupportedPlatforms + + iPhoneOS + + CFBundleVersion + 1 + MinimumOSVersion + 12.0 + UIDeviceFamily + + 1 + 2 + + UIRequiredDeviceCapabilities + + arm64 + + + \ No newline at end of file diff --git a/iosApp/shared.xcframework/ios-arm64_x86_64-simulator/shared.framework/Info.plist b/iosApp/shared.xcframework/ios-arm64_x86_64-simulator/shared.framework/Info.plist new file mode 100644 index 0000000..25c51d7 --- /dev/null +++ b/iosApp/shared.xcframework/ios-arm64_x86_64-simulator/shared.framework/Info.plist @@ -0,0 +1,31 @@ + + + + + CFBundleExecutable + shared + CFBundleIdentifier + com.assignment.pokemon.shared + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + shared + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSupportedPlatforms + + iPhoneOS + + CFBundleVersion + 1 + MinimumOSVersion + 12.0 + UIDeviceFamily + + 1 + 2 + + +