From 2b7561deead65bd13c9e2b6cb37d58a293bf85ef Mon Sep 17 00:00:00 2001 From: Sam Hill Date: Wed, 27 Sep 2023 15:28:44 -0400 Subject: [PATCH] Introduce SKIE Co-authored-by: Russell Wolf Co-authored-by: Jigar Brahmbhatt --- .github/workflows/KaMPKit-Android.yml | 2 +- .github/workflows/KaMPKit-iOS.yml | 2 +- README.md | 46 ++--- app/build.gradle.kts | 3 + .../kampkit/android/ui/Composables.kt | 49 ++++-- build.gradle.kts | 2 - docs/APP_BUILD.md | 12 +- docs/DETAILED_DEV_SETUP.md | 24 +-- docs/GENERAL_ARCHITECTURE.md | 130 +++++++------- docs/IOS_PROJ_INTEGRATION.md | 2 +- docs/WHAT_AND_WHY.md | 6 +- gradle.properties | 2 + gradle/libs.versions.toml | 18 +- gradle/wrapper/gradle-wrapper.properties | 6 +- ios/KaMPKitiOS.xcodeproj/project.pbxproj | 14 +- ios/KaMPKitiOS/BreedListScreen.swift | 159 +++++++++--------- ios/KaMPKitiOS/CombineAdapters.swift | 40 ----- ios/Podfile | 2 +- ios/Podfile.lock | 6 +- ios/Pods/Local Podspecs/shared.podspec.json | 11 +- ios/Pods/Manifest.lock | 6 +- ios/Pods/Pods.xcodeproj/project.pbxproj | 155 +++++++++-------- .../Pods-KaMPKitiOS-Info.plist | 2 +- .../Pods-KaMPKitiOS-frameworks.sh | 2 +- .../SwiftLint/SwiftLint.debug.xcconfig | 1 + .../SwiftLint/SwiftLint.release.xcconfig | 1 + .../shared/shared.debug.xcconfig | 1 + .../shared/shared.release.xcconfig | 1 + settings.gradle.kts | 1 + shared/build.gradle.kts | 17 +- shared/shared.podspec | 2 +- .../co/touchlab/kampkit/models/ViewModel.kt | 4 - .../touchlab/kampkit/models/BreedViewModel.kt | 118 ++++++++----- .../co/touchlab/kampkit/models/ViewModel.kt | 3 - .../co/touchlab/kampkit/BreedViewModelTest.kt | 58 ++++--- .../kampkit/BreedCallbackViewModel.kt | 26 --- .../co/touchlab/kampkit/CoroutineAdapters.kt | 35 ---- .../kotlin/co/touchlab/kampkit/KoinIOS.kt | 5 +- .../co/touchlab/kampkit/models/ViewModel.kt | 33 +--- 39 files changed, 480 insertions(+), 527 deletions(-) delete mode 100644 ios/KaMPKitiOS/CombineAdapters.swift delete mode 100644 shared/src/iosMain/kotlin/co/touchlab/kampkit/BreedCallbackViewModel.kt delete mode 100644 shared/src/iosMain/kotlin/co/touchlab/kampkit/CoroutineAdapters.kt diff --git a/.github/workflows/KaMPKit-Android.yml b/.github/workflows/KaMPKit-Android.yml index e7c8090a..a843c714 100644 --- a/.github/workflows/KaMPKit-Android.yml +++ b/.github/workflows/KaMPKit-Android.yml @@ -22,7 +22,7 @@ jobs: - uses: actions/setup-java@v3 with: distribution: corretto - java-version: 11 + java-version: 17 - name: Build run: ./gradlew build diff --git a/.github/workflows/KaMPKit-iOS.yml b/.github/workflows/KaMPKit-iOS.yml index 2b0fb774..d8544d64 100644 --- a/.github/workflows/KaMPKit-iOS.yml +++ b/.github/workflows/KaMPKit-iOS.yml @@ -22,7 +22,7 @@ jobs: - uses: actions/setup-java@v3 with: distribution: corretto - java-version: 11 + java-version: 17 - name: Build run: ./gradlew :shared:iosX64Test diff --git a/README.md b/README.md index 8ac73825..e9f51377 100644 --- a/README.md +++ b/README.md @@ -5,26 +5,30 @@ ![KaMP Kit Image](kampkit.png) -***Welcome to KaMP Kit!*** +***Welcome to KaMP Kit!*** -## 2022 Update +## Intro -KaMP Kit started a little over 2 years ago with the goal of helping developers interested in KMP and KMM get started +KaMP Kit started a little under 4 years ago with the goal of helping developers interested in Kotlin Multiplatform (aka KMP) get started quickly with a great set of libraries and patterns. At the time, there were not many sample apps and getting started was not trivial. The KMM situation has improved considerably since then, and various barriers to entry have been removed. -Whereas KaMP Kit started with the goal of being a minimal sample, we now intend it to be less "getting started" and -more "best practice model". Watch this repo and follow [@TouchlabHQ](https://twitter.com/TouchlabHQ) for updates! +Whereas KaMP Kit started with the goal of being a minimal sample, we now intend it to be less `getting started` and +more `best practice model`. Watch this repo and follow [@TouchlabHQ](https://twitter.com/TouchlabHQ) for updates! + +### 2023 Update + +We updated `KaMPKit` to make sure of Touchlab's new [SKIE](https://skie.touchlab.co/) tool. SKIE allowed use to remove a lot of boilerplate code related to `ViewModel` sharing, and also we can now use Kotlin sealed classes as Swift enums in iOS code. Take a look at our detailed [migration case study](https://touchlabpro.touchlab.dev/touchlab/training/skie-architecture/migrating-kampkit-to-skie) > ## Subscribe! > -> We build solutions that get teams started smoothly with Kotlin Multiplatform Mobile and ensure their success in production. Join our community to learn how your peers are adopting KMM. +> We build solutions that get teams started smoothly with Kotlin Multiplatform and ensure their success in production. Join our community to learn how your peers are adopting KMM. [Sign up here](https://go.touchlab.co/newsletter-gh)! ## Getting Help -KaMP Kit support can be found in the Kotlin Community Slack, [request access here](http://slack.kotlinlang.org/). Post in the "[#touchlab-tools](https://kotlinlang.slack.com/archives/CTJB58X7X)" channel. +KaMP Kit support can be found in the Kotlin Community Slack, [request access here](http://slack.kotlinlang.org/). Post in the [#touchlab-tools](https://kotlinlang.slack.com/archives/CTJB58X7X) channel. For direct assistance, please [contact Touchlab](https://go.touchlab.co/contactkamp) to discuss support options. @@ -32,13 +36,12 @@ For direct assistance, please [contact Touchlab](https://go.touchlab.co/contactk ### Goal -The goal of KaMP Kit is to facilitate your evaluation of Kotlin Multiplatform (aka KMP). It is a collection of code and +The goal of KaMP Kit is to facilitate your evaluation of KMP. It is a collection of code and tools designed to get you started quickly. It's also a showcase of Touchlab's typical choices for architecture, libraries, and other best practices. The KMP ecosystem has generated a lot of excitement, and has evolved very rapidly. As a result, there's a lot of old or -conflicting documentation, blog posts, tutorials, etc. We, Touchlab, have worked with several teams looking at KMM ( -Kotlin Multiplatform Mobile) and KMP, and have found that the **primary** stumbling block is simply getting started. +conflicting documentation, blog posts, tutorials, etc. We, Touchlab, have worked with several teams looking at KMP, and have found that the **primary** stumbling block is simply getting started. KaMP Kit is designed to get you past that primary stumbling block. You should be able to set up your development environment, clone the repo, and have a running sample app very quickly. From there, you can focus on what you want to build. @@ -48,7 +51,7 @@ This kit exists because the info you may find from Google about KMM and KMP is l ### Audience -We (Touchlab) are focused primarily on using KMP for native mobile development (now called KMM). As a result, this kit is primarily targeted at native mobile developers (Android or iOS), as well as engineering managers for native mobile teams. You should have little-to-no experience with KMP, although some of the information after setup may be useful if you do have KMP experience. +We (Touchlab) are focused primarily on using KMP for native mobile development. As a result, this kit is primarily targeted at native mobile developers (Android or iOS), as well as engineering managers for native mobile teams. You should have little-to-no experience with KMP, although some of the information after setup may be useful if you do have KMP experience. ## What's Included? @@ -68,10 +71,9 @@ The central part of the "Kit" is the starter app. It includes a set of libraries You will need the following: -* JVM 8 -* Android SDK and the latest stable Android Studio(2021.2.1+) or IntelliJ(2021.2+) -* Intellij Kotlin plugin with 1.7.10 support (should be included in the latest Android Studio or IDEA) -* Mac with Xcode 14+ for the iOS build +- JVM 17 +- Android SDK and the latest stable Android Studio (2022.3+) or IntelliJ(2023.2+) +- Mac with Xcode 14+ for the iOS build For a more detailed guide targeted at iOS developers, see [DETAILED_DEV_SETUP](docs/DETAILED_DEV_SETUP.md). @@ -99,7 +101,7 @@ If the app is building, it's a good time to take a break and get some background ### KMP Intro -It's important to understand not just how to set up the platform, but to get a better perspective on what the tech can do and why we think it'll be very successful. KMP is distinct from other code sharing and "cross platform" systems, and understanding those distinctions is useful. +It's important to understand not just how to set up the platform, but to get a better perspective on what the tech can do and why we think it'll be very successful. KMP is distinct from other code sharing and `cross platform` systems, and understanding those distinctions is useful. [Longer intro to KaMP Kit](docs/WHAT_AND_WHY.md) - Original version of this doc's intro. Cut because it was pretty long. @@ -108,20 +110,26 @@ It's important to understand not just how to set up the platform, but to get a b ### "Selling" KMP KaMPKit can help you demonstrate to management and other stakeholders the value of sharing code with KMP. Check out these resources for more advice on pitching KMP to your team: + +[Kotlin Multiplatform Mobile for Teams](https://www.youtube.com/watch?v=-tJvCOfJesk&t=2145s) + [Building a Business Case for KMP](https://touchlab.co/building-business-case-kotlin-multiplatform/) + [7 ways to convince your engineering manager to pilot Kotlin Multiplatform](https://touchlab.co/7-ways-convince-engineering-manager-pilot-kotlin-multiplatform/) ### Xcode Debugging -For information on how to debug Kotlin in Xcode, check out the [Debugging Kotlin In Xcode](docs/DEBUGGING_KOTLIN_IN_XCODE.md) doc. +For information on how to debug Kotlin in Xcode, check out the [Debugging Kotlin In Xcode](docs/DEBUGGING_KOTLIN_IN_XCODE.md) doc. ## 5) Integrating 'shared' With Existing Apps As part of your evaluation, you'll need to decide if you're going to integrate KMP into existing apps. Some teams feel integrating with their production apps is a better demonstration of KMP's viability. While KMP's interop is great, relative to other technologies, **integrating *anything* into a production app build process can be a difficult task**. Once integrated, development is generally smooth, but modifying production build systems can be a time consuming task. +[Adopting Kotlin Multiplatform In Brownfield Applications](https://www.youtube.com/watch?v=rF-w_jL0qsI) + ### Android -The Android side is somewhat more straightforward. Kotlin is the preferred language for Android, and the library can be integrated as just another module library. We'll be updating soon with a general Android integration doc. In the meantime, the simplest method would be to copy the shared module into your standard Android build, and use the `app` module as a reference for dependency resolution. +The Android side is somewhat more straightforward. Android development is Kotlin-first nowadays, and the library can be integrated as just another module library. We'll be updating soon with a general Android integration doc. In the meantime, the simplest method would be to copy the shared module into your standard Android build, and use the `app` module as a reference for dependency resolution. ### iOS @@ -160,5 +168,5 @@ We have made KMP the focus of Touchlab. We had possibly the first KMP* app publi > ## Subscribe! > -> We build solutions that get teams started smoothly with Kotlin Multiplatform Mobile and ensure their success in production. Join our community to learn how your peers are adopting KMM. +> We build solutions that get teams started smoothly with Kotlin Multiplatform and ensure their success in production. Join our community to learn how your peers are adopting KMP. [Sign up here](https://go.touchlab.co/newsletter)! diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ef663cea..4e6d9d06 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -23,6 +23,8 @@ android { } compileOptions { isCoreLibraryDesugaringEnabled = true + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 } lint { @@ -32,6 +34,7 @@ android { buildFeatures { compose = true + buildConfig = true } composeOptions { diff --git a/app/src/main/kotlin/co/touchlab/kampkit/android/ui/Composables.kt b/app/src/main/kotlin/co/touchlab/kampkit/android/ui/Composables.kt index 002c2624..f6b6d2e2 100644 --- a/app/src/main/kotlin/co/touchlab/kampkit/android/ui/Composables.kt +++ b/app/src/main/kotlin/co/touchlab/kampkit/android/ui/Composables.kt @@ -49,6 +49,10 @@ fun MainScreen( val dogsState by viewModel.breedState.collectAsStateWithLifecycle() val scope = rememberCoroutineScope() + LaunchedEffect(viewModel) { + viewModel.activate() + } + MainScreenContent( dogsState = dogsState, onRefresh = { scope.launch { viewModel.refreshBreeds() } }, @@ -74,22 +78,23 @@ fun MainScreenContent( val refreshState = rememberPullRefreshState(dogsState.isLoading, onRefresh) Box(Modifier.pullRefresh(refreshState)) { - if (dogsState.isEmpty) { - Empty() - } - val breeds = dogsState.breeds - if (breeds != null) { - LaunchedEffect(breeds) { + when (dogsState) { + is BreedViewState.Empty -> Empty() + is BreedViewState.Content -> { + val breeds = dogsState.breeds onSuccess(breeds) + Success(successData = breeds, favoriteBreed = onFavorite) } - Success(successData = breeds, favoriteBreed = onFavorite) - } - val error = dogsState.error - if (error != null) { - LaunchedEffect(error) { + + is BreedViewState.Error -> { + val error = dogsState.error onError(error) + Error(error) + } + + BreedViewState.Initial -> { + // no-op (just show spinner until first data is loaded) } - Error(error) } PullRefreshIndicator(dogsState.isLoading, refreshState, Modifier.align(Alignment.TopCenter)) @@ -182,7 +187,7 @@ fun FavoriteIcon(breed: Breed) { @Composable fun MainScreenContentPreview_Success() { MainScreenContent( - dogsState = BreedViewState( + dogsState = BreedViewState.Content( breeds = listOf( Breed(0, "appenzeller", false), Breed(1, "australian", true) @@ -190,3 +195,21 @@ fun MainScreenContentPreview_Success() { ) ) } + +@Preview +@Composable +fun MainScreenContentPreview_Initial() { + MainScreenContent(dogsState = BreedViewState.Initial) +} + +@Preview +@Composable +fun MainScreenContentPreview_Empty() { + MainScreenContent(dogsState = BreedViewState.Empty()) +} + +@Preview +@Composable +fun MainScreenContentPreview_Error() { + MainScreenContent(dogsState = BreedViewState.Error("Something went wrong!")) +} diff --git a/build.gradle.kts b/build.gradle.kts index 47e20f28..153b5796 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -15,8 +15,6 @@ allprojects { repositories { google() mavenCentral() - maven("https://androidx.dev/storage/compose-compiler/repository/") - maven("https://maven.pkg.jetbrains.space/kotlin/p/kotlin/dev/") } } diff --git a/docs/APP_BUILD.md b/docs/APP_BUILD.md index c365ab20..e8f30efc 100644 --- a/docs/APP_BUILD.md +++ b/docs/APP_BUILD.md @@ -3,9 +3,9 @@ ## Prerequisites Before you build the app you will require these items: -* JVM 8 -* Android SDK and Android Studio or IntelliJ -* Mac with Xcode 11+ for the iOS build +* JVM 17 +- Android SDK and the latest stable Android Studio (2022.3+) or IntelliJ(2023.2+) +- Mac with Xcode 14+ for the iOS build For more details, check out the [DETAILED_DEV_SETUP](DETAILED_DEV_SETUP.md) document. @@ -17,7 +17,7 @@ git clone https://github.com/touchlab/KaMPKit.git ### 2) Build Android 1. Open the project in Android Studio/IntelliJ and wait for indexing to finish. -2. Make sure you see the run config for the Android app +2. Make sure you see the run config for the Android app ![](runconfig.png) 3. Run the Android app on either the Emulator or a phone. If the app builds correctly, you should see this: @@ -26,8 +26,8 @@ git clone https://github.com/touchlab/KaMPKit.git ### 3) Build iOS 1. [Optional] Run gradle build. If you are more familiar with Android it may be easier to run the gradle build and confirm that the shared library builds properly before moving into Xcode land, but this isn't necessary. The shared library will also build when run in Xcode. - 1. Open a Terminal window or use the one at the bottom of Android Studio/IntelliJ. - 2. Navigate to the project's root directory (`KaMPKit/` - not `KaMPKit/ios/` - which is iOS project's root directory). + 1. Open a Terminal window or use the one at the bottom of Android Studio/IntelliJ. + 2. Navigate to the project's root directory (`KaMPKit/` - not `KaMPKit/ios/` - which is iOS project's root directory). 3. Run the command `./gradlew build` which will build the shared library. 2. Open Xcode **workspace** project in the `ios/` folder: `KaMPKitiOS.xcworkspace`. 3. Run the iOS app on either the Simulator or a phone. If the app builds correctly, you should see this: diff --git a/docs/DETAILED_DEV_SETUP.md b/docs/DETAILED_DEV_SETUP.md index 404a3aaa..a6aec0cb 100644 --- a/docs/DETAILED_DEV_SETUP.md +++ b/docs/DETAILED_DEV_SETUP.md @@ -1,13 +1,12 @@ # KMP Development Environment Setup -Not assuming anything if you're an iOS developer. You may not have the Android/JVM setup necessary to run everything. - +Not assuming anything if you're an iOS developer. You may not have the Android/JVM setup necessary to run everything. ## Install JDK -You'll need a JDK (Java Development Kit), preferably version 8. We recommend -[Amazon Corretto](https://docs.aws.amazon.com/corretto/latest/corretto-8-ug/macos-install.html). Download the pkg +You'll need a JDK (Java Development Kit), version 17. You can use the one already comes built-in the Android Studio but if you prefer a standalone JDK installation then, we recommend +[Amazon Corretto](https://docs.aws.amazon.com/corretto/latest/corretto-17-ug/macos-install.html). Download the pkg installer and go through the setup instructions. Some alternative options, if desired: @@ -15,15 +14,13 @@ Some alternative options, if desired: - [SDKMan](https://sdkman.io/) - JDK version manager and installer. - [AdoptOpenJDK](https://adoptopenjdk.net/) - Alternate JDK distribution. - - ## Install the IDE(s) You'll also need either Android Studio, IntelliJ, or both. Android Studio is an Android development focused skin of IntelliJ, which is more platform agnostic. There is a built-in KMM plugin in the Android Studio, which enables you to run and debug the iOS part of your application on iOS targets straight from Android Studio. IntelliJ IDEA has a newer Kotlin API platform and gets bugfixes -sooner, but it has an older version of Android plugin. If you don't have either, we recommend +sooner, but it has an older version of Android Gradle Plugin. If you don't have either, we recommend installing both through the [Jetbrains Toolbox](https://www.jetbrains.com/toolbox-app/download/download-thanks.html). @@ -34,7 +31,7 @@ If you just want one or the other, you can use the following links: - [IntelliJ setup guide](https://www.jetbrains.com/help/idea/run-for-the-first-time.html) You can use [KDoctor](https://github.com/Kotlin/kdoctor) to help you set-up your environment for -Kotlin Multiplatform Mobile app development. It ensures that all required components are properly +Kotlin Multiplatform app development. It ensures that all required components are properly installed and ready for use. If something is missed or not configured KDoctor highlights the problem and suggests how to fix the problem. @@ -42,21 +39,18 @@ and suggests how to fix the problem. Once you have your IDE installed, open it. If it's Android Studio, select **Open an Existing Android Studio Project** and if it's IntelliJ select **Import Project**. In the finder that opens up, select the root directory of your clone of this repository. -Opening this project in Android Studio should automatically configure the project's `local.properties` file. If for some reason it doesn't, or if you open the project in IntelliJ, you'll need to configure this file manually. To do so, open `local.properties`, and set the value of `sdk.dir` to `/Users/[YOUR_USERNAME]/Library/Android/sdk`. +Opening this project in Android Studio should automatically configure the project's `local.properties` file. If for some reason it doesn't, or if you open the project in IntelliJ, you'll need to configure this file manually. To do so, open `local.properties`, and set the value of `sdk.dir` to `/Users/[YOUR_USERNAME]/Library/Android/sdk` (or path to where Android SDK is installed). On the left, above the project structure (or the Project Navigator in Xcode-ese), there's a dropdown menu above your project's root directory. Make sure that it's set to "Project" (_for context: the IDE may think that you're working on a traditional Android project and set this menu to "Android" or make some similar mistake, and organize the files in the navigator accordingly_). - ## Install an Android Emulator -The Android corollary to a Simulator is an Emulator. To install an Emulator, you need to open the Android Virtual Device (AVD) Manager, which is the corollary to the Device and Simulators window in Xcode. - -If you're in Android Studio, go to Tools -> AVD Manager. If you're in IntelliJ, there's one extra step: go to Tools -> Android -> AVD Manager. After this first step, the process is the same in Android Studio and IntelliJ. Select **+ Create New Virtual Device...**. - -You'll have a large choice of devices to choose from, but we recommend you install the newest, largest Pixel device to emulate (Pixel 3 XL at the time this is being written). Go to the next step and select the newest API level, and then go to the last step and select **Finish**. +The Android corollary to a Simulator is an Emulator. To install an Emulator, you need to open the Android Virtual Device (AVD) Manager, which is the corollary to the Device and Simulators window in Xcode. +If you're in Android Studio, go to Tools -> AVD Manager. If you're in IntelliJ, there's one extra step: go to Tools -> Android -> AVD Manager. After this first step, the process is the same in Android Studio and IntelliJ. Select **+ Create New Virtual Device...**. +You'll have a large choice of devices to choose from, but we recommend you install the newest, latest Pixel device to emulate. Go to the next step and select the newest API level, and then go to the last step and select **Finish**. ## Next Steps diff --git a/docs/GENERAL_ARCHITECTURE.md b/docs/GENERAL_ARCHITECTURE.md index b94b8caf..95ca6a9e 100644 --- a/docs/GENERAL_ARCHITECTURE.md +++ b/docs/GENERAL_ARCHITECTURE.md @@ -1,11 +1,13 @@ # Architecture Overview -This doc goes over the overall architecture of the app, the libraries usage and the locations of files and directories. + In this guide, we'll provide you with a clear understanding of the app's structure, the usage of libraries, and the locations of important files and directories. * [Structure of the Project](#Structure-of-the-Project) * [Overall Architecture](#Overall-Architecture) * [Coroutines and Ktor](#Coroutines-and-Ktor) * [Libraries and Dependencies](#Libraries-and-Dependencies) + * [SKIE](#SKIE) - Swift-friendly API generator + * [Kermit](#Kermit) - Logging * [SqlDelight](#SqlDelight) - Database * [Ktor](#Ktor) - Networking * [Multiplatform Settings](#Multiplatform-Settings) - Settings @@ -14,109 +16,107 @@ This doc goes over the overall architecture of the app, the libraries usage and ## Structure of the Project -KaMP Kit is broken up into three different directories: +KaMP Kit is organized into three main directories: * shared * app * ios -The app directory holds the android version of the app, and all the android code. As a default, Android Studio will name the project "app" when creating it. Even though this can be confusing for kmp this is the default. +The app directory contains the Android version of the app, complete with Android-specific code. The name "app" is the default name assigned by Android Studio during project creation -Similarly the ios directory holds the iOS version of the app, which contains an Xcode project and a Workspace. We want to use the workspace as it contains the shared library. +Similarly, the ios directory houses the iOS version of the app. This directory includes an Xcode project and workspace. For better compatibility, it's recommended to use the workspace as it incorporates the shared library. -Finally the shared directory holds the shared code. The shared directory is actually an android library that is referenced from the app project. This library contains directories for the different platforms as well as directories for testing. +The **shared** directory is crucial as it contains the shared codebase. The shared directory is actually a library project that is referenced from the app project. Within this library, you'll find separate directories for various platforms and testing: * androidMain * iosMain * commonMain - * androidTest + * androidUnitTest * iosTest * commonTest -Each of these directories has the same folder structure: the language type, then the package name. - i.e. *"kotlin/co/touchlab/kampkit/"* +Each of these directories maintains a consistent structure: the programming language followed by the package name (e.g., *"kotlin/co/touchlab/kampkit/"*). ## Overall Architecture #### Platform -KaMP Kit, whether running in Android or iOS, starts with the platforms View (`MainActivity` / `ViewController`). These are the standard UI classes for each platform and they are launched when the app starts. They are responsible for all the UI, including dealing with the RecyclerView/UITableView, getting input from the user and handling the views lifecycle. +KaMP Kit app, whether running in Android or iOS, starts with the platforms View (`MainActivity` / `ViewController`). These components serve as the standard UI interfaces for each platform and initiate upon app launch. They handle all aspects of the user interface, including RecyclerView/UITableView, user input, and view lifecycle management. + #### ViewModel -From the platforms views we then have the ViewModel layer which is responsible for connecting our -shared data and the views. If you want your shared viewmodel to be an `androidx.lifecycle.ViewModel` -on the Android side, you can take either a composition or inheritence approach. The composition -approach has a `CommonViewModel` that's just an object, and then wraps it in Android or iOS classes -that do the platform-specific work. An advantage is flexibility - the platform view models can do -extra platform-specific transformations more easily, and can more easily expose platform-specific -members that aren't delegated to common, but on the other hand Android has a wrapper layer that will -often be a no-op. For this project we chose the inheritence approach, because Android can use the +From the platforms views we then have the ViewModel layer that bridges our shared data with the views. + +If you want your shared viewmodel to be an `androidx.lifecycle.ViewModel` +on the Android side, you can take either a composition or inheritence approach. + +For this project we chose the inheritence approach, because Android can use the common viewmodel directly. To enable sharing of presentation logic between platforms, we define `expect abstract class ViewModel` in `commonMain`, with platform specific implementations provided in `androidMain` and `iosMain`. The android implementation simply extends the Jetpack -ViewModel, while an equivalent is implemented for iOS. We don't want to manage the coroutine -lifecycle on Android because the platform has its own scope handling. We just want to make sure our -viewmodel-layer coroutines are tied to the provided viewModelScope, and Android can directly consume -coroutine artifacts. On iOS, we need to manage that more explicitly. That means we have our own -scope and iOS consumers need to explicitly close it when the screen ends. We use `MainScope()` to -define coroutine scopes. This is a function from the kotlinx library that's just implemented -as `CoroutineScope(Dispatchers.Main + SupervisorJob())`. You may want to also add an error-handler -or other things, so consider a custom function in that case. An additional class `CallbackViewModel` -is also included for the iOS implementation. This acts as a wrapper for our ViewModel implementation -to make it easier to interact with from swift. We want to be able to wrap our flows in callbacks -that can be wired into our Swift code. Then inside our iOS viewmodel we define an extension function -on Flow to make conversion and scope-handling easy. There are different ways you might want to -consume this depending on your stack/architecture. We convert `FlowAdapter`s to `Publisher`s which -we then map inside `ObservableObjects` to `Published` values that can be easily observed from -SwiftUI views. With these platform specific implementations we can now implement our -ViewModel (`BreedViewModel`) in the common MultiPlatform code. +ViewModel, while an equivalent is implemented for iOS. + +`ViewModel` sharing used to a bit more convoluted but now with Touchlab's [Skie](#Skie) tool, iOS code can reference the common `BreedViewModel` directly. #### Repository -The `BreedRepository` is in the common MultiPlatform code, and handles the data access functionality. The `BreedRepository` references the Multiplatform-Settings, and two helper classes: `DogApiImpl` (which implements `DogApi`) and `DatabaseHelper`. The `DatabaseHelper` and `DogApiImpl` both use Multiplatform libraries to retrieve data and send it back to the `BreedRepository`. +The `BreedRepository` resides in the common Multiplatform code and handles data access functions. This repository references the `Multiplatform-Settings` library, as well as two auxiliary classes: `DogApiImpl` (implementing `DogApi`) and `DatabaseHelper`. Both `DatabaseHelper` and `DogApiImpl` utilize Multiplatform libraries to fetch and manage data, forwarding it to the `BreedRepository`. -> note that the BreedRepository references the interface DogApi. This is so we can test the Model using a Mock Api +> Note that the BreedRepository references the interface DogApi. This is so we can test the Model using a Mock Api -In this implementation the ViewModel listens to the database as a flow, so that when any changes occur to the database it will then call the callback it was passed. When breeds are requested the model retrieves the information from the network, then saves the data to the database. This triggers the database flow to send the new data to the Platform to update the display. +In this implementation the ViewModel listens to the database as a flow, so that when any changes occur to the database it will then call the callback it was passed. When breed data is requested, the model fetches it from the network and saves it to the database. This, in turn, triggers the database flow to update the platform for display updates. In Short: -**Platform -> BreedCallbackViewModel (iOS only) -> BreedViewModel -> BreedRepository -> DogApiImpl -> BreedModel -> DatabaseHelper -> BreedRepository -> BreedViewModel -> BreedCallbackViewModel (iOS only) -> Platform** +**Platform -> BreedViewModel -> BreedRepository -> DogApiImpl -> BreedModel -> DatabaseHelper -> BreedRepository -> BreedViewModel -> Platform** -You may be asking where the Multiplatform-settings comes in. When the BreedModel is told to get breeds from the network, it first checks to see if it's done a network request within the past hour. If it has then it decides not to update the breeds. +You may be asking where the `Multiplatform-settings` comes in. When the BreedModel is told to get breeds from the network, it first checks to see if it's done a network request within the past hour. If it has then it decides not to update the breeds. ## Kotlinx Coroutines -We use a new version of Kotlinx Coroutines that uses a new memory model that solves the problems -with multithreading and freezing objects. To learn more -check [Migration Guide](https://github.com/JetBrains/kotlin/blob/master/kotlin-native/NEW_MM.md) +We use a new version of Kotlinx Coroutines that uses a new memory model that resolves the multithreading and object freezing concerns. To learn more, refer to the [Migration Guide](https://github.com/JetBrains/kotlin/blob/master/kotlin-native/NEW_MM.md) and [our Blogpost](https://touchlab.co/testing-the-kotlin-native-memory-model/). -See [DogApiImpl.kt](https://github.com/touchlab/KaMPKit/blob/5376b4c2dd4be7f2436e10dddbf56b0d5ab33443/shared/src/commonMain/kotlin/co/touchlab/kampkit/ktor/DogApiImpl.kt#L36) + Explore the implementations in [DogApiImpl.kt](https://github.com/touchlab/KaMPKit/blob/5376b4c2dd4be7f2436e10dddbf56b0d5ab33443/shared/src/commonMain/kotlin/co/touchlab/kampkit/ktor/DogApiImpl.kt#L36) and [BreedModel.kt](https://github.com/touchlab/KaMPKit/blob/b2e8a330f8c12429255711c4c55a328885615d8b/shared/src/commonMain/kotlin/co/touchlab/kampkit/models/BreedModel.kt#L49) ## Libraries and Dependencies -If you're familiar with Android projects then you know that the apps dependencies are stored in the build.gradle. Since shared is an android library, it also contains its own build.gradle where it stores its own dependencies. If you open *"shared/build.gradle.kts"* you will see **sourceSets** corresponding to the directories in the shared project. +If you're familiar with Android projects then you know that the apps dependencies are stored in the `build.gradle.kts`. Since shared is a library project, it also contains its own `build.gradle.kts` where it defines its own dependencies. If you open *`shared/build.gradle.kts`* you will see **`sourceSets`** corresponding to the directories in the shared project. -Each part of the shared library can declare its own dependencies in these source sets. For example the multiplatform-settings library is only declared in **commonMain** and **commonTest**, since the library uses gradle metadata to pull in the correct platform specific dependencies. Other libraries that may require platform specific variables, such as SqlDelight, require platform specific dependencies. With this example you can see that **commonMain** has `sqlDelight.runtime` and **androidMain** has `sqlDelight.driverAndroid`. +Each part of the shared library can declare its own dependencies in these source sets. For example the `multiplatform-settings` library is only declared for the **commonMain** and **commonTest**, since the multiplatform gradle plugin uses hierarchical project structure to pull in the correct platform specific dependencies. Other libraries like `SqlDelight`, which necessitate platform-specific variables, require distinct platform dependencies. Consider the example of `commonMain` using `sqlDelight.runtime`, while `androidMain` utilizes `sqlDelight.driverAndroid`. Below is some information about some of the libraries used in the project. +* [SKIE](#SKIE) +* [Kermit](#Kermit) * [SqlDelight](#SqlDelight) * [Ktor](#Ktor) * [Multiplatform Settings](#Multiplatform-Settings) * [Koin](#Koin) * [Turbine](#Turbine) +### SKIE + +Documentation: https://skie.touchlab.co/intro + +SKIE is setup as a Gradle plugin. SKIE runs during compile-time, generating Kotlin IR and Swift code. The Swift code is compiled and linked directly into the Xcode Framework produced by the Kotlin compiler, requiring no changes for your code distribution. + +SKIE streamlines iOS code, reducing the preceding boilerplate. Suspend functions and flows are automatically translated into Swift-style async functions or streams. Additionally, SKIE simplifies the conversion between Kotlin sealed classes and Swift enums, facilitating more idiomatic and exhaustive switches in Swift. + +### Kermit + +Documentation: https://kermit.touchlab.co/ + +Kermit is a Kotlin Multiplatform logging library. It's as easy as it can get logging library. The default platform `LogWriter` is readily available without any setup hassles. + ### SqlDelight Documentation: [https://github.com/cashapp/sqldelight](https://github.com/cashapp/sqldelight) Usage in the project: *commonMain/kotlin/co/touchlab/kampkit/DatabaseHelper.kt* SQL Location in the project: *commonMain/sqldelight/co/touchlab/kampkit/Table.sq* - SqlDelight is a multiplatform SQL library that generates type-safe APIs from SQL Statements. Since it is a multiplatform library, it naturally uses code stored in commonMain. SQL statements are stored in the sqldelight directory, in .sq files. ex: *"commonMain/sqldelight/co/touchlab/kampkit/Table.sq"* -Even though the SQL queries and main bulk of the library are in the common code, there are some platform specific drivers required from Android and iOS in order to work correctly on each platform. These are the `AndroidSqliteDriver` and the `NativeSqliteDriver`(for iOS). These are passed in from platform specific code, in this case injected into the **BreedModel**. The APIs are stored in the build folder, and referenced from the `DatabaseHelper` (also in commonMain). +Even though the SQL queries and main bulk of the library are in the common code, there are some platform specific drivers required from Android and iOS in order to work correctly on each platform. These are the `AndroidSqliteDriver` and the `NativeSqliteDriver`(for iOS). These are passed in from platform specific code, in this case injected into the **BreedModel**. The APIs are stored in the build folder, and referenced from the `DatabaseHelper` (also in commonMain). ##### Flow Normally sql queries are called, and a result is given, but what if you want to get sql query as a flow? We've added Coroutine Extensions to the shared code, which adds the `asFlow` function that converts queries into flows. Behind the scenes this creates a Query Listener that when a query result has changed, emits the new value to the flow. @@ -126,42 +126,44 @@ Documentation: https://ktor.io/ Usage in the project: *commonMain/kotlin/co/touchlab/kampkit/ktor/DogApiImpl.kt* -Ktor is a multiplatform networking library for building asynchronous clients. Again since it is a multiplatform library, it uses code stored in commonMain. Even though all of Ktors code is in **commonMain**, there are some platform specific dependencies needed in the build.gradle. +Ktor, a multiplatform networking library, facilitates asynchronous client creation. Although the entirety of Ktor's code is housed in `commonMain`, specific platform dependencies are necessary for proper functionality. These dependencies are outlined in the build.gradle. ### Multiplatform Settings Documentation: https://github.com/russhwolf/multiplatform-settings Usage in the project: *commonMain/kotlin/co/touchlab/kampkit/models/BreedModel.kt* -Multiplatform settings really speaks for itself, it persists data by storing it in settings. It is being used in the BreedModel, and acts similarly to a `HashMap` or `Dictionary`. Much like SqlDelight the actual internals of the settings are platform specific, so the settings are passed in from the platform and all of the actual saving and loading is in the common code. +Multiplatform settings really speaks for itself. It persists data by storing it in settings. It is being used in the `BreedModel`, and acts similarly to a `HashMap` or `Dictionary`. Much like `SqlDelight` the actual internals of the settings are platform specific, so the settings are passed in from the platform and all of the actual saving and loading is in the common code. ### Koin Documentation: https://insert-koin.io/ Usage in the project: *commonMain/kotlin/co/touchlab/kampkit/Koin.kt* -Koin is a lightweight dependency injection framework. It is being used in the *koin.kt* file to inject modules into the BreedModel. You can tell which variables are being injected in the BreedModel because they are being set using `by inject()`. In our implementation we are separating our injections into two different modules: the `coreModule` and the `platformModule`. As you can guess the platformModule contains injections requiring platform specific implementations (SqlDelight and Multiplatform Settings). The coreModule contains the Ktor implementation and the Database Helper, which actually takes from the platformModule. +Koin is a lightweight dependency injection framework. It is being used in the *koin.kt* file to inject modules into the BreedModel. + +Injected variables within the BreedModel are marked using by inject(). We've structured injections into two modules: coreModule and platformModule. The former houses `Ktor` and `Database Helper` implementations, while the latter encompasses platform-specific dependencies (`SqlDelight` and `Multiplatform Settings`). ## Testing -With KMP you can share tests between platforms, however since we have platform specific drivers and -dependencies our tests need to be run by the individual platforms. In short we can share tests but -we have to run them separately as android and iOS. The shared tests can be found in the commonTest -directory, while the implementations can be found in the androidTest and iosTest directories. You -may be thinking: Weren't the libraries injected? How does the dependency injection work? Well that -is actually handled in the `TestUtil.kt` file in commonTest. In order to pass the platform specific -libraries(SqlDelight requires a platform driver) into the BreedRepository for testing we need to -inject them. For running tests we use `kotlinx.coroutines.test.runTest`. For specifying a test -runner we use `@RunWith()` annotation. Note that the actual `testDbConnection()` implementations are -in the *TestUtilAndroid.kt* and *TestUtilIOS.kt* files. +With KMP, tests can be shared across platforms. However, due to platform-specific drivers and dependencies, tests must be executed on individual platforms. In essence, while tests can be shared, they must be run separately for Android and iOS. + +The shared tests can be found in the `commonTest` +directory, while the implementations can be found in the `androidTest` and `iosTest` directories. + +Dependency injection for testing is managed in the `TestUtil.kt` file in `commonTest`. This file facilitates the injection of platform-specific libraries (for instance, `SqlDelight` requiring a platform driver) into the `BreedRepository` to enable effective testing. + +For running tests we use `kotlinx.coroutines.test.runTest`. For specifying a test +runner we use `@RunWith()` annotation. Platform-specific implementations of `testDbConnection()` are stored in *TestUtilAndroid.kt* and *TestUtilIOS.kt*. ### Turbine Check out the [Repository](https://github.com/cashapp/turbine) for more info. -Turbine is a small testing library for kotlinx.coroutines Flow. -For example: `BreedViewModelTest.kt`. -#### Android -On the android side we are using AndroidRunner to run the tests because we want to use android specifics in our tests. If you're not using android specific methods then you don't need to use AndroidRunner. The android tests are run can be easily run in Android Studio by right clicking on the shared folder, and selecting `Run 'All Tests'`. +Turbine is a small testing library for `kotlinx.coroutines Flow`. +A practical example can be found in `BreedViewModelTest.kt`. + +### Android +On the android side we are using `AndroidRunner` to run the tests because we want to use android specifics in our tests. If you're not using android specific methods then you don't need to use `AndroidRunner`. The android tests are run can be easily run in Android Studio by right clicking on the folder, and selecting `Run 'All Tests'`. -#### iOS +### iOS iOS tests have their own gradle task allowing them to run with an iOS simulator. You can simply go to the terminal and run `./gradlew iosTest`. diff --git a/docs/IOS_PROJ_INTEGRATION.md b/docs/IOS_PROJ_INTEGRATION.md index 7c4cf211..1f249b14 100644 --- a/docs/IOS_PROJ_INTEGRATION.md +++ b/docs/IOS_PROJ_INTEGRATION.md @@ -66,7 +66,7 @@ following into your new `Podfile`: ``` use_frameworks! -platform :ios, '12.4' +platform :ios, '15.0' install! 'cocoapods', :deterministic_uuids => false diff --git a/docs/WHAT_AND_WHY.md b/docs/WHAT_AND_WHY.md index 06f3e0bc..51c219c2 100644 --- a/docs/WHAT_AND_WHY.md +++ b/docs/WHAT_AND_WHY.md @@ -1,6 +1,6 @@ # KMP: What and Why? -## 2022 Update +## 2023 Update This document describes the original vision and goals of KaMP Kit. Many of these ideas have evolved since then, but this writeup is still here so you can see where it all came from. @@ -17,10 +17,12 @@ You can transfer your skills and code to any task at hand. Kotlin can output JVM bytecode, Javascript, and an array of LLVM-based native executable code. Describing the entirety of KMP would take some time, but the KaMP Kit is focused on native mobile development, so we’ll speak to that specifiically. + KMP enables optional shared architecture and logic, that can be used in both Android and iOS. Kotlin is already the default language for Android, which means unlike all other “cross platform” options, it is fully “native” to the platform (and, really, any JVM environment). + On iOS, the Kotlin Native compiler generates an Xcode Framework that you can include into Xcode and call from Swift or Objective-C. Using [a 3rd party plugin](https://github.com/touchlab/xcode-kotlin) (*cough* by Touchlab *cough*) you can debug Kotlin directly in Xcode. iOS developers (soon to be “mobile developers”) can stick to the tools they currently @@ -29,7 +31,7 @@ Integrating Kotlin is not an abrupt and dramatic (ie RISKY) change to your team ## What is this Kit? -KMP is new tech, supporting many features and platforms, and has had rapid development over the past 2-3 years. As a +KMP is new tech, supporting many features and platforms, and has had rapid development over the past 3 years. As a result, the documentation ecosystem right now can be difficult to navigate. The official Jetbrains docs cover a wide range of options but can be difficult to navigate. That situation is being addressed, at the same time that the platform itself is stabilizing. The documentation out on the web is often more focused on mobile specifically, but is all over diff --git a/gradle.properties b/gradle.properties index cb13eb29..6521b23d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -15,5 +15,7 @@ android.useAndroidX=true kotlin.code.style=official # Tell the KMM plugin where the iOS project lives xcodeproj=./ios +kotlin.native.cacheKind.iosX64=none +kotlin.native.cacheKind.iosArm64=none # New Android source-set layout kotlin.mpp.androidSourceSetLayoutVersion=2 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5a530eba..26d5d5de 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,18 +1,18 @@ [versions] -kotlin = "1.8.21" +kotlin = "1.9.0" ## SDK Versions minSdk = "21" -targetSdk = "33" -compileSdk = "33" +targetSdk = "34" +compileSdk = "34" # Dependencies -android-gradle-plugin = "7.4.2" +android-gradle-plugin = "8.0.2" ktlint-gradle = "11.4.2" gradle-versions = "0.47.0" -compose = "1.4.3" -composeCompiler = "1.4.7" +compose = "1.5.0" +composeCompiler = "1.5.2" android-desugaring = "2.0.3" androidx-core = "1.10.1" @@ -29,11 +29,12 @@ ktor = "2.3.3" robolectric = "4.10.3" kermit = "2.0.0-RC3" +skie = "0.4.18-preview" -koin = "3.4.1" +koin = "3.4.3" multiplatformSettings = "1.0.0" turbine = "1.0.0" -sqlDelight = "2.0.0-rc01" +sqlDelight = "2.0.0" [libraries] android-desugaring = { module = "com.android.tools:desugar_jdk_libs", version.ref = "android-desugaring" } @@ -82,6 +83,7 @@ sqlDelight-runtime = { module = "app.cash.sqldelight:runtime", version.ref = "sq touchlab-kermit = { module = "co.touchlab:kermit", version.ref = "kermit" } touchlab-kermit-simple = { module = "co.touchlab:kermit-simple", version.ref = "kermit" } +touchlab-skie-annotations = { module = "co.touchlab.skie:configuration-annotations", version.ref = "skie" } turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index b710a249..8d99c1fb 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ -#Thu Feb 16 12:29:00 CST 2023 +#Tue Aug 29 21:56:54 EDT 2023 distributionBase=GRADLE_USER_HOME -distributionUrl=https\://services.gradle.org/distributions/gradle-7.6-all.zip distributionPath=wrapper/dists -zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/ios/KaMPKitiOS.xcodeproj/project.pbxproj b/ios/KaMPKitiOS.xcodeproj/project.pbxproj index 765a0360..f80440ab 100644 --- a/ios/KaMPKitiOS.xcodeproj/project.pbxproj +++ b/ios/KaMPKitiOS.xcodeproj/project.pbxproj @@ -8,7 +8,6 @@ /* Begin PBXBuildFile section */ 3DFF917C64A18A83DA010EE1 /* Pods_KaMPKitiOS.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B859F3FB23133D22AB9DD835 /* Pods_KaMPKitiOS.framework */; }; - 461C74AA2788F5F3004B1FFC /* CombineAdapters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 461C74A92788F5F3004B1FFC /* CombineAdapters.swift */; }; 46A5B5EF26AF54F7002EFEAA /* BreedListScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46A5B5EE26AF54F7002EFEAA /* BreedListScreen.swift */; }; 46A5B60826B04921002EFEAA /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 46A5B60626B04920002EFEAA /* Main.storyboard */; }; 46B5284D249C5CF400A7725D /* Koin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46B5284C249C5CF400A7725D /* Koin.swift */; }; @@ -39,7 +38,6 @@ /* Begin PBXFileReference section */ 1DFCC00C8DAA719770A18D1A /* Pods-KaMPKitiOS.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-KaMPKitiOS.release.xcconfig"; path = "Pods/Target Support Files/Pods-KaMPKitiOS/Pods-KaMPKitiOS.release.xcconfig"; sourceTree = ""; }; 2A1ED6A4A2A53F5F75C58E5F /* Pods-KaMPKitiOS.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-KaMPKitiOS.release.xcconfig"; path = "Pods/Target Support Files/Pods-KaMPKitiOS/Pods-KaMPKitiOS.release.xcconfig"; sourceTree = ""; }; - 461C74A92788F5F3004B1FFC /* CombineAdapters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CombineAdapters.swift; sourceTree = ""; }; 46A5B5EE26AF54F7002EFEAA /* BreedListScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BreedListScreen.swift; sourceTree = ""; }; 46A5B60726B04920002EFEAA /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 46B5284C249C5CF400A7725D /* Koin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Koin.swift; sourceTree = ""; }; @@ -136,7 +134,6 @@ F1465F0B23AA94BF0055F7C3 /* LaunchScreen.storyboard */, F1465F0E23AA94BF0055F7C3 /* Info.plist */, 46A5B5EE26AF54F7002EFEAA /* BreedListScreen.swift */, - 461C74A92788F5F3004B1FFC /* CombineAdapters.swift */, ); path = KaMPKitiOS; sourceTree = ""; @@ -354,7 +351,6 @@ buildActionMask = 2147483647; files = ( 46B5284D249C5CF400A7725D /* Koin.swift in Sources */, - 461C74AA2788F5F3004B1FFC /* CombineAdapters.swift in Sources */, 46A5B5EF26AF54F7002EFEAA /* BreedListScreen.swift in Sources */, F1465F0123AA94BF0055F7C3 /* AppDelegate.swift in Sources */, ); @@ -464,7 +460,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.2; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -522,7 +518,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.2; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -542,6 +538,7 @@ CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = 8UD86646U9; INFOPLIST_FILE = KaMPKitiOS/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -570,6 +567,7 @@ CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = 8UD86646U9; INFOPLIST_FILE = KaMPKitiOS/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -596,7 +594,7 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; INFOPLIST_FILE = KaMPKitiOSTests/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 13.2; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -617,7 +615,7 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; INFOPLIST_FILE = KaMPKitiOSTests/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 13.2; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/ios/KaMPKitiOS/BreedListScreen.swift b/ios/KaMPKitiOS/BreedListScreen.swift index 8cda285d..fce06e06 100644 --- a/ios/KaMPKitiOS/BreedListScreen.swift +++ b/ios/KaMPKitiOS/BreedListScreen.swift @@ -6,109 +6,87 @@ // Copyright © 2021 Touchlab. All rights reserved. // -import Combine import SwiftUI import shared -private let log = koin.loggerWithTag(tag: "ViewController") +private let log = koin.loggerWithTag(tag: "BreedListScreen") -class ObservableBreedModel: ObservableObject { - private var viewModel: BreedCallbackViewModel? - - @Published - var loading = false - - @Published - var breeds: [Breed]? - - @Published - var error: String? - - private var cancellables = [AnyCancellable]() - - func activate() { - let viewModel = KotlinDependencies.shared.getBreedViewModel() - - doPublish(viewModel.breeds) { [weak self] dogsState in - self?.loading = dogsState.isLoading - self?.breeds = dogsState.breeds - self?.error = dogsState.error - - if let breeds = dogsState.breeds { - log.d(message: {"View updating with \(breeds.count) breeds"}) - } - if let errorMessage = dogsState.error { - log.e(message: {"Displaying error: \(errorMessage)"}) - } - }.store(in: &cancellables) - - self.viewModel = viewModel - } - - func deactivate() { - cancellables.forEach { $0.cancel() } - cancellables.removeAll() - - viewModel?.clear() - viewModel = nil - } - - func onBreedFavorite(_ breed: Breed) { - viewModel?.updateBreedFavorite(breed: breed) - } +struct BreedListScreen: View { - func refresh() { - viewModel?.refreshBreeds() - } -} + @State + var viewModel: BreedViewModel? -struct BreedListScreen: View { - @StateObject - var observableModel = ObservableBreedModel() + @State + var breedState: BreedViewState = .Initial.shared var body: some View { BreedListContent( - loading: observableModel.loading, - breeds: observableModel.breeds, - error: observableModel.error, - onBreedFavorite: { observableModel.onBreedFavorite($0) }, - refresh: { observableModel.refresh() } + state: breedState, + onBreedFavorite: { breed in + Task { + try? await viewModel?.updateBreedFavorite(breed: breed) + } + }, + refresh: { + Task { + try? await viewModel?.refreshBreeds() + } + } ) - .onAppear(perform: { - observableModel.activate() - }) - .onDisappear(perform: { - observableModel.deactivate() - }) + .task { + let viewModel = KotlinDependencies.shared.getBreedViewModel() + await withTaskCancellationHandler( + operation: { + self.viewModel = viewModel + Task { + try? await viewModel.activate() + } + for await breedState in viewModel.breedState { + self.breedState = breedState + } + }, + onCancel: { + viewModel.clear() + self.viewModel = nil + } + ) + } } } struct BreedListContent: View { - var loading: Bool - var breeds: [Breed]? - var error: String? + var state: BreedViewState var onBreedFavorite: (Breed) -> Void var refresh: () -> Void var body: some View { ZStack { VStack { - if let breeds = breeds { - List(breeds, id: \.id) { breed in + switch onEnum(of: state) { + case .content(let content): + List(content.breeds, id: \.id) { breed in BreedRowView(breed: breed) { onBreedFavorite(breed) } } - } - if let error = error { - Text(error) + case .error(let error): + Spacer() + Text(error.error) .foregroundColor(.red) + Spacer() + case .empty: + Spacer() + Text("Sorry, no doggos found") + Spacer() + case .initial: + Spacer() } + Button("Refresh") { refresh() } } - if loading { Text("Loading...") } + if state.isLoading { Text("Loading...") } } } } @@ -132,15 +110,30 @@ struct BreedRowView: View { struct BreedListScreen_Previews: PreviewProvider { static var previews: some View { - BreedListContent( - loading: false, - breeds: [ - Breed(id: 0, name: "appenzeller", favorite: false), - Breed(id: 1, name: "australian", favorite: true) - ], - error: nil, - onBreedFavorite: { _ in }, - refresh: {} - ) + Group { + BreedListContent( + state: .Content(breeds: [ + Breed(id: 0, name: "appenzeller", favorite: false), + Breed(id: 1, name: "australian", favorite: true) + ]), + onBreedFavorite: { _ in }, + refresh: {} + ) + BreedListContent( + state: .Initial.shared, + onBreedFavorite: { _ in }, + refresh: {} + ) + BreedListContent( + state: .Empty(), + onBreedFavorite: { _ in }, + refresh: {} + ) + BreedListContent( + state: .Error(error: "Something went wrong!"), + onBreedFavorite: { _ in }, + refresh: {} + ) + } } } diff --git a/ios/KaMPKitiOS/CombineAdapters.swift b/ios/KaMPKitiOS/CombineAdapters.swift deleted file mode 100644 index 53435445..00000000 --- a/ios/KaMPKitiOS/CombineAdapters.swift +++ /dev/null @@ -1,40 +0,0 @@ -import Combine -import shared - -/// Create a Combine publisher from the supplied `FlowAdapter`. Use this in contexts where more transformation will be -/// done on the Swift side before the value is bound to UI -func createPublisher(_ flowAdapter: FlowAdapter) -> AnyPublisher { - return Deferred>> { - let subject = PassthroughSubject() - let canceller = flowAdapter.subscribe( - onEach: { item in subject.send(item) }, - onComplete: { subject.send(completion: .finished) }, - onThrow: { error in subject.send(completion: .failure(KotlinError(error))) } - ) - return subject.handleEvents(receiveCancel: { canceller.cancel() }) - }.eraseToAnyPublisher() -} - -/// Prepare the supplied `FlowAdapter` to be bound to UI. The `onEach` callback will be called from `DispatchQueue.main` -/// on every new emission. -/// -/// Note that this calls `assertNoFailure()` internally so you should handle errors upstream to avoid crashes. -func doPublish(_ flowAdapter: FlowAdapter, onEach: @escaping (T) -> Void) -> Cancellable { - return createPublisher(flowAdapter) - .assertNoFailure() - .compactMap { $0 } - .receive(on: DispatchQueue.main) - .sink { onEach($0) } -} - -/// Wraps a `KotlinThrowable` in a `LocalizedError` which can be used as a Combine error type -class KotlinError: LocalizedError { - let throwable: KotlinThrowable - - init(_ throwable: KotlinThrowable) { - self.throwable = throwable - } - var errorDescription: String? { - throwable.message - } -} diff --git a/ios/Podfile b/ios/Podfile index 1c87ed2d..fc8bb520 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -1,6 +1,6 @@ use_frameworks! -platform :ios, '12.4' +platform :ios, '15.0' install! 'cocoapods', :deterministic_uuids => false diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 0d481a6a..6a15f370 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -15,9 +15,9 @@ EXTERNAL SOURCES: :path: "../shared/" SPEC CHECKSUMS: - shared: 153082a09d0db819a966647739a6809fed9eff56 + shared: de4d9686452f92be2911e6312a47b0d2961b2dd6 SwiftLint: c585ebd615d9520d7fbdbe151f527977b0534f1e -PODFILE CHECKSUM: d5a73f50a47bad1893e4fbf8978f1bef946ebdf6 +PODFILE CHECKSUM: 3130ca9ec3cd58f2c60cfe8a432d7d96d89938bf -COCOAPODS: 1.11.3 +COCOAPODS: 1.12.1 diff --git a/ios/Pods/Local Podspecs/shared.podspec.json b/ios/Pods/Local Podspecs/shared.podspec.json index 9f2f93a2..a45124c9 100644 --- a/ios/Pods/Local Podspecs/shared.podspec.json +++ b/ios/Pods/Local Podspecs/shared.podspec.json @@ -10,9 +10,6 @@ "summary": "Common library for the KaMP starter kit", "vendored_frameworks": "build/cocoapods/framework/shared.framework", "libraries": "c++", - "platforms": { - "ios": "12.4" - }, "pod_target_xcconfig": { "KOTLIN_PROJECT_PATH": ":shared", "PRODUCT_MODULE_NAME": "shared" @@ -24,5 +21,11 @@ "shell_path": "/bin/sh", "script": " if [ \"YES\" = \"$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED\" ]; then\n echo \"Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \"YES\"\"\n exit 0\n fi\n set -ev\n REPO_ROOT=\"$PODS_TARGET_SRCROOT\"\n \"$REPO_ROOT/../gradlew\" -p \"$REPO_ROOT\" $KOTLIN_PROJECT_PATH:syncFramework -Pkotlin.native.cocoapods.platform=$PLATFORM_NAME -Pkotlin.native.cocoapods.archs=\"$ARCHS\" -Pkotlin.native.cocoapods.configuration=\"$CONFIGURATION\"\n" } - ] + ], + "platforms": { + "osx": null, + "ios": null, + "tvos": null, + "watchos": null + } } diff --git a/ios/Pods/Manifest.lock b/ios/Pods/Manifest.lock index 0d481a6a..6a15f370 100644 --- a/ios/Pods/Manifest.lock +++ b/ios/Pods/Manifest.lock @@ -15,9 +15,9 @@ EXTERNAL SOURCES: :path: "../shared/" SPEC CHECKSUMS: - shared: 153082a09d0db819a966647739a6809fed9eff56 + shared: de4d9686452f92be2911e6312a47b0d2961b2dd6 SwiftLint: c585ebd615d9520d7fbdbe151f527977b0534f1e -PODFILE CHECKSUM: d5a73f50a47bad1893e4fbf8978f1bef946ebdf6 +PODFILE CHECKSUM: 3130ca9ec3cd58f2c60cfe8a432d7d96d89938bf -COCOAPODS: 1.11.3 +COCOAPODS: 1.12.1 diff --git a/ios/Pods/Pods.xcodeproj/project.pbxproj b/ios/Pods/Pods.xcodeproj/project.pbxproj index 96c12b0b..5832709d 100644 --- a/ios/Pods/Pods.xcodeproj/project.pbxproj +++ b/ios/Pods/Pods.xcodeproj/project.pbxproj @@ -15,34 +15,37 @@ dependencies = ( ); name = SwiftLint; + productName = SwiftLint; }; 8777C9F6889E59EFFD631D80AEE9048B /* shared */ = { isa = PBXAggregateTarget; buildConfigurationList = 46EB2E000001A0 /* Build configuration list for PBXAggregateTarget "shared" */; buildPhases = ( - 46EB2E00000380 /* [CP-User] Build shared */, + 46EB2E000003A0 /* [CP-User] Build shared */, + 46EB2E00000390 /* [CP] Copy dSYMs */, ); dependencies = ( ); name = shared; + productName = shared; }; /* End PBXAggregateTarget section */ /* Begin PBXBuildFile section */ - 46EB2E000002B0 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 46EB2E000002A0 /* Foundation.framework */; }; - 46EB2E00000320 /* Pods-KaMPKitiOS-umbrella.h in Headers */ = {isa = PBXBuildFile; fileRef = 46EB2E00000310 /* Pods-KaMPKitiOS-umbrella.h */; settings = {ATTRIBUTES = (Public, ); }; }; - 46EB2E00000370 /* Pods-KaMPKitiOS-dummy.m in Sources */ = {isa = PBXBuildFile; fileRef = 46EB2E00000360 /* Pods-KaMPKitiOS-dummy.m */; }; + 46EB2E000002C0 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 46EB2E000002B0 /* Foundation.framework */; }; + 46EB2E00000330 /* Pods-KaMPKitiOS-umbrella.h in Headers */ = {isa = PBXBuildFile; fileRef = 46EB2E00000320 /* Pods-KaMPKitiOS-umbrella.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 46EB2E00000380 /* Pods-KaMPKitiOS-dummy.m in Sources */ = {isa = PBXBuildFile; fileRef = 46EB2E00000370 /* Pods-KaMPKitiOS-dummy.m */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ - 46EB2E00000390 /* PBXContainerItemProxy */ = { + 46EB2E000003B0 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 46EB2E00000000 /* Project object */; proxyType = 1; remoteGlobalIDString = 52B60EC2A583F24ACBB69C113F5488B9; remoteInfo = SwiftLint; }; - 46EB2E000003B0 /* PBXContainerItemProxy */ = { + 46EB2E000003D0 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 46EB2E00000000 /* Project object */; proxyType = 1; @@ -52,32 +55,33 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ - 46EB2E000000D0 /* Podfile */ = {isa = PBXFileReference; explicitFileType = text.script.ruby; includeInIndex = 1; indentWidth = 2; lastKnownFileType = text; name = Podfile; path = ../Podfile; sourceTree = SOURCE_ROOT; tabWidth = 2; xcLanguageSpecificationIdentifier = xcode.lang.ruby; }; + 46EB2E000000D0 /* Podfile */ = {isa = PBXFileReference; explicitFileType = text.script.ruby; includeInIndex = 1; indentWidth = 2; name = Podfile; path = ../Podfile; sourceTree = SOURCE_ROOT; tabWidth = 2; xcLanguageSpecificationIdentifier = xcode.lang.ruby; }; 46EB2E000000F0 /* shared.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = shared.framework; path = build/cocoapods/framework/shared.framework; sourceTree = ""; }; - 46EB2E00000110 /* shared.podspec */ = {isa = PBXFileReference; explicitFileType = text.script.ruby; includeInIndex = 1; indentWidth = 2; lastKnownFileType = text; path = shared.podspec; sourceTree = ""; tabWidth = 2; xcLanguageSpecificationIdentifier = xcode.lang.ruby; }; + 46EB2E00000110 /* shared.podspec */ = {isa = PBXFileReference; explicitFileType = text.script.ruby; includeInIndex = 1; indentWidth = 2; path = shared.podspec; sourceTree = ""; tabWidth = 2; xcLanguageSpecificationIdentifier = xcode.lang.ruby; }; 46EB2E00000170 /* SwiftLint.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; path = SwiftLint.debug.xcconfig; sourceTree = ""; }; 46EB2E00000180 /* SwiftLint.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; path = SwiftLint.release.xcconfig; sourceTree = ""; }; 46EB2E000001E0 /* shared.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; path = shared.debug.xcconfig; sourceTree = ""; }; 46EB2E000001F0 /* shared.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; path = shared.release.xcconfig; sourceTree = ""; }; - 46EB2E000002A0 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS14.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; - 46EB2E000002D0 /* Pods-KaMPKitiOS.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; path = "Pods-KaMPKitiOS.release.xcconfig"; sourceTree = ""; }; - 46EB2E000002E0 /* Pods-KaMPKitiOS.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; path = "Pods-KaMPKitiOS.debug.xcconfig"; sourceTree = ""; }; - 46EB2E000002F0 /* Pods-KaMPKitiOS-Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = "Pods-KaMPKitiOS-Info.plist"; sourceTree = ""; }; - 46EB2E00000300 /* Pods-KaMPKitiOS.modulemap */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.module; path = "Pods-KaMPKitiOS.modulemap"; sourceTree = ""; }; - 46EB2E00000310 /* Pods-KaMPKitiOS-umbrella.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = "Pods-KaMPKitiOS-umbrella.h"; sourceTree = ""; }; - 46EB2E00000330 /* Pods-KaMPKitiOS-frameworks.sh */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.script.sh; path = "Pods-KaMPKitiOS-frameworks.sh"; sourceTree = ""; }; - 46EB2E00000340 /* Pods-KaMPKitiOS-acknowledgements.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = "Pods-KaMPKitiOS-acknowledgements.plist"; sourceTree = ""; }; - 46EB2E00000350 /* Pods-KaMPKitiOS-acknowledgements.markdown */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text; path = "Pods-KaMPKitiOS-acknowledgements.markdown"; sourceTree = ""; }; - 46EB2E00000360 /* Pods-KaMPKitiOS-dummy.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; path = "Pods-KaMPKitiOS-dummy.m"; sourceTree = ""; }; + 46EB2E00000200 /* shared-copy-dsyms.sh */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.script.sh; path = "shared-copy-dsyms.sh"; sourceTree = ""; }; + 46EB2E000002B0 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS14.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; + 46EB2E000002E0 /* Pods-KaMPKitiOS.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; path = "Pods-KaMPKitiOS.release.xcconfig"; sourceTree = ""; }; + 46EB2E000002F0 /* Pods-KaMPKitiOS.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; path = "Pods-KaMPKitiOS.debug.xcconfig"; sourceTree = ""; }; + 46EB2E00000300 /* Pods-KaMPKitiOS-Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = "Pods-KaMPKitiOS-Info.plist"; sourceTree = ""; }; + 46EB2E00000310 /* Pods-KaMPKitiOS.modulemap */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.module; path = "Pods-KaMPKitiOS.modulemap"; sourceTree = ""; }; + 46EB2E00000320 /* Pods-KaMPKitiOS-umbrella.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = "Pods-KaMPKitiOS-umbrella.h"; sourceTree = ""; }; + 46EB2E00000340 /* Pods-KaMPKitiOS-frameworks.sh */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.script.sh; path = "Pods-KaMPKitiOS-frameworks.sh"; sourceTree = ""; }; + 46EB2E00000350 /* Pods-KaMPKitiOS-acknowledgements.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = "Pods-KaMPKitiOS-acknowledgements.plist"; sourceTree = ""; }; + 46EB2E00000360 /* Pods-KaMPKitiOS-acknowledgements.markdown */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text; path = "Pods-KaMPKitiOS-acknowledgements.markdown"; sourceTree = ""; }; + 46EB2E00000370 /* Pods-KaMPKitiOS-dummy.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; path = "Pods-KaMPKitiOS-dummy.m"; sourceTree = ""; }; 4EDB9C40CD3583B30579DB2BE865F1D3 /* Pods-KaMPKitiOS */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; name = "Pods-KaMPKitiOS"; path = Pods_KaMPKitiOS.framework; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ - 46EB2E00000270 /* Frameworks */ = { + 46EB2E00000280 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 46EB2E000002B0 /* Foundation.framework in Frameworks */, + 46EB2E000002C0 /* Foundation.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -107,7 +111,7 @@ 46EB2E00000060 /* Frameworks */ = { isa = PBXGroup; children = ( - 46EB2E00000290 /* iOS */, + 46EB2E000002A0 /* iOS */, ); name = Frameworks; sourceTree = ""; @@ -115,7 +119,7 @@ 46EB2E00000070 /* Targets Support Files */ = { isa = PBXGroup; children = ( - 46EB2E000002C0 /* Pods-KaMPKitiOS */, + 46EB2E000002D0 /* Pods-KaMPKitiOS */, ); name = "Targets Support Files"; sourceTree = ""; @@ -141,7 +145,6 @@ children = ( 46EB2E00000160 /* Support Files */, ); - name = SwiftLint; path = SwiftLint; sourceTree = ""; }; @@ -185,6 +188,7 @@ 46EB2E000001D0 /* Support Files */ = { isa = PBXGroup; children = ( + 46EB2E00000200 /* shared-copy-dsyms.sh */, 46EB2E000001E0 /* shared.debug.xcconfig */, 46EB2E000001F0 /* shared.release.xcconfig */, ); @@ -192,26 +196,26 @@ path = "../ios/Pods/Target Support Files/shared"; sourceTree = ""; }; - 46EB2E00000290 /* iOS */ = { + 46EB2E000002A0 /* iOS */ = { isa = PBXGroup; children = ( - 46EB2E000002A0 /* Foundation.framework */, + 46EB2E000002B0 /* Foundation.framework */, ); name = iOS; sourceTree = ""; }; - 46EB2E000002C0 /* Pods-KaMPKitiOS */ = { + 46EB2E000002D0 /* Pods-KaMPKitiOS */ = { isa = PBXGroup; children = ( - 46EB2E00000300 /* Pods-KaMPKitiOS.modulemap */, - 46EB2E00000350 /* Pods-KaMPKitiOS-acknowledgements.markdown */, - 46EB2E00000340 /* Pods-KaMPKitiOS-acknowledgements.plist */, - 46EB2E00000360 /* Pods-KaMPKitiOS-dummy.m */, - 46EB2E00000330 /* Pods-KaMPKitiOS-frameworks.sh */, - 46EB2E000002F0 /* Pods-KaMPKitiOS-Info.plist */, - 46EB2E00000310 /* Pods-KaMPKitiOS-umbrella.h */, - 46EB2E000002E0 /* Pods-KaMPKitiOS.debug.xcconfig */, - 46EB2E000002D0 /* Pods-KaMPKitiOS.release.xcconfig */, + 46EB2E00000310 /* Pods-KaMPKitiOS.modulemap */, + 46EB2E00000360 /* Pods-KaMPKitiOS-acknowledgements.markdown */, + 46EB2E00000350 /* Pods-KaMPKitiOS-acknowledgements.plist */, + 46EB2E00000370 /* Pods-KaMPKitiOS-dummy.m */, + 46EB2E00000340 /* Pods-KaMPKitiOS-frameworks.sh */, + 46EB2E00000300 /* Pods-KaMPKitiOS-Info.plist */, + 46EB2E00000320 /* Pods-KaMPKitiOS-umbrella.h */, + 46EB2E000002F0 /* Pods-KaMPKitiOS.debug.xcconfig */, + 46EB2E000002E0 /* Pods-KaMPKitiOS.release.xcconfig */, ); name = "Pods-KaMPKitiOS"; path = "Target Support Files/Pods-KaMPKitiOS"; @@ -220,11 +224,11 @@ /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ - 46EB2E00000250 /* Headers */ = { + 46EB2E00000260 /* Headers */ = { isa = PBXHeadersBuildPhase; buildActionMask = 2147483647; files = ( - 46EB2E00000320 /* Pods-KaMPKitiOS-umbrella.h in Headers */, + 46EB2E00000330 /* Pods-KaMPKitiOS-umbrella.h in Headers */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -233,18 +237,18 @@ /* Begin PBXNativeTarget section */ FA6E46E8E7462D72AFBDCEA698D24977 /* Pods-KaMPKitiOS */ = { isa = PBXNativeTarget; - buildConfigurationList = 46EB2E00000210 /* Build configuration list for PBXNativeTarget "Pods-KaMPKitiOS" */; + buildConfigurationList = 46EB2E00000220 /* Build configuration list for PBXNativeTarget "Pods-KaMPKitiOS" */; buildPhases = ( - 46EB2E00000250 /* Headers */, - 46EB2E00000260 /* Sources */, - 46EB2E00000270 /* Frameworks */, - 46EB2E00000280 /* Resources */, + 46EB2E00000260 /* Headers */, + 46EB2E00000270 /* Sources */, + 46EB2E00000280 /* Frameworks */, + 46EB2E00000290 /* Resources */, ); buildRules = ( ); dependencies = ( - 46EB2E000003A0 /* PBXTargetDependency */, 46EB2E000003C0 /* PBXTargetDependency */, + 46EB2E000003E0 /* PBXTargetDependency */, ); name = "Pods-KaMPKitiOS"; productName = Pods_KaMPKitiOS; @@ -281,7 +285,7 @@ /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ - 46EB2E00000280 /* Resources */ = { + 46EB2E00000290 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( @@ -291,7 +295,24 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ - 46EB2E00000380 /* [CP-User] Build shared */ = { + 46EB2E00000390 /* [CP] Copy dSYMs */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/shared/shared-copy-dsyms-input-files.xcfilelist", + ); + name = "[CP] Copy dSYMs"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/shared/shared-copy-dsyms-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/shared/shared-copy-dsyms.sh\"\n"; + showEnvVarsInLog = 0; + }; + 46EB2E000003A0 /* [CP-User] Build shared */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -304,28 +325,28 @@ /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ - 46EB2E00000260 /* Sources */ = { + 46EB2E00000270 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 46EB2E00000370 /* Pods-KaMPKitiOS-dummy.m in Sources */, + 46EB2E00000380 /* Pods-KaMPKitiOS-dummy.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ - 46EB2E000003A0 /* PBXTargetDependency */ = { + 46EB2E000003C0 /* PBXTargetDependency */ = { isa = PBXTargetDependency; name = SwiftLint; target = 52B60EC2A583F24ACBB69C113F5488B9 /* SwiftLint */; - targetProxy = 46EB2E00000390 /* PBXContainerItemProxy */; + targetProxy = 46EB2E000003B0 /* PBXContainerItemProxy */; }; - 46EB2E000003C0 /* PBXTargetDependency */ = { + 46EB2E000003E0 /* PBXTargetDependency */ = { isa = PBXTargetDependency; name = shared; target = 8777C9F6889E59EFFD631D80AEE9048B /* shared */; - targetProxy = 46EB2E000003B0 /* PBXContainerItemProxy */; + targetProxy = 46EB2E000003D0 /* PBXContainerItemProxy */; }; /* End PBXTargetDependency section */ @@ -383,7 +404,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.4; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -446,7 +467,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.4; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -464,7 +485,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - IPHONEOS_DEPLOYMENT_TARGET = 9.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -481,7 +502,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - IPHONEOS_DEPLOYMENT_TARGET = 9.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -497,8 +518,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CLANG_ENABLE_OBJC_WEAK = NO; - IPHONEOS_DEPLOYMENT_TARGET = 12.4; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -515,8 +535,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CLANG_ENABLE_OBJC_WEAK = NO; - IPHONEOS_DEPLOYMENT_TARGET = 12.4; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -526,9 +545,9 @@ }; name = Debug; }; - 46EB2E00000220 /* Release */ = { + 46EB2E00000230 /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 46EB2E000002D0 /* Pods-KaMPKitiOS.release.xcconfig */; + baseConfigurationReference = 46EB2E000002E0 /* Pods-KaMPKitiOS.release.xcconfig */; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO; CLANG_ENABLE_OBJC_WEAK = NO; @@ -542,7 +561,7 @@ DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = "Target Support Files/Pods-KaMPKitiOS/Pods-KaMPKitiOS-Info.plist"; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 12.4; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -564,9 +583,9 @@ }; name = Release; }; - 46EB2E00000230 /* Debug */ = { + 46EB2E00000240 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 46EB2E000002E0 /* Pods-KaMPKitiOS.debug.xcconfig */; + baseConfigurationReference = 46EB2E000002F0 /* Pods-KaMPKitiOS.debug.xcconfig */; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO; CLANG_ENABLE_OBJC_WEAK = NO; @@ -580,7 +599,7 @@ DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = "Target Support Files/Pods-KaMPKitiOS/Pods-KaMPKitiOS-Info.plist"; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 12.4; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -631,11 +650,11 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - 46EB2E00000210 /* Build configuration list for PBXNativeTarget "Pods-KaMPKitiOS" */ = { + 46EB2E00000220 /* Build configuration list for PBXNativeTarget "Pods-KaMPKitiOS" */ = { isa = XCConfigurationList; buildConfigurations = ( - 46EB2E00000230 /* Debug */, - 46EB2E00000220 /* Release */, + 46EB2E00000240 /* Debug */, + 46EB2E00000230 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; diff --git a/ios/Pods/Target Support Files/Pods-KaMPKitiOS/Pods-KaMPKitiOS-Info.plist b/ios/Pods/Target Support Files/Pods-KaMPKitiOS/Pods-KaMPKitiOS-Info.plist index 2243fe6e..19cf209d 100644 --- a/ios/Pods/Target Support Files/Pods-KaMPKitiOS/Pods-KaMPKitiOS-Info.plist +++ b/ios/Pods/Target Support Files/Pods-KaMPKitiOS/Pods-KaMPKitiOS-Info.plist @@ -3,7 +3,7 @@ CFBundleDevelopmentRegion - en + ${PODS_DEVELOPMENT_LANGUAGE} CFBundleExecutable ${EXECUTABLE_NAME} CFBundleIdentifier diff --git a/ios/Pods/Target Support Files/Pods-KaMPKitiOS/Pods-KaMPKitiOS-frameworks.sh b/ios/Pods/Target Support Files/Pods-KaMPKitiOS/Pods-KaMPKitiOS-frameworks.sh index 333d767c..1e57ae2b 100755 --- a/ios/Pods/Target Support Files/Pods-KaMPKitiOS/Pods-KaMPKitiOS-frameworks.sh +++ b/ios/Pods/Target Support Files/Pods-KaMPKitiOS/Pods-KaMPKitiOS-frameworks.sh @@ -41,7 +41,7 @@ install_framework() if [ -L "${source}" ]; then echo "Symlinked..." - source="$(readlink "${source}")" + source="$(readlink -f "${source}")" fi if [ -d "${source}/${BCSYMBOLMAP_DIR}" ]; then diff --git a/ios/Pods/Target Support Files/SwiftLint/SwiftLint.debug.xcconfig b/ios/Pods/Target Support Files/SwiftLint/SwiftLint.debug.xcconfig index 003a1f43..5238df58 100644 --- a/ios/Pods/Target Support Files/SwiftLint/SwiftLint.debug.xcconfig +++ b/ios/Pods/Target Support Files/SwiftLint/SwiftLint.debug.xcconfig @@ -3,6 +3,7 @@ CONFIGURATION_BUILD_DIR = ${PODS_CONFIGURATION_BUILD_DIR}/SwiftLint GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1 PODS_BUILD_DIR = ${BUILD_DIR} PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME) +PODS_DEVELOPMENT_LANGUAGE = ${DEVELOPMENT_LANGUAGE} PODS_ROOT = ${SRCROOT} PODS_TARGET_SRCROOT = ${PODS_ROOT}/SwiftLint PODS_XCFRAMEWORKS_BUILD_DIR = $(PODS_CONFIGURATION_BUILD_DIR)/XCFrameworkIntermediates diff --git a/ios/Pods/Target Support Files/SwiftLint/SwiftLint.release.xcconfig b/ios/Pods/Target Support Files/SwiftLint/SwiftLint.release.xcconfig index 003a1f43..5238df58 100644 --- a/ios/Pods/Target Support Files/SwiftLint/SwiftLint.release.xcconfig +++ b/ios/Pods/Target Support Files/SwiftLint/SwiftLint.release.xcconfig @@ -3,6 +3,7 @@ CONFIGURATION_BUILD_DIR = ${PODS_CONFIGURATION_BUILD_DIR}/SwiftLint GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1 PODS_BUILD_DIR = ${BUILD_DIR} PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME) +PODS_DEVELOPMENT_LANGUAGE = ${DEVELOPMENT_LANGUAGE} PODS_ROOT = ${SRCROOT} PODS_TARGET_SRCROOT = ${PODS_ROOT}/SwiftLint PODS_XCFRAMEWORKS_BUILD_DIR = $(PODS_CONFIGURATION_BUILD_DIR)/XCFrameworkIntermediates diff --git a/ios/Pods/Target Support Files/shared/shared.debug.xcconfig b/ios/Pods/Target Support Files/shared/shared.debug.xcconfig index 49484481..ee6cdedd 100644 --- a/ios/Pods/Target Support Files/shared/shared.debug.xcconfig +++ b/ios/Pods/Target Support Files/shared/shared.debug.xcconfig @@ -6,6 +6,7 @@ KOTLIN_PROJECT_PATH = :shared OTHER_LDFLAGS = $(inherited) -l"c++" PODS_BUILD_DIR = ${BUILD_DIR} PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME) +PODS_DEVELOPMENT_LANGUAGE = ${DEVELOPMENT_LANGUAGE} PODS_ROOT = ${SRCROOT} PODS_TARGET_SRCROOT = ${PODS_ROOT}/../../shared PODS_XCFRAMEWORKS_BUILD_DIR = $(PODS_CONFIGURATION_BUILD_DIR)/XCFrameworkIntermediates diff --git a/ios/Pods/Target Support Files/shared/shared.release.xcconfig b/ios/Pods/Target Support Files/shared/shared.release.xcconfig index 49484481..ee6cdedd 100644 --- a/ios/Pods/Target Support Files/shared/shared.release.xcconfig +++ b/ios/Pods/Target Support Files/shared/shared.release.xcconfig @@ -6,6 +6,7 @@ KOTLIN_PROJECT_PATH = :shared OTHER_LDFLAGS = $(inherited) -l"c++" PODS_BUILD_DIR = ${BUILD_DIR} PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME) +PODS_DEVELOPMENT_LANGUAGE = ${DEVELOPMENT_LANGUAGE} PODS_ROOT = ${SRCROOT} PODS_TARGET_SRCROOT = ${PODS_ROOT}/../../shared PODS_XCFRAMEWORKS_BUILD_DIR = $(PODS_CONFIGURATION_BUILD_DIR)/XCFrameworkIntermediates diff --git a/settings.gradle.kts b/settings.gradle.kts index ee6cebb6..d0b63f2b 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -3,6 +3,7 @@ pluginManagement { google() mavenCentral() gradlePluginPortal() + maven("https://api.touchlab.dev/public") } } diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 75b64d60..6f93ca1f 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -1,4 +1,4 @@ - +@file:Suppress("UnstableApiUsage") plugins { kotlin("multiplatform") @@ -6,6 +6,7 @@ plugins { kotlin("plugin.serialization") id("com.android.library") id("app.cash.sqldelight") + id("co.touchlab.skie") version "0.5.0" } android { @@ -25,12 +26,17 @@ android { abortOnError = true } namespace = "co.touchlab.kampkit" + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } } version = "1.2" kotlin { - android() + androidTarget() ios() // Note: iosSimulatorArm64 target requires that all dependencies have M1 support iosSimulatorArm64() @@ -52,6 +58,7 @@ kotlin { implementation(libs.bundles.ktor.common) implementation(libs.multiplatformSettings.common) implementation(libs.kotlinx.dateTime) + implementation(libs.touchlab.skie.annotations) api(libs.touchlab.kermit) } } @@ -88,11 +95,6 @@ kotlin { } } - sourceSets.matching { it.name.endsWith("Test") } - .configureEach { - languageSettings.optIn("kotlin.time.ExperimentalTime") - } - cocoapods { summary = "Common library for the KaMP starter kit" homepage = "https://github.com/touchlab/KaMPKit" @@ -101,7 +103,6 @@ kotlin { linkerOpts("-lsqlite3") export(libs.touchlab.kermit.simple) } - ios.deploymentTarget = "12.4" podfile = project.file("../ios/Podfile") } } diff --git a/shared/shared.podspec b/shared/shared.podspec index 7c41fceb..dc2ff960 100644 --- a/shared/shared.podspec +++ b/shared/shared.podspec @@ -8,7 +8,7 @@ Pod::Spec.new do |spec| spec.summary = 'Common library for the KaMP starter kit' spec.vendored_frameworks = 'build/cocoapods/framework/shared.framework' spec.libraries = 'c++' - spec.ios.deployment_target = '12.4' + spec.pod_target_xcconfig = { diff --git a/shared/src/androidMain/kotlin/co/touchlab/kampkit/models/ViewModel.kt b/shared/src/androidMain/kotlin/co/touchlab/kampkit/models/ViewModel.kt index 9eb0f27e..9623482a 100644 --- a/shared/src/androidMain/kotlin/co/touchlab/kampkit/models/ViewModel.kt +++ b/shared/src/androidMain/kotlin/co/touchlab/kampkit/models/ViewModel.kt @@ -1,12 +1,8 @@ package co.touchlab.kampkit.models -import kotlinx.coroutines.CoroutineScope import androidx.lifecycle.ViewModel as AndroidXViewModel -import androidx.lifecycle.viewModelScope as androidXViewModelScope actual abstract class ViewModel actual constructor() : AndroidXViewModel() { - actual val viewModelScope: CoroutineScope = androidXViewModelScope - actual override fun onCleared() { super.onCleared() } diff --git a/shared/src/commonMain/kotlin/co/touchlab/kampkit/models/BreedViewModel.kt b/shared/src/commonMain/kotlin/co/touchlab/kampkit/models/BreedViewModel.kt index fa3cb40e..16467436 100644 --- a/shared/src/commonMain/kotlin/co/touchlab/kampkit/models/BreedViewModel.kt +++ b/shared/src/commonMain/kotlin/co/touchlab/kampkit/models/BreedViewModel.kt @@ -2,13 +2,13 @@ package co.touchlab.kampkit.models import co.touchlab.kampkit.db.Breed import co.touchlab.kermit.Logger -import kotlinx.coroutines.Job +import co.touchlab.skie.configuration.annotations.DefaultArgumentInterop import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch class BreedViewModel( private val breedRepository: BreedRepository, @@ -16,11 +16,15 @@ class BreedViewModel( ) : ViewModel() { private val mutableBreedState: MutableStateFlow = - MutableStateFlow(BreedViewState(isLoading = true)) + MutableStateFlow(BreedViewState.Initial) - val breedState: StateFlow = mutableBreedState + val breedState: StateFlow = mutableBreedState.asStateFlow() - init { + /** + * Activates this viewModel so that `breedState` returns the current breed state. Suspends until cancelled, at + * which point `breedState` will no longer update. + */ + suspend fun activate() { observeBreeds() } @@ -28,7 +32,7 @@ class BreedViewModel( log.v("Clearing BreedViewModel") } - private fun observeBreeds() { + private suspend fun observeBreeds() { // Refresh breeds, and emit any exception that was thrown so we can handle it downstream val refreshFlow = flow { try { @@ -39,61 +43,85 @@ class BreedViewModel( } } - viewModelScope.launch { - combine(refreshFlow, breedRepository.getBreeds()) { throwable, breeds -> throwable to breeds } - .collect { (error, breeds) -> - mutableBreedState.update { previousState -> - val errorMessage = if (error != null) { - "Unable to download breed list" - } else { - previousState.error - } - BreedViewState( - isLoading = false, - breeds = breeds.takeIf { it.isNotEmpty() }, - error = errorMessage.takeIf { breeds.isEmpty() }, - isEmpty = breeds.isEmpty() && errorMessage == null - ) + combine( + refreshFlow, + breedRepository.getBreeds() + ) { throwable, breeds -> throwable to breeds } + .collect { (error, breeds) -> + mutableBreedState.update { previousState -> + val errorMessage = if (error != null) { + "Unable to download breed list" + } else if (previousState is BreedViewState.Error) { + previousState.error + } else { + null + } + + if (breeds.isNotEmpty()) { + BreedViewState.Content(breeds) + } else if (errorMessage != null) { + BreedViewState.Error(errorMessage) + } else { + BreedViewState.Empty() } } - } + } } - fun refreshBreeds(): Job { + suspend fun refreshBreeds() { // Set loading state, which will be cleared when the repository re-emits - mutableBreedState.update { it.copy(isLoading = true) } - return viewModelScope.launch { - log.v { "refreshBreeds" } - try { - breedRepository.refreshBreeds() - } catch (exception: Exception) { - handleBreedError(exception) + mutableBreedState.update { + when (it) { + is BreedViewState.Initial -> it + is BreedViewState.Content -> it.copy(isLoading = true) + is BreedViewState.Empty -> it.copy(isLoading = true) + is BreedViewState.Error -> it.copy(isLoading = true) } } - } - fun updateBreedFavorite(breed: Breed): Job { - return viewModelScope.launch { - breedRepository.updateBreedFavorite(breed) + log.v { "refreshBreeds" } + try { + breedRepository.refreshBreeds() + } catch (exception: Exception) { + handleBreedError(exception) } } + suspend fun updateBreedFavorite(breed: Breed) { + breedRepository.updateBreedFavorite(breed) + } + private fun handleBreedError(throwable: Throwable) { log.e(throwable) { "Error downloading breed list" } mutableBreedState.update { - if (it.breeds.isNullOrEmpty()) { - BreedViewState(error = "Unable to refresh breed list") - } else { - // Just let it fail silently if we have a cache - it.copy(isLoading = false) + when (it) { + is BreedViewState.Content -> it.copy(isLoading = false) // Just let it fail silently if we have a cache + is BreedViewState.Empty, + is BreedViewState.Error, + is BreedViewState.Initial -> BreedViewState.Error(error = "Unable to refresh breed list") } } } } -data class BreedViewState( - val breeds: List? = null, - val error: String? = null, - val isLoading: Boolean = false, - val isEmpty: Boolean = false -) +sealed class BreedViewState { + abstract val isLoading: Boolean + + data object Initial : BreedViewState() { + override val isLoading: Boolean = true + } + + data class Empty @DefaultArgumentInterop.Enabled constructor( + override val isLoading: Boolean = false + ) : BreedViewState() + + data class Content @DefaultArgumentInterop.Enabled constructor( + val breeds: List, + override val isLoading: Boolean = false + ) : BreedViewState() + + data class Error @DefaultArgumentInterop.Enabled constructor( + val error: String, + override val isLoading: Boolean = false + ) : BreedViewState() +} diff --git a/shared/src/commonMain/kotlin/co/touchlab/kampkit/models/ViewModel.kt b/shared/src/commonMain/kotlin/co/touchlab/kampkit/models/ViewModel.kt index d3c42256..f292265c 100644 --- a/shared/src/commonMain/kotlin/co/touchlab/kampkit/models/ViewModel.kt +++ b/shared/src/commonMain/kotlin/co/touchlab/kampkit/models/ViewModel.kt @@ -1,8 +1,5 @@ package co.touchlab.kampkit.models -import kotlinx.coroutines.CoroutineScope - expect abstract class ViewModel() { - val viewModelScope: CoroutineScope protected open fun onCleared() } diff --git a/shared/src/commonTest/kotlin/co/touchlab/kampkit/BreedViewModelTest.kt b/shared/src/commonTest/kotlin/co/touchlab/kampkit/BreedViewModelTest.kt index 7914dc28..6dcc9021 100644 --- a/shared/src/commonTest/kotlin/co/touchlab/kampkit/BreedViewModelTest.kt +++ b/shared/src/commonTest/kotlin/co/touchlab/kampkit/BreedViewModelTest.kt @@ -13,6 +13,8 @@ import co.touchlab.kermit.Logger import co.touchlab.kermit.StaticConfig import com.russhwolf.settings.MapSettings import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain @@ -38,19 +40,23 @@ class BreedViewModelTest { private val clock = ClockMock(Clock.System.now()) private val repository: BreedRepository = BreedRepository(dbHelper, settings, ktorApi, kermit, clock) - private val viewModel by lazy { BreedViewModel(repository, kermit) } + + private val viewModel by lazy { + BreedViewModel(repository, kermit) + .also { GlobalScope.launch { it.activate() } } + } companion object { private val appenzeller = Breed(1, "appenzeller", false) private val australianNoLike = Breed(2, "australian", false) private val australianLike = Breed(2, "australian", true) - private val breedViewStateSuccessNoFavorite = BreedViewState( + private val breedViewStateSuccessNoFavorite = BreedViewState.Content( breeds = listOf(appenzeller, australianNoLike) ) - private val breedViewStateSuccessFavorite = BreedViewState( + private val breedViewStateSuccessFavorite = BreedViewState.Content( breeds = listOf(appenzeller, australianLike) ) - private val breedNames = breedViewStateSuccessNoFavorite.breeds?.map { it.name }.orEmpty() + private val breedNames = breedViewStateSuccessNoFavorite.breeds.map { it.name } } @BeforeTest @@ -71,7 +77,7 @@ class BreedViewModelTest { viewModel.breedState.test { assertEquals( breedViewStateSuccessNoFavorite, - awaitItemPrecededBy(BreedViewState(isLoading = true), BreedViewState(isEmpty = true)) + awaitItemPrecededBy(BreedViewState.Initial, BreedViewState.Empty()) ) } } @@ -82,8 +88,8 @@ class BreedViewModelTest { viewModel.breedState.test { assertEquals( - BreedViewState(isEmpty = true), - awaitItemPrecededBy(BreedViewState(isLoading = true)) + BreedViewState.Empty(), + awaitItemPrecededBy(BreedViewState.Initial) ) } } @@ -100,13 +106,13 @@ class BreedViewModelTest { dbHelper.updateFavorite(australianLike.id, true) viewModel.breedState.test { - assertEquals(breedViewStateSuccessFavorite, awaitItemPrecededBy(BreedViewState(isLoading = true))) + assertEquals(breedViewStateSuccessFavorite, awaitItemPrecededBy(BreedViewState.Initial)) expectNoEvents() - viewModel.refreshBreeds().join() + viewModel.refreshBreeds() // id is 5 here because it incremented twice when trying to insert duplicate breeds assertEquals( - BreedViewState(breedViewStateSuccessFavorite.breeds?.plus(Breed(5, "extra", false))), + BreedViewState.Content(breedViewStateSuccessFavorite.breeds + Breed(5, "extra", false)), awaitItemPrecededBy(breedViewStateSuccessFavorite.copy(isLoading = true)) ) } @@ -126,8 +132,8 @@ class BreedViewModelTest { viewModel.breedState.test { // id is 5 here because it incremented twice when trying to insert duplicate breeds assertEquals( - BreedViewState(breedViewStateSuccessFavorite.breeds?.plus(Breed(5, "extra", false))), - awaitItemPrecededBy(BreedViewState(isLoading = true), breedViewStateSuccessFavorite) + BreedViewState.Content(breedViewStateSuccessFavorite.breeds + Breed(5, "extra", false)), + awaitItemPrecededBy(BreedViewState.Initial, breedViewStateSuccessFavorite) ) } } @@ -140,10 +146,10 @@ class BreedViewModelTest { dbHelper.updateFavorite(australianLike.id, true) viewModel.breedState.test { - assertEquals(breedViewStateSuccessFavorite, awaitItemPrecededBy(BreedViewState(isLoading = true))) + assertEquals(breedViewStateSuccessFavorite, awaitItemPrecededBy(BreedViewState.Initial)) expectNoEvents() - viewModel.updateBreedFavorite(australianLike).join() + viewModel.updateBreedFavorite(australianLike) assertEquals( breedViewStateSuccessNoFavorite, awaitItemPrecededBy(breedViewStateSuccessFavorite.copy(isLoading = true)) @@ -158,11 +164,11 @@ class BreedViewModelTest { dbHelper.insertBreeds(breedNames) viewModel.breedState.test { - assertEquals(breedViewStateSuccessNoFavorite, awaitItemPrecededBy(BreedViewState(isLoading = true))) + assertEquals(breedViewStateSuccessNoFavorite, awaitItemPrecededBy(BreedViewState.Initial)) assertEquals(0, ktorApi.calledCount) expectNoEvents() - viewModel.refreshBreeds().join() + viewModel.refreshBreeds() assertEquals( breedViewStateSuccessNoFavorite, awaitItemPrecededBy(breedViewStateSuccessNoFavorite.copy(isLoading = true)) @@ -177,8 +183,8 @@ class BreedViewModelTest { viewModel.breedState.test { assertEquals( - BreedViewState(error = "Unable to download breed list"), - awaitItemPrecededBy(BreedViewState(isLoading = true), BreedViewState(isEmpty = true)) + BreedViewState.Error(error = "Unable to download breed list"), + awaitItemPrecededBy(BreedViewState.Initial, BreedViewState.Empty()) ) } } @@ -192,12 +198,12 @@ class BreedViewModelTest { viewModel.breedState.test { assertEquals( breedViewStateSuccessNoFavorite, - awaitItemPrecededBy(BreedViewState(isLoading = true)) + awaitItemPrecededBy(BreedViewState.Initial) ) expectNoEvents() ktorApi.prepareResult(ktorApi.successResult()) - viewModel.refreshBreeds().join() + viewModel.refreshBreeds() assertEquals( breedViewStateSuccessNoFavorite, @@ -213,12 +219,12 @@ class BreedViewModelTest { viewModel.breedState.test { assertEquals( breedViewStateSuccessNoFavorite, - awaitItemPrecededBy(BreedViewState(isLoading = true), BreedViewState(isEmpty = true)) + awaitItemPrecededBy(BreedViewState.Initial, BreedViewState.Empty()) ) expectNoEvents() ktorApi.throwOnCall(RuntimeException("Test error")) - viewModel.refreshBreeds().join() + viewModel.refreshBreeds() assertEquals( breedViewStateSuccessNoFavorite, @@ -233,13 +239,13 @@ class BreedViewModelTest { ktorApi.throwOnCall(RuntimeException("Test error")) viewModel.breedState.test { - assertEquals(BreedViewState(isEmpty = true), awaitItemPrecededBy(BreedViewState(isLoading = true))) + assertEquals(BreedViewState.Empty(), awaitItemPrecededBy(BreedViewState.Initial)) expectNoEvents() - viewModel.refreshBreeds().join() + viewModel.refreshBreeds() assertEquals( - BreedViewState(error = "Unable to refresh breed list"), - awaitItemPrecededBy(BreedViewState(isEmpty = true, isLoading = true)) + BreedViewState.Error(error = "Unable to refresh breed list"), + awaitItemPrecededBy(BreedViewState.Empty(isLoading = true)) ) } } diff --git a/shared/src/iosMain/kotlin/co/touchlab/kampkit/BreedCallbackViewModel.kt b/shared/src/iosMain/kotlin/co/touchlab/kampkit/BreedCallbackViewModel.kt deleted file mode 100644 index 2246ed15..00000000 --- a/shared/src/iosMain/kotlin/co/touchlab/kampkit/BreedCallbackViewModel.kt +++ /dev/null @@ -1,26 +0,0 @@ -package co.touchlab.kampkit - -import co.touchlab.kampkit.db.Breed -import co.touchlab.kampkit.models.BreedRepository -import co.touchlab.kampkit.models.BreedViewModel -import co.touchlab.kampkit.models.CallbackViewModel -import co.touchlab.kermit.Logger - -@Suppress("Unused") // Members are called from Swift -class BreedCallbackViewModel( - breedRepository: BreedRepository, - log: Logger -) : CallbackViewModel() { - - override val viewModel = BreedViewModel(breedRepository, log) - - val breeds = viewModel.breedState.asCallbacks() - - fun refreshBreeds() { - viewModel.refreshBreeds() - } - - fun updateBreedFavorite(breed: Breed) { - viewModel.updateBreedFavorite(breed) - } -} diff --git a/shared/src/iosMain/kotlin/co/touchlab/kampkit/CoroutineAdapters.kt b/shared/src/iosMain/kotlin/co/touchlab/kampkit/CoroutineAdapters.kt deleted file mode 100644 index 0cbdb4e2..00000000 --- a/shared/src/iosMain/kotlin/co/touchlab/kampkit/CoroutineAdapters.kt +++ /dev/null @@ -1,35 +0,0 @@ -package co.touchlab.kampkit - -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onCompletion -import kotlinx.coroutines.flow.onEach - -class FlowAdapter( - private val scope: CoroutineScope, - private val flow: Flow -) { - fun subscribe( - onEach: (item: T) -> Unit, - onComplete: () -> Unit, - onThrow: (error: Throwable) -> Unit - ): Canceller = JobCanceller( - flow.onEach { onEach(it) } - .catch { onThrow(it) } - .onCompletion { onComplete() } - .launchIn(scope) - ) -} - -interface Canceller { - fun cancel() -} - -private class JobCanceller(private val job: Job) : Canceller { - override fun cancel() { - job.cancel() - } -} diff --git a/shared/src/iosMain/kotlin/co/touchlab/kampkit/KoinIOS.kt b/shared/src/iosMain/kotlin/co/touchlab/kampkit/KoinIOS.kt index f17c5561..5dd611ee 100644 --- a/shared/src/iosMain/kotlin/co/touchlab/kampkit/KoinIOS.kt +++ b/shared/src/iosMain/kotlin/co/touchlab/kampkit/KoinIOS.kt @@ -3,6 +3,7 @@ package co.touchlab.kampkit import app.cash.sqldelight.db.SqlDriver import app.cash.sqldelight.driver.native.NativeSqliteDriver import co.touchlab.kampkit.db.KaMPKitDb +import co.touchlab.kampkit.models.BreedViewModel import co.touchlab.kermit.Logger import com.russhwolf.settings.NSUserDefaultsSettings import com.russhwolf.settings.Settings @@ -31,7 +32,7 @@ actual val platformModule = module { single { Darwin.create() } - single { BreedCallbackViewModel(get(), getWith("BreedCallbackViewModel")) } + single { BreedViewModel(get(), getWith("BreedViewModel")) } } // Access from Swift to create a logger @@ -41,5 +42,5 @@ fun Koin.loggerWithTag(tag: String) = @Suppress("unused") // Called from Swift object KotlinDependencies : KoinComponent { - fun getBreedViewModel() = getKoin().get() + fun getBreedViewModel() = getKoin().get() } diff --git a/shared/src/iosMain/kotlin/co/touchlab/kampkit/models/ViewModel.kt b/shared/src/iosMain/kotlin/co/touchlab/kampkit/models/ViewModel.kt index 7ae32085..62dce1a1 100644 --- a/shared/src/iosMain/kotlin/co/touchlab/kampkit/models/ViewModel.kt +++ b/shared/src/iosMain/kotlin/co/touchlab/kampkit/models/ViewModel.kt @@ -1,45 +1,18 @@ package co.touchlab.kampkit.models -import co.touchlab.kampkit.FlowAdapter -import kotlinx.coroutines.MainScope -import kotlinx.coroutines.cancel -import kotlinx.coroutines.flow.Flow - /** - * Base class that provides a Kotlin/Native equivalent to the AndroidX `ViewModel`. In particular, this provides - * a [CoroutineScope][kotlinx.coroutines.CoroutineScope] which uses [Dispatchers.Main][kotlinx.coroutines.Dispatchers.Main] - * and can be tied into an arbitrary lifecycle by calling [clear] at the appropriate time. + * Base class that provides a Kotlin/Native equivalent to the AndroidX `ViewModel`. */ -actual abstract class ViewModel { - - actual val viewModelScope = MainScope() +actual abstract class ViewModel actual constructor() { /** * Override this to do any cleanup immediately before the internal [CoroutineScope][kotlinx.coroutines.CoroutineScope] - * is cancelled in [clear] + * is cancelled. */ protected actual open fun onCleared() { } - /** - * Cancels the internal [CoroutineScope][kotlinx.coroutines.CoroutineScope]. After this is called, the ViewModel should - * no longer be used. - */ fun clear() { onCleared() - viewModelScope.cancel() } } - -abstract class CallbackViewModel { - protected abstract val viewModel: ViewModel - - /** - * Create a [FlowAdapter] from this [Flow] to make it easier to interact with from Swift. - */ - fun Flow.asCallbacks() = - FlowAdapter(viewModel.viewModelScope, this) - - @Suppress("Unused") // Called from Swift - fun clear() = viewModel.clear() -}