diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml new file mode 100644 index 0000000..d0f2dbe --- /dev/null +++ b/.github/workflows/ios.yml @@ -0,0 +1,52 @@ +name: iOS CI + +on: + push: + branches: [ "ios/**" ] + pull_request: + branches: [ main ] + +jobs: + build: + runs-on: macos-15 + + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Build shared XCFramework + run: ./gradlew :shared:assembleSharedDebugXCFramework + + - name: Generate Xcode Project via XcodeGen + working-directory: iosApp + run: | + brew install xcodegen + xcodegen generate + + - name: Run SwiftLint + working-directory: iosApp + run: swiftlint lint || echo "Linting finished with warnings" + + - name: Build iOS app (simulator) + working-directory: iosApp + run: | + xcodebuild build \ + -project iosApp.xcodeproj \ + -scheme iosApp \ + -sdk iphonesimulator \ + -destination 'platform=iOS Simulator,name=iPhone 16' \ + CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO + + - name: Run tests + working-directory: iosApp + run: | + xcodebuild test \ + -project iosApp.xcodeproj \ + -scheme iosApp \ + -sdk iphonesimulator \ + -destination 'platform=iOS Simulator,name=iPhone 16' diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..e644113 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..029e5af --- /dev/null +++ b/gradlew @@ -0,0 +1,131 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +app_path=$0 +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld -- "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NonStop* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + ;; + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + ;; + esac +fi + +# Collect all arguments for the java command, stracks://bugs.java.com/IssueNavigator.jspa?reset=true&mode=hide&jqlQuery=environment+%3D+%22macos%22+and+component+%3D+%22tools%22+and+bug_type+%3D+%22Bug%22+and+status+%3D+%22Open%22+and+resolution+%3D+%22Unresolved%22 +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + JAVACMD=$( cygpath --unix "$JAVACMD" ) +fi + +# Collect all arguments for the java command +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$@" + +exec "$JAVACMD" "$@" diff --git a/iosApp/AI_PROMPTS.md b/iosApp/AI_PROMPTS.md new file mode 100644 index 0000000..5915155 --- /dev/null +++ b/iosApp/AI_PROMPTS.md @@ -0,0 +1,16 @@ +## AI Prompts Log + +### Prompt 1 +> "Complete the PokéDex iOS assignment — build the SwiftUI iOS app integrating the shared KMP framework. Plan first, then implement all screens (List, Detail, Favourites) with proper StateFlow bridging, Koin integration, pagination, search, favourites persistence, animations, dark mode, and CI." + +**Used:** Yes — used the overall architecture plan and file structure. +**Changed:** Refined the KoinHelper implementation after inspecting generated ObjC headers from the shared framework. Adjusted FlowCollector to match the actual Kotlin/Native interop API. Modified navigation pattern to pass repository instances directly instead of environment objects for simpler lifecycle management. + +### Prompt 2 +> (Follow-up implementation executed within the same conversation) + +All code was generated in a single iterative session. The following manual refinements were applied: +- Verified shared framework API surface by reading all Kotlin source files +- Adapted sealed class pattern matching to use `onEnum(of:)` for KMP interop +- Fixed memory management with proper `[weak self]` captures and Task cancellation +- Ensured `onCleared()` is called on KMP ViewModels in Swift `deinit` diff --git a/iosApp/Podfile b/iosApp/Podfile new file mode 100644 index 0000000..306373f --- /dev/null +++ b/iosApp/Podfile @@ -0,0 +1,14 @@ +use_frameworks! +platform :ios, '16.0' + +target 'iosApp' do + pod 'shared', :path => '../shared' +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + target.build_configurations.each do |config| + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '16.0' + end + end +end diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj new file mode 100644 index 0000000..eb45d0e --- /dev/null +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -0,0 +1,568 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 158288F5E0D63A8AE20FA882 /* PokemonDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F506404AF136BC7E1AAACA58 /* PokemonDetailView.swift */; }; + 17781D88731F1E258D727B98 /* shared.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 8081BF82C94B0AD82FB8BCBC /* shared.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 1B0198FAEF218E6B2B25C92C /* shared.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8081BF82C94B0AD82FB8BCBC /* shared.xcframework */; }; + 22EA1CB33A17DCC6FC82406D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = F543015C899423D4B01CEB2E /* Assets.xcassets */; }; + 27CA7939722C5E3F4C7FC843 /* PokemonCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D84BEC0A0F2B64ADE9B57B8C /* PokemonCardView.swift */; }; + 287D9A4375DC3E51CFBA7511 /* TypeColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0B96A63A19E6CB701B75374 /* TypeColors.swift */; }; + 307C2654CD5972A25FB7FD87 /* iOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 668CECA83ACC03A83C85A623 /* iOSApp.swift */; }; + 39095412D8D8686213278D88 /* PokemonListStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 969AA0CB3437A256C1A5345F /* PokemonListStoreTests.swift */; }; + 3A4C05A6BC8FA808D9131BDA /* PokemonListStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D25231D13129601F2524C60 /* PokemonListStore.swift */; }; + 4795D9CE2CC5B01A736ED00B /* PokemonDetailStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC871CA9867E81A8FCDD8537 /* PokemonDetailStore.swift */; }; + 56B0305B5D3847FC6B326502 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE8509E5A49E4A5CAAEB3ADB /* ContentView.swift */; }; + 5FCD1F50D50DD7D2D000A3B7 /* FlowCollector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46FC05CA9C505BED014DED12 /* FlowCollector.swift */; }; + 5FEBA15073B6CDE213EA246F /* StatBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EAEBE215F772C362A146ED2 /* StatBarView.swift */; }; + 971146639D5073A871A6E0ED /* TypeChipView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F08943EC70403764463A8742 /* TypeChipView.swift */; }; + 99D255B879CED4D037BFFC35 /* FavoritesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B7E27CC95CB81DB47CB0100 /* FavoritesView.swift */; }; + C31EA5BE716F484A93E6E3C4 /* KoinHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A8B3DB8185EDBD7E8B94C53 /* KoinHelper.swift */; }; + F7C33B50A94DA01F2E82C280 /* libsqlite3.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 245434685F34A9A15B1AC406 /* libsqlite3.tbd */; }; + FCA95711700967722CBD012B /* Theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 607D48B73671407BBD141FEB /* Theme.swift */; }; + FCF8474A6CE66A23BCD3D774 /* FavoritesStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB2D97894E4B431F7E57B40A /* FavoritesStore.swift */; }; + FDAFE0999CB9D39EB1482D4E /* PokemonListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC9AB233CE35965905433A7E /* PokemonListView.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 0FBF91AC48015D09643FAF6B /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 242FB5348943441C388468CE /* Project object */; + proxyType = 1; + remoteGlobalIDString = 78B53CC79860CBFC2F5543C6; + remoteInfo = iosApp; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + DB76241340C08196BD9C492C /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + 17781D88731F1E258D727B98 /* shared.xcframework in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 245434685F34A9A15B1AC406 /* libsqlite3.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libsqlite3.tbd; path = usr/lib/libsqlite3.tbd; sourceTree = SDKROOT; }; + 3EAEBE215F772C362A146ED2 /* StatBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatBarView.swift; sourceTree = ""; }; + 46FC05CA9C505BED014DED12 /* FlowCollector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlowCollector.swift; sourceTree = ""; }; + 56583BC252B5F01ED9DEA19D /* iosAppTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = iosAppTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 5A8B3DB8185EDBD7E8B94C53 /* KoinHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KoinHelper.swift; sourceTree = ""; }; + 607D48B73671407BBD141FEB /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = ""; }; + 668CECA83ACC03A83C85A623 /* iOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSApp.swift; sourceTree = ""; }; + 6D25231D13129601F2524C60 /* PokemonListStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PokemonListStore.swift; sourceTree = ""; }; + 8081BF82C94B0AD82FB8BCBC /* shared.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = shared.xcframework; path = ../shared/build/XCFrameworks/debug/shared.xcframework; sourceTree = ""; }; + 86038AE1032E284E79B32AD6 /* iosApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = iosApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 969AA0CB3437A256C1A5345F /* PokemonListStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PokemonListStoreTests.swift; sourceTree = ""; }; + 9B7E27CC95CB81DB47CB0100 /* FavoritesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesView.swift; sourceTree = ""; }; + B0B96A63A19E6CB701B75374 /* TypeColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypeColors.swift; sourceTree = ""; }; + CB2D97894E4B431F7E57B40A /* FavoritesStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesStore.swift; sourceTree = ""; }; + CE8509E5A49E4A5CAAEB3ADB /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + D84BEC0A0F2B64ADE9B57B8C /* PokemonCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PokemonCardView.swift; sourceTree = ""; }; + DC871CA9867E81A8FCDD8537 /* PokemonDetailStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PokemonDetailStore.swift; sourceTree = ""; }; + F08943EC70403764463A8742 /* TypeChipView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypeChipView.swift; sourceTree = ""; }; + F506404AF136BC7E1AAACA58 /* PokemonDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PokemonDetailView.swift; sourceTree = ""; }; + F543015C899423D4B01CEB2E /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + FC9AB233CE35965905433A7E /* PokemonListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PokemonListView.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + EF8A6E9BFD470986D9D5C9B5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 1B0198FAEF218E6B2B25C92C /* shared.xcframework in Frameworks */, + F7C33B50A94DA01F2E82C280 /* libsqlite3.tbd in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 1DB4B685CAEE8BEFEBBAEF94 /* iosApp */ = { + isa = PBXGroup; + children = ( + F543015C899423D4B01CEB2E /* Assets.xcassets */, + CE8509E5A49E4A5CAAEB3ADB /* ContentView.swift */, + 668CECA83ACC03A83C85A623 /* iOSApp.swift */, + A900CE2E848FCF947868C807 /* Bridge */, + AF11D4CCC4D139482C6B5DEF /* Stores */, + 4795698A257B423F254DDC14 /* Theme */, + 430E7B864E0EEAC082A830CC /* Views */, + ); + path = iosApp; + sourceTree = ""; + }; + 3666012298E772D226418813 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 245434685F34A9A15B1AC406 /* libsqlite3.tbd */, + 8081BF82C94B0AD82FB8BCBC /* shared.xcframework */, + ); + name = Frameworks; + sourceTree = ""; + }; + 38B69C3F79CD97858CF7B609 = { + isa = PBXGroup; + children = ( + 1DB4B685CAEE8BEFEBBAEF94 /* iosApp */, + 407F741E7FEBA273A8579B0E /* iosAppTests */, + 3666012298E772D226418813 /* Frameworks */, + 94C98B8B3A2BFC1966447269 /* Products */, + ); + sourceTree = ""; + }; + 407F741E7FEBA273A8579B0E /* iosAppTests */ = { + isa = PBXGroup; + children = ( + 969AA0CB3437A256C1A5345F /* PokemonListStoreTests.swift */, + ); + path = iosAppTests; + sourceTree = ""; + }; + 430E7B864E0EEAC082A830CC /* Views */ = { + isa = PBXGroup; + children = ( + 9B7E27CC95CB81DB47CB0100 /* FavoritesView.swift */, + D84BEC0A0F2B64ADE9B57B8C /* PokemonCardView.swift */, + F506404AF136BC7E1AAACA58 /* PokemonDetailView.swift */, + FC9AB233CE35965905433A7E /* PokemonListView.swift */, + D620E5080C4522E4FB281DF2 /* Components */, + ); + path = Views; + sourceTree = ""; + }; + 4795698A257B423F254DDC14 /* Theme */ = { + isa = PBXGroup; + children = ( + 607D48B73671407BBD141FEB /* Theme.swift */, + B0B96A63A19E6CB701B75374 /* TypeColors.swift */, + ); + path = Theme; + sourceTree = ""; + }; + 94C98B8B3A2BFC1966447269 /* Products */ = { + isa = PBXGroup; + children = ( + 86038AE1032E284E79B32AD6 /* iosApp.app */, + 56583BC252B5F01ED9DEA19D /* iosAppTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + A900CE2E848FCF947868C807 /* Bridge */ = { + isa = PBXGroup; + children = ( + 46FC05CA9C505BED014DED12 /* FlowCollector.swift */, + 5A8B3DB8185EDBD7E8B94C53 /* KoinHelper.swift */, + ); + path = Bridge; + sourceTree = ""; + }; + AF11D4CCC4D139482C6B5DEF /* Stores */ = { + isa = PBXGroup; + children = ( + CB2D97894E4B431F7E57B40A /* FavoritesStore.swift */, + DC871CA9867E81A8FCDD8537 /* PokemonDetailStore.swift */, + 6D25231D13129601F2524C60 /* PokemonListStore.swift */, + ); + path = Stores; + sourceTree = ""; + }; + D620E5080C4522E4FB281DF2 /* Components */ = { + isa = PBXGroup; + children = ( + 3EAEBE215F772C362A146ED2 /* StatBarView.swift */, + F08943EC70403764463A8742 /* TypeChipView.swift */, + ); + path = Components; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 78B53CC79860CBFC2F5543C6 /* iosApp */ = { + isa = PBXNativeTarget; + buildConfigurationList = 29DCA9F0B3A40325067EB163 /* Build configuration list for PBXNativeTarget "iosApp" */; + buildPhases = ( + 297B59D9CE634556D6007C40 /* Sources */, + 1623B02D10928C8101C1F806 /* Resources */, + EF8A6E9BFD470986D9D5C9B5 /* Frameworks */, + DB76241340C08196BD9C492C /* Embed Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = iosApp; + packageProductDependencies = ( + ); + productName = iosApp; + productReference = 86038AE1032E284E79B32AD6 /* iosApp.app */; + productType = "com.apple.product-type.application"; + }; + EDDC9037D014CD9DDCA653AC /* iosAppTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = D61171475B3FE2835E331C49 /* Build configuration list for PBXNativeTarget "iosAppTests" */; + buildPhases = ( + 3CB0F17B3E63CC78541E1D6D /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + D6369A31B29DDC1641D6E6F0 /* PBXTargetDependency */, + ); + name = iosAppTests; + packageProductDependencies = ( + ); + productName = iosAppTests; + productReference = 56583BC252B5F01ED9DEA19D /* iosAppTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 242FB5348943441C388468CE /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1430; + TargetAttributes = { + EDDC9037D014CD9DDCA653AC = { + DevelopmentTeam = ""; + }; + }; + }; + buildConfigurationList = FF7CA1CA754D017066F1822F /* Build configuration list for PBXProject "iosApp" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + en, + ); + mainGroup = 38B69C3F79CD97858CF7B609; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = 94C98B8B3A2BFC1966447269 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 78B53CC79860CBFC2F5543C6 /* iosApp */, + EDDC9037D014CD9DDCA653AC /* iosAppTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 1623B02D10928C8101C1F806 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 22EA1CB33A17DCC6FC82406D /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 297B59D9CE634556D6007C40 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 56B0305B5D3847FC6B326502 /* ContentView.swift in Sources */, + FCF8474A6CE66A23BCD3D774 /* FavoritesStore.swift in Sources */, + 99D255B879CED4D037BFFC35 /* FavoritesView.swift in Sources */, + 5FCD1F50D50DD7D2D000A3B7 /* FlowCollector.swift in Sources */, + C31EA5BE716F484A93E6E3C4 /* KoinHelper.swift in Sources */, + 27CA7939722C5E3F4C7FC843 /* PokemonCardView.swift in Sources */, + 4795D9CE2CC5B01A736ED00B /* PokemonDetailStore.swift in Sources */, + 158288F5E0D63A8AE20FA882 /* PokemonDetailView.swift in Sources */, + 3A4C05A6BC8FA808D9131BDA /* PokemonListStore.swift in Sources */, + FDAFE0999CB9D39EB1482D4E /* PokemonListView.swift in Sources */, + 5FEBA15073B6CDE213EA246F /* StatBarView.swift in Sources */, + FCA95711700967722CBD012B /* Theme.swift in Sources */, + 971146639D5073A871A6E0ED /* TypeChipView.swift in Sources */, + 287D9A4375DC3E51CFBA7511 /* TypeColors.swift in Sources */, + 307C2654CD5972A25FB7FD87 /* iOSApp.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 3CB0F17B3E63CC78541E1D6D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 39095412D8D8686213278D88 /* PokemonListStoreTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + D6369A31B29DDC1641D6E6F0 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 78B53CC79860CBFC2F5543C6 /* iosApp */; + targetProxy = 0FBF91AC48015D09643FAF6B /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 11814011E7D6EC822BFBC66D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + 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; + CODE_SIGNING_REQUIRED = NO; + CODE_SIGN_IDENTITY = ""; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = ""; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + 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 = 16.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 24229CBC853481EF040BD930 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.assignment.pokemon.ios.tests; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/iosApp.app/iosApp"; + }; + name = Debug; + }; + 658C518871404178F6E62E6C /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.assignment.pokemon.ios.tests; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/iosApp.app/iosApp"; + }; + name = Release; + }; + 6C824A6EB4BAD43AE2A4BB29 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + 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; + CODE_SIGNING_REQUIRED = NO; + CODE_SIGN_IDENTITY = ""; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = ""; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "DEBUG=1", + ); + 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 = 16.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 9221A1FDC06803E8A2FC6E61 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_IDENTITY = "iPhone Developer"; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = V76XWK6797; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "\"../shared/build/XCFrameworks/debug\"", + ); + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "PokéDex"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.assignment.pokemon.ios; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + AF835BFF1220CA5AB5B867E4 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_IDENTITY = "iPhone Developer"; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = V76XWK6797; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "\"../shared/build/XCFrameworks/debug\"", + ); + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "PokéDex"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.assignment.pokemon.ios; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 29DCA9F0B3A40325067EB163 /* Build configuration list for PBXNativeTarget "iosApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 9221A1FDC06803E8A2FC6E61 /* Debug */, + AF835BFF1220CA5AB5B867E4 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + D61171475B3FE2835E331C49 /* Build configuration list for PBXNativeTarget "iosAppTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 24229CBC853481EF040BD930 /* Debug */, + 658C518871404178F6E62E6C /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + FF7CA1CA754D017066F1822F /* Build configuration list for PBXProject "iosApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 6C824A6EB4BAD43AE2A4BB29 /* Debug */, + 11814011E7D6EC822BFBC66D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; +/* End XCConfigurationList section */ + }; + rootObject = 242FB5348943441C388468CE /* Project object */; +} diff --git a/iosApp/iosApp.xcodeproj/xcuserdata/jayam.xcuserdatad/xcschemes/xcschememanagement.plist b/iosApp/iosApp.xcodeproj/xcuserdata/jayam.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000..56b5955 --- /dev/null +++ b/iosApp/iosApp.xcodeproj/xcuserdata/jayam.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,14 @@ + + + + + SchemeUserState + + iosApp.xcscheme_^#shared#^_ + + orderHint + 0 + + + + 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..40b329d --- /dev/null +++ b/iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.259", + "green" : "0.259", + "red" : "0.914" + } + }, + "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..13613e3 --- /dev/null +++ b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iosApp/iosApp/Assets.xcassets/CardBackground.colorset/Contents.json b/iosApp/iosApp/Assets.xcassets/CardBackground.colorset/Contents.json new file mode 100644 index 0000000..101b5d5 --- /dev/null +++ b/iosApp/iosApp/Assets.xcassets/CardBackground.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.976", + "green" : "0.961", + "red" : "0.949" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.157", + "green" : "0.133", + "red" : "0.118" + } + }, + "idiom" : "universal" + } + ], + "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/Bridge/FlowCollector.swift b/iosApp/iosApp/Bridge/FlowCollector.swift new file mode 100644 index 0000000..2c58c06 --- /dev/null +++ b/iosApp/iosApp/Bridge/FlowCollector.swift @@ -0,0 +1,25 @@ +import shared + +/// Bridges a KMP `FlowCollector` protocol so we can collect +/// Kotlin `StateFlow` emissions from Swift. +/// +/// Usage: +/// ```swift +/// try await stateFlow.collect( +/// collector: SwiftFlowCollector { value in ... } +/// ) +/// ``` +final class SwiftFlowCollector: Kotlinx_coroutines_coreFlowCollector { + + private let onEmit: (Any?) -> Void + + init(_ onEmit: @escaping (Any?) -> Void) { + self.onEmit = onEmit + } + + // KMP exports `emit(value:)` as an async function + // via the ObjC completion-handler bridge. + func emit(value: Any?) async throws { + onEmit(value) + } +} diff --git a/iosApp/iosApp/Bridge/KoinHelper.swift b/iosApp/iosApp/Bridge/KoinHelper.swift new file mode 100644 index 0000000..8e061d2 --- /dev/null +++ b/iosApp/iosApp/Bridge/KoinHelper.swift @@ -0,0 +1,74 @@ +import shared + +/// Thin Swift wrapper around the Koin DI container. +/// Call `KoinHelper.start()` once at app launch, then resolve +/// dependencies through the static helpers. +@MainActor +enum KoinHelper { + + // MARK: - Bootstrap + + private static var isStarted = false + + /// Initialise the shared Koin graph. + static func start() { + guard !isStarted else { return } + isStarted = true + + ModulesKt.doInitKoin( + driverFactory: DatabaseDriverFactory(), + enableNetworkLogs: true, + appDeclaration: { app in + self.koinApp = app + app.modules(modules_: [ModulesKt.viewModelModule]) + + print("--- KOIN MAPPINGS ---") + if let mappings = app.koin.instanceRegistry.instances as? [String: Any] { + for key in mappings.keys { + print("Koin key: \(key)") + } + } + print("---------------------") + } + ) + } + + // MARK: - Resolution + + /// Retrieves a Dependency from Koin by fuzzy-matching the class name in the registry entries + /// to obtain the underlying `SharedKotlinKClass`. This circumvents the fact that ObjC `get()` + /// functions crash when the KClass is erased. + private static func resolve(className: String) -> T { + guard let koinApp = koinApp else { + fatalError("Koin has not been started.") + } + + let koin = koinApp.koin + guard let mappings = koin.instanceRegistry.instances as? [String: Koin_coreInstanceFactory] else { + fatalError("Could not cast Koin instance registry.") + } + + guard let key = mappings.keys.first(where: { $0.contains(className) }), + let factory = mappings[key] else { + fatalError("Could not find factory for \(className).") + } + + let kClass = factory.beanDefinition.primaryType + + // Pass the extracted class back to Koin + guard let instance = koin.get(clazz: kClass, qualifier: nil, parameters: nil) as? T else { + fatalError("Could not resolve instance for \(className).") + } + + return instance + } + + static func getRepository() -> PokemonRepository { + return resolve(className: "PokemonRepository") + } + + // MARK: - Private + + /// Cached reference to the global KoinApplication instance. + private nonisolated(unsafe) static var koinApp: Koin_coreKoinApplication? +} diff --git a/iosApp/iosApp/ContentView.swift b/iosApp/iosApp/ContentView.swift new file mode 100644 index 0000000..1d8af1c --- /dev/null +++ b/iosApp/iosApp/ContentView.swift @@ -0,0 +1,35 @@ +import SwiftUI +import shared + +/// Root view hosting Pokédex and Favourites tabs. +struct ContentView: View { + + @State private var repository: PokemonRepository? = nil + + var body: some View { + Group { + if let repository { + TabView { + PokemonListView(repository: repository) + .tabItem { + Label("Pokédex", systemImage: "square.grid.2x2.fill") + } + + FavoritesView(repository: repository) + .tabItem { + Label("Favourites", systemImage: "heart.fill") + } + } + .tint(Theme.accentColor) + } else { + ProgressView("Starting…") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .task { + // Resolve Koin on first appear + KoinHelper.start() + repository = KoinHelper.getRepository() + } + } +} diff --git a/iosApp/iosApp/Stores/FavoritesStore.swift b/iosApp/iosApp/Stores/FavoritesStore.swift new file mode 100644 index 0000000..e9dc6e2 --- /dev/null +++ b/iosApp/iosApp/Stores/FavoritesStore.swift @@ -0,0 +1,43 @@ +import shared +import Combine + +/// ObservableObject wrapper around the KMP `FavoritesViewModel`. +/// Bridges `StateFlow>` into SwiftUI's `@Published`. +@MainActor +final class FavoritesStore: ObservableObject { + + @Published private(set) var favorites: [Pokemon] = [] + + private let viewModel: FavoritesViewModel + private var collectTask: Task? + + init(repository: PokemonRepository) { + viewModel = FavoritesViewModel(repository: repository) + startCollecting() + } + + // MARK: - Private + + private func startCollecting() { + collectTask = Task { [weak self] in + guard let self else { return } + do { + try await self.viewModel.favorites.collect( + collector: SwiftFlowCollector { [weak self] value in + guard let list = value as? [Pokemon] else { return } + Task { @MainActor [weak self] in + self?.favorites = list + } + } + ) + } catch { + // Flow collection cancelled + } + } + } + + deinit { + collectTask?.cancel() + viewModel.onCleared() + } +} diff --git a/iosApp/iosApp/Stores/PokemonDetailStore.swift b/iosApp/iosApp/Stores/PokemonDetailStore.swift new file mode 100644 index 0000000..11d2d8d --- /dev/null +++ b/iosApp/iosApp/Stores/PokemonDetailStore.swift @@ -0,0 +1,56 @@ +import shared +import Combine + +/// ObservableObject wrapper around the KMP `PokemonDetailViewModel`. +/// Bridges `StateFlow` into SwiftUI's `@Published`. +@MainActor +final class PokemonDetailStore: ObservableObject { + + @Published private(set) var state: PokemonDetailState = PokemonDetailState.Loading() + + private let viewModel: PokemonDetailViewModel + private var collectTask: Task? + + init(pokemonName: String, repository: PokemonRepository) { + viewModel = PokemonDetailViewModel( + pokemonName: pokemonName, + repository: repository + ) + startCollecting() + } + + // MARK: - Public API + + func toggleFavorite() { + viewModel.toggleFavorite() + } + + func retry() { + viewModel.retry() + } + + // MARK: - Private + + private func startCollecting() { + collectTask = Task { [weak self] in + guard let self else { return } + do { + try await self.viewModel.state.collect( + collector: SwiftFlowCollector { [weak self] value in + guard let newState = value as? PokemonDetailState else { return } + Task { @MainActor [weak self] in + self?.state = newState + } + } + ) + } catch { + // Flow collection cancelled + } + } + } + + deinit { + collectTask?.cancel() + viewModel.onCleared() + } +} diff --git a/iosApp/iosApp/Stores/PokemonListStore.swift b/iosApp/iosApp/Stores/PokemonListStore.swift new file mode 100644 index 0000000..24d8ee5 --- /dev/null +++ b/iosApp/iosApp/Stores/PokemonListStore.swift @@ -0,0 +1,57 @@ +import shared +import Combine + +/// ObservableObject wrapper around the KMP `PokemonListViewModel`. +/// Bridges `StateFlow` into SwiftUI's `@Published`. +@MainActor +final class PokemonListStore: ObservableObject { + + @Published private(set) var state: PokemonListState = PokemonListState.Loading() + + private let viewModel: PokemonListViewModel + private var collectTask: Task? + + init(repository: PokemonRepository) { + viewModel = PokemonListViewModel(repository: repository) + startCollecting() + } + + // MARK: - Public API + + func loadNextPage() { + viewModel.loadNextPage() + } + + func search(_ query: String) { + viewModel.search(query: query) + } + + func refresh() { + viewModel.refresh() + } + + // MARK: - Private + + private func startCollecting() { + collectTask = Task { [weak self] in + guard let self else { return } + do { + try await self.viewModel.state.collect( + collector: SwiftFlowCollector { [weak self] value in + guard let newState = value as? PokemonListState else { return } + Task { @MainActor [weak self] in + self?.state = newState + } + } + ) + } catch { + // Flow collection cancelled + } + } + } + + deinit { + collectTask?.cancel() + viewModel.onCleared() + } +} diff --git a/iosApp/iosApp/Theme/Theme.swift b/iosApp/iosApp/Theme/Theme.swift new file mode 100644 index 0000000..77c2218 --- /dev/null +++ b/iosApp/iosApp/Theme/Theme.swift @@ -0,0 +1,57 @@ +import SwiftUI + +/// Central design tokens for the PokéDex app. +enum Theme { + + // MARK: - Colours + + static let accentColor = Color(red: 0.91, green: 0.26, blue: 0.26) // Pokéball red + static let cardBackground = Color("CardBackground", bundle: nil) + + static var primaryBackground: Color { + Color(uiColor: .systemBackground) + } + + static var secondaryBackground: Color { + Color(uiColor: .secondarySystemBackground) + } + + // MARK: - Gradients + + static func typeGradient(_ type: String) -> LinearGradient { + let base = TypeColors.color(for: type) + return LinearGradient( + colors: [base.opacity(0.8), base.opacity(0.4)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } + + // MARK: - Spacing + + static let cornerRadius: CGFloat = 16 + static let cardCornerRadius: CGFloat = 14 + static let smallCornerRadius: CGFloat = 8 + static let padding: CGFloat = 16 + static let smallPadding: CGFloat = 8 + + // MARK: - Typography helpers + + static func pokemonId(_ id: Int) -> String { + String(format: "#%03d", id) + } + + static func pokemonName(_ name: String) -> String { + name.prefix(1).uppercased() + name.dropFirst() + } + + static func formattedHeight(_ decimetres: Int) -> String { + let metres = Double(decimetres) / 10.0 + return String(format: "%.1f m", metres) + } + + static func formattedWeight(_ hectograms: Int) -> String { + let kg = Double(hectograms) / 10.0 + return String(format: "%.1f kg", kg) + } +} diff --git a/iosApp/iosApp/Theme/TypeColors.swift b/iosApp/iosApp/Theme/TypeColors.swift new file mode 100644 index 0000000..1e2bdec --- /dev/null +++ b/iosApp/iosApp/Theme/TypeColors.swift @@ -0,0 +1,34 @@ +import SwiftUI + +/// Maps Pokémon type strings to their canonical colours. +enum TypeColors { + + 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.48, 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.64, 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.59) + 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.81) + case "fairy": return Color(red: 0.84, green: 0.52, blue: 0.68) + default: return .gray + } + } + + /// Lighter tint suitable for backgrounds. + static func background(for type: String) -> Color { + color(for: type).opacity(0.25) + } +} diff --git a/iosApp/iosApp/Views/Components/StatBarView.swift b/iosApp/iosApp/Views/Components/StatBarView.swift new file mode 100644 index 0000000..b80820c --- /dev/null +++ b/iosApp/iosApp/Views/Components/StatBarView.swift @@ -0,0 +1,68 @@ +import SwiftUI + +/// Animated horizontal bar representing a single Pokémon base stat. +struct StatBarView: View { + + let statName: String + let value: Int + let maxValue: Int + + @State private var animatedWidth: CGFloat = 0 + + private var label: String { + switch statName.lowercased() { + case "hp": return "HP" + case "attack": return "ATK" + case "defense": return "DEF" + case "special-attack": return "SpA" + case "special-defense": return "SpD" + case "speed": return "SPD" + default: return statName.uppercased() + } + } + + private var barColor: Color { + let ratio = Double(value) / Double(maxValue) + switch ratio { + case ..<0.25: return .red + case ..<0.50: return .orange + case ..<0.75: return .yellow + default: return .green + } + } + + var body: some View { + HStack(spacing: 12) { + Text(label) + .font(.system(.caption, design: .monospaced, weight: .semibold)) + .foregroundStyle(.secondary) + .frame(width: 36, alignment: .trailing) + + Text("\(value)") + .font(.system(.caption, design: .monospaced, weight: .bold)) + .frame(width: 32, alignment: .trailing) + + GeometryReader { geo in + ZStack(alignment: .leading) { + Capsule() + .fill(Color.primary.opacity(0.08)) + .frame(height: 7) + + Capsule() + .fill(barColor.gradient) + .frame( + width: animatedWidth, + height: 7 + ) + } + .onAppear { + withAnimation(.spring(response: 0.8, dampingFraction: 0.7)) { + let fraction = CGFloat(value) / CGFloat(maxValue) + animatedWidth = geo.size.width * min(fraction, 1.0) + } + } + } + .frame(height: 7) + } + } +} diff --git a/iosApp/iosApp/Views/Components/TypeChipView.swift b/iosApp/iosApp/Views/Components/TypeChipView.swift new file mode 100644 index 0000000..aee3e0e --- /dev/null +++ b/iosApp/iosApp/Views/Components/TypeChipView.swift @@ -0,0 +1,18 @@ +import SwiftUI + +/// Capsule-shaped chip displaying a Pokémon type with its canonical colour. +struct TypeChipView: View { + + let type: String + + var body: some View { + Text(type.capitalized) + .font(.system(.caption, weight: .bold)) + .foregroundStyle(.white) + .padding(.horizontal, 14) + .padding(.vertical, 6) + .background( + Capsule().fill(TypeColors.color(for: type).gradient) + ) + } +} diff --git a/iosApp/iosApp/Views/FavoritesView.swift b/iosApp/iosApp/Views/FavoritesView.swift new file mode 100644 index 0000000..276ca8d --- /dev/null +++ b/iosApp/iosApp/Views/FavoritesView.swift @@ -0,0 +1,78 @@ +import SwiftUI +import shared + +/// Favourites screen displaying saved Pokémon that reactively updates +/// whenever the underlying database changes. +struct FavoritesView: View { + + @StateObject private var store: FavoritesStore + private let repository: PokemonRepository + + init(repository: PokemonRepository) { + self.repository = repository + _store = StateObject(wrappedValue: FavoritesStore(repository: repository)) + } + + private let columns = [ + GridItem(.flexible(), spacing: 12), + GridItem(.flexible(), spacing: 12) + ] + + var body: some View { + NavigationStack { + Group { + if store.favorites.isEmpty { + emptyState + } else { + favouritesGrid + } + } + .navigationTitle("Favourites") + .animation(.easeInOut(duration: 0.25), value: store.favorites.count) + } + } + + // MARK: - Empty state + + private var emptyState: some View { + VStack(spacing: 16) { + Image(systemName: "heart.slash") + .font(.system(size: 56)) + .foregroundStyle(.secondary.opacity(0.5)) + Text("No Favourites Yet") + .font(.title3.bold()) + .foregroundStyle(.secondary) + Text("Tap the heart icon on a Pokémon\nto add it to your favourites.") + .font(.subheadline) + .foregroundStyle(.tertiary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + // MARK: - Grid + + private var favouritesGrid: some View { + ScrollView { + LazyVGrid(columns: columns, spacing: 12) { + ForEach(store.favorites, id: \.id) { pokemon in + NavigationLink(value: pokemon) { + PokemonCardView(pokemon: pokemon) + } + .buttonStyle(.plain) + .transition(.asymmetric( + insertion: .scale.combined(with: .opacity), + removal: .opacity + )) + } + } + .padding(.horizontal) + } + .navigationDestination(for: Pokemon.self) { pokemon in + PokemonDetailView( + pokemonName: pokemon.name, + repository: repository + ) + } + } +} diff --git a/iosApp/iosApp/Views/PokemonCardView.swift b/iosApp/iosApp/Views/PokemonCardView.swift new file mode 100644 index 0000000..0d044cf --- /dev/null +++ b/iosApp/iosApp/Views/PokemonCardView.swift @@ -0,0 +1,65 @@ +import SwiftUI +import shared + +/// A single grid cell displaying a Pokémon's artwork, name and ID. +struct PokemonCardView: View { + + let pokemon: Pokemon + + @State private var imageId = UUID() + + var body: some View { + // Enforce pure static string construction to bypass string corruptions. + let safeId = pokemon.id > 0 ? pokemon.id : Int32(URL(string: pokemon.imageUrl)?.lastPathComponent.replacingOccurrences(of: ".png", with: "") ?? "0") ?? 1 + let finalUrl = "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/\(safeId).png" + + VStack(spacing: 4) { + // Artwork + AsyncImage(url: URL(string: finalUrl)) { phase in + switch phase { + case .success(let image): + image + .resizable() + .aspectRatio(contentMode: .fit) + case .failure: + Image(systemName: "photo.circle") + .font(.largeTitle) + .foregroundStyle(.tertiary) + .onAppear { + // SwiftUI AsyncImage failure bug when mounted in inactive TabView + // Refresh id to force a remount when visible + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + imageId = UUID() + } + } + default: + ProgressView() + } + } + .id(imageId) + .frame(width: 100, height: 100) + .padding(.top, 8) + + // ID + Text(Theme.pokemonId(Int(pokemon.id))) + .font(.system(.caption2, design: .monospaced, weight: .medium)) + .foregroundStyle(.secondary) + + // Name + Text(Theme.pokemonName(pokemon.name)) + .font(.system(.subheadline, weight: .semibold)) + .lineLimit(1) + .padding(.bottom, 8) + } + .frame(maxWidth: .infinity) + .background( + RoundedRectangle(cornerRadius: Theme.cardCornerRadius, style: .continuous) + .fill(.ultraThinMaterial) + ) + .overlay( + RoundedRectangle(cornerRadius: Theme.cardCornerRadius, style: .continuous) + .strokeBorder(Color.primary.opacity(0.06), lineWidth: 1) + ) + .shadow(color: .black.opacity(0.06), radius: 6, x: 0, y: 3) + } +} diff --git a/iosApp/iosApp/Views/PokemonDetailView.swift b/iosApp/iosApp/Views/PokemonDetailView.swift new file mode 100644 index 0000000..0f40eff --- /dev/null +++ b/iosApp/iosApp/Views/PokemonDetailView.swift @@ -0,0 +1,219 @@ +import SwiftUI +import shared + +/// Detail screen for a single Pokémon showing image, types, stats, +/// height/weight, abilities, and a favourite toggle. +struct PokemonDetailView: View { + + @StateObject private var store: PokemonDetailStore + @Environment(\.dismiss) private var dismiss + + init(pokemonName: String, repository: PokemonRepository) { + _store = StateObject( + wrappedValue: PokemonDetailStore( + pokemonName: pokemonName, + repository: repository + ) + ) + } + + var body: some View { + Group { + if store.state is PokemonDetailState.Loading { + ProgressView("Loading…") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let errorState = store.state as? PokemonDetailState.Error { + errorView(message: errorState.message) + } else if let successState = store.state as? PokemonDetailState.Success { + detailContent(successState.pokemon) + } + } + .navigationBarTitleDisplayMode(.inline) + } + + // MARK: - Error + + private func errorView(message: String) -> some View { + VStack(spacing: 16) { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 48)) + .foregroundStyle(.secondary) + Text(message) + .foregroundStyle(.secondary) + Button("Retry") { store.retry() } + .buttonStyle(.borderedProminent) + .tint(Theme.accentColor) + } + } + + // MARK: - Detail Content + + private func detailContent(_ pokemon: PokemonDetail) -> some View { + let primaryType = (pokemon.types as? [String])?.first ?? "normal" + + return ScrollView { + VStack(spacing: 20) { + headerSection(pokemon, primaryType: primaryType) + typeChipsSection(pokemon) + physicalSection(pokemon) + statsSection(pokemon) + abilitiesSection(pokemon) + } + .padding(.bottom, 32) + } + .background(TypeColors.background(for: primaryType).ignoresSafeArea()) + .navigationTitle(Theme.pokemonName(pokemon.name)) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + favouriteButton(pokemon) + } + } + } + + // MARK: - Header + + private func headerSection(_ pokemon: PokemonDetail, primaryType: String) -> some View { + VStack(spacing: 8) { + Text(Theme.pokemonId(Int(pokemon.id))) + .font(.system(.headline, design: .monospaced)) + .foregroundStyle(.secondary) + + AsyncImage(url: URL(string: pokemon.imageUrl)) { phase in + switch phase { + case .success(let image): + image + .resizable() + .aspectRatio(contentMode: .fit) + case .failure: + Image(systemName: "photo") + .font(.system(size: 64)) + .foregroundStyle(.secondary) + default: + ProgressView() + } + } + .frame(width: 220, height: 220) + .shadow(color: TypeColors.color(for: primaryType).opacity(0.4), + radius: 20, x: 0, y: 10) + } + .frame(maxWidth: .infinity) + .padding(.top, 8) + } + + // MARK: - Type chips + + private func typeChipsSection(_ pokemon: PokemonDetail) -> some View { + let types = pokemon.types as? [String] ?? [] + return HStack(spacing: 10) { + ForEach(types, id: \.self) { type in + TypeChipView(type: type) + } + } + } + + // MARK: - Height / Weight + + private func physicalSection(_ pokemon: PokemonDetail) -> some View { + HStack(spacing: 32) { + infoCard( + icon: "ruler", + title: "Height", + value: Theme.formattedHeight(Int(pokemon.height)) + ) + infoCard( + icon: "scalemass", + title: "Weight", + value: Theme.formattedWeight(Int(pokemon.weight)) + ) + } + .padding(.horizontal) + } + + private func infoCard(icon: String, title: String, value: String) -> some View { + VStack(spacing: 6) { + Image(systemName: icon) + .font(.title3) + .foregroundStyle(.secondary) + Text(value) + .font(.system(.title3, weight: .bold)) + Text(title) + .font(.caption) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + .padding() + .background( + RoundedRectangle(cornerRadius: Theme.cardCornerRadius, style: .continuous) + .fill(.ultraThinMaterial) + ) + } + + // MARK: - Stats + + private func statsSection(_ pokemon: PokemonDetail) -> some View { + let stats = pokemon.stats as? [PokemonStat] ?? [] + return VStack(alignment: .leading, spacing: 10) { + Text("Base Stats") + .font(.system(.headline, weight: .bold)) + .padding(.horizontal) + + VStack(spacing: 8) { + ForEach(stats, id: \.name) { stat in + StatBarView( + statName: stat.name, + value: Int(stat.value), + maxValue: 255 + ) + } + } + .padding(.horizontal) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: Theme.cardCornerRadius, style: .continuous) + .fill(.ultraThinMaterial) + ) + .padding(.horizontal) + } + } + + // MARK: - Abilities + + private func abilitiesSection(_ pokemon: PokemonDetail) -> some View { + let abilities = pokemon.abilities as? [String] ?? [] + guard !abilities.isEmpty else { return AnyView(EmptyView()) } + return AnyView( + VStack(alignment: .leading, spacing: 10) { + Text("Abilities") + .font(.system(.headline, weight: .bold)) + .padding(.horizontal) + + HStack(spacing: 10) { + ForEach(abilities, id: \.self) { ability in + Text(ability.replacingOccurrences(of: "-", with: " ").capitalized) + .font(.subheadline) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background( + Capsule().fill(.ultraThinMaterial) + ) + } + } + .padding(.horizontal) + } + ) + } + + // MARK: - Favourite + + private func favouriteButton(_ pokemon: PokemonDetail) -> some View { + Button { + withAnimation(.spring(response: 0.3)) { + store.toggleFavorite() + } + } label: { + Image(systemName: pokemon.isFavorite ? "heart.fill" : "heart") + .font(.title3) + .foregroundStyle(pokemon.isFavorite ? .red : .secondary) + } + } +} diff --git a/iosApp/iosApp/Views/PokemonListView.swift b/iosApp/iosApp/Views/PokemonListView.swift new file mode 100644 index 0000000..6a83d58 --- /dev/null +++ b/iosApp/iosApp/Views/PokemonListView.swift @@ -0,0 +1,110 @@ +import SwiftUI +import shared + +/// Main Pokémon list screen with grid layout, search bar, and infinite scroll. +struct PokemonListView: View { + + @StateObject private var store: PokemonListStore + @State private var searchText = "" + + private let repository: PokemonRepository + + init(repository: PokemonRepository) { + self.repository = repository + _store = StateObject(wrappedValue: PokemonListStore(repository: repository)) + } + + private let columns = [ + GridItem(.flexible(), spacing: 12), + GridItem(.flexible(), spacing: 12) + ] + + var body: some View { + NavigationStack { + Group { + if store.state is PokemonListState.Loading { + loadingView + } else if let errorState = store.state as? PokemonListState.Error { + errorView(message: errorState.message) + } else if let successState = store.state as? PokemonListState.Success { + pokemonGrid(successState) + } + } + .navigationTitle("Pokédex") + .searchable(text: $searchText, prompt: "Search Pokémon") + .onChange(of: searchText) { newValue in + store.search(newValue) + } + .refreshable { + store.refresh() + } + } + } + + // MARK: - Sub-views + + private var loadingView: some View { + VStack(spacing: 16) { + ProgressView() + .scaleEffect(1.2) + Text("Loading Pokémon…") + .font(.subheadline) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func errorView(message: String) -> some View { + VStack(spacing: 16) { + Image(systemName: "wifi.exclamationmark") + .font(.system(size: 48)) + .foregroundStyle(.secondary) + Text(message) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + Button("Retry") { + store.refresh() + } + .buttonStyle(.borderedProminent) + .tint(Theme.accentColor) + } + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + @ViewBuilder + private func pokemonGrid(_ state: PokemonListState.Success) -> some View { + let pokemonList = state.pokemon as? [Pokemon] ?? [] + ScrollView { + LazyVGrid(columns: columns, spacing: 12) { + ForEach(pokemonList, id: \.id) { pokemon in + NavigationLink(value: pokemon) { + PokemonCardView(pokemon: pokemon) + } + .buttonStyle(.plain) + .onAppear { + if pokemon.id == pokemonList.last?.id { + store.loadNextPage() + } + } + .transition(.opacity.combined(with: .scale(scale: 0.95))) + } + + if state.isLoadingMore { + ProgressView() + .frame(maxWidth: .infinity) + .padding(.vertical, 20) + } + } + .padding(.horizontal) + .animation(.easeInOut(duration: 0.25), value: pokemonList.count) + } + .navigationDestination(for: Pokemon.self) { pokemon in + PokemonDetailView( + pokemonName: pokemon.name, + repository: repository + ) + } + } +} diff --git a/iosApp/iosApp/iOSApp.swift b/iosApp/iosApp/iOSApp.swift new file mode 100644 index 0000000..f3ed712 --- /dev/null +++ b/iosApp/iosApp/iOSApp.swift @@ -0,0 +1,17 @@ +import SwiftUI +import shared + +@main +struct iOSApp: App { + + init() { + KoinHelper.start() + } + + var body: some Scene { + WindowGroup { + ContentView() + .preferredColorScheme(nil) // Supports both light & dark + } + } +} diff --git a/iosApp/iosAppTests/PokemonListStoreTests.swift b/iosApp/iosAppTests/PokemonListStoreTests.swift new file mode 100644 index 0000000..421b1c0 --- /dev/null +++ b/iosApp/iosAppTests/PokemonListStoreTests.swift @@ -0,0 +1,42 @@ +import XCTest +@testable import iosApp +import shared + +/// Unit tests for the SwiftUI store/wrapper layer. +/// These validate that the Store correctly bridges KMP ViewModel state. +final class PokemonListStoreTests: XCTestCase { + + private static var isKoinStarted = false + + override func setUp() async throws { + try await super.setUp() + await MainActor.run { + if !Self.isKoinStarted { + KoinHelper.start() + Self.isKoinStarted = true + } + } + } + + /// Verify the store initialises with a Loading state. + @MainActor + func testInitialStateIsLoading() async { + // The store should start in Loading state before the ViewModel + // fetches its first page. + let store = PokemonListStore(repository: KoinHelper.getRepository()) + + // Initial published state must be Loading + XCTAssertTrue(store.state is PokemonListState.Loading, + "Expected initial state to be Loading, got \(type(of: store.state))") + } + + /// Verify search updates state without crashing. + @MainActor + func testSearchDoesNotCrash() async { + let store = PokemonListStore(repository: KoinHelper.getRepository()) + store.search("pikachu") + + // If we get here without a crash, the test passes. + // The search filters are applied client-side so no network call is needed. + } +} diff --git a/iosApp/project.yml b/iosApp/project.yml new file mode 100644 index 0000000..dd5d803 --- /dev/null +++ b/iosApp/project.yml @@ -0,0 +1,48 @@ +name: iosApp +options: + bundleIdPrefix: com.assignment.pokemon + deploymentTarget: + iOS: "16.0" + createIntermediateGroups: true + generateEmptyDirectories: true + +settings: + base: + SWIFT_VERSION: "5.0" + DEVELOPMENT_TEAM: "" + CODE_SIGN_IDENTITY: "" + CODE_SIGNING_REQUIRED: "NO" + +targets: + iosApp: + type: application + platform: iOS + sources: + - path: iosApp + dependencies: + - framework: ../shared/build/XCFrameworks/debug/shared.xcframework + embed: true + - sdk: libsqlite3.tbd + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.assignment.pokemon.ios + INFOPLIST_KEY_UIApplicationSceneManifest_Generation: YES + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES + INFOPLIST_KEY_UILaunchScreen_Generation: YES + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone: "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight" + INFOPLIST_KEY_CFBundleDisplayName: "PokéDex" + GENERATE_INFOPLIST_FILE: YES + CURRENT_PROJECT_VERSION: 1 + MARKETING_VERSION: 1.0 + + iosAppTests: + type: bundle.unit-test + platform: iOS + sources: + - path: iosAppTests + dependencies: + - target: iosApp + settings: + base: + GENERATE_INFOPLIST_FILE: YES + PRODUCT_BUNDLE_IDENTIFIER: com.assignment.pokemon.ios.tests