diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..d7966bf --- /dev/null +++ b/.editorconfig @@ -0,0 +1,4 @@ +# noinspection EditorConfigKeyCorrectness +[*.{kt,kts}] +ktlint_code_style = android_studio +ktlint_function_naming_ignore_when_annotated_with=Composable \ No newline at end of file diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..279b185 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,22 @@ + + + +Issue: https://github.com/touchlab/KaMPKit/issues/[issue number] + +## Summary + + +## Fix + + +## Testing + +- `./gradlew :app:build` +- `./gradlew :shared:build` +- `xcodebuild -workspace ios/KaMPKitiOS.xcworkspace -scheme KaMPKitiOS + -sdk iphoneos -configuration Debug build -destination name="iPhone 8"` +- manual testing + + +### **Screenshot / Video of App working with the Changes** +fix in action \ No newline at end of file diff --git a/.github/workflows/KaMPKit-Android.yml b/.github/workflows/KaMPKit-Android.yml new file mode 100644 index 0000000..170be2d --- /dev/null +++ b/.github/workflows/KaMPKit-Android.yml @@ -0,0 +1,33 @@ +name: KaMPKit-Android + +on: + workflow_dispatch: + push: + branches: + - main + pull_request: + paths-ignore: + - "**.md" + - "*.png" + - docs + - ios + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: corretto + java-version: 17 + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Build + run: ./gradlew build + + + diff --git a/.github/workflows/KaMPKit-iOS.yml b/.github/workflows/KaMPKit-iOS.yml new file mode 100644 index 0000000..6de6176 --- /dev/null +++ b/.github/workflows/KaMPKit-iOS.yml @@ -0,0 +1,42 @@ +name: KaMPKit-iOS + +on: + workflow_dispatch: + push: + branches: + - main + pull_request: + paths-ignore: + - "**.md" + - "*.png" + - docs + - app + +jobs: + build: + runs-on: macos-latest + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: corretto + java-version: 17 + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Run tests + run: ./gradlew :shared:iosX64Test + + - name: Build + uses: sersoft-gmbh/xcodebuild-action@v1 + with: + project: ios/KaMPKitiOS.xcodeproj + scheme: KaMPKitiOS + destination: name=iPhone 8 + sdk: iphoneos + configuration: Debug + action: build + use-xcpretty: false + build-settings: CODE_SIGN_IDENTITY= CODE_SIGNING_REQUIRED=NO diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6eedae1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +*.iml +.gradle +/local.properties +/.idea +.DS_Store +/build +/buildSrc/build +/captures +.externalNativeBuild +.cxx +*.xcuserstate +*.xcbkptlist +!/.idea/codeStyles/* +!/.idea/inspectionProfiles/* +.kotlin \ No newline at end of file diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml new file mode 100644 index 0000000..2582293 --- /dev/null +++ b/.idea/codeStyles/Project.xml @@ -0,0 +1,136 @@ + + + + + + + + + + +
+ + + + xmlns:android + + ^$ + + + +
+
+ + + + xmlns:.* + + ^$ + + + BY_NAME + +
+
+ + + + .*:id + + http://schemas.android.com/apk/res/android + + + +
+
+ + + + .*:name + + http://schemas.android.com/apk/res/android + + + +
+
+ + + + name + + ^$ + + + +
+
+ + + + style + + ^$ + + + +
+
+ + + + .* + + ^$ + + + BY_NAME + +
+
+ + + + .* + + http://schemas.android.com/apk/res/android + + + ANDROID_ATTRIBUTE_ORDER + +
+
+ + + + .* + + .* + + + BY_NAME + +
+
+
+
+ + +
+
\ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000..0f7bc51 --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + diff --git a/.idea/inspectionProfiles/ktlint.xml b/.idea/inspectionProfiles/ktlint.xml new file mode 100644 index 0000000..0938bfd --- /dev/null +++ b/.idea/inspectionProfiles/ktlint.xml @@ -0,0 +1,43 @@ + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..64580d1 --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + diff --git a/CONTACT_US.md b/CONTACT_US.md new file mode 100644 index 0000000..b8e2a1c --- /dev/null +++ b/CONTACT_US.md @@ -0,0 +1,11 @@ +# Contact Us + +KaMP Kit support can be found in the Kotlin Community Slack. Look for the `kampkit-support` channel. + +To join the Kotlin Community Slack, [request access here](http://slack.kotlinlang.org/) + +For direct assistance, please [reach out to Touchlab](https://go.touchlab.co/contactkamp) to discuss support options. + +If you find any bugs or issues in with project, you can create an issue in +the [GitHub repository](https://github.com/touchlab/KaMPKit), but please don't mistake it with general KMP helpline. You +can get answers for general questions in Slack. diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..0a42350 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2020 Touchlab + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..4149062 --- /dev/null +++ b/README.md @@ -0,0 +1,172 @@ +[![KaMP Kit Android](https://img.shields.io/github/actions/workflow/status/touchlab/KaMPKit/KaMPKit-Android.yml?branch=main&logo=Android&style=plastic)](https://github.com/touchlab/KaMPKit/actions/workflows/KaMPKit-Android.yml) +[![KaMP Kit iOS](https://img.shields.io/github/actions/workflow/status/touchlab/KaMPKit/KaMPKit-iOS.yml?branch-main&logo=iOS&style=plastic)](https://github.com/touchlab/KaMPKit/actions/workflows/KaMPKit-iOS.yml) + +# KaMP Kit + +![KaMP Kit Image](kampkit.png) + +***Welcome to KaMP Kit!*** + +## Intro + +KaMP Kit started in early 2020 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 KMP 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! + +### 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 and ensure their success in production. Join our community to learn how your peers are adopting KMP. + [Sign up here](https://touchlab.co/?s=shownewsletter)! + +## 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. + +For direct assistance, please [contact Touchlab](https://go.touchlab.co/contactkamp) to discuss support options. + +## About + +### Goal + +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 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. + +#### *Very Important Message!!!* + +This kit exists because the info you may find from Google about KMP is likely to be outdated or conflicting with the config here. It is highly recommended that you reach out directly if you run into issues. + +### Audience + +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? + +1. The Starter App - A native mobile KMP app with a small functional feature set. +2. Educational Resources - Introductory information on KMP and Kotlin/Native. +3. Integration Information - If you're integrating shared code into an existing application, guides to assist with that effort. + +## What's *Not* Included? + +Comprehensive guides, advanced tutorials, or generally support for fixing anything not included in the starter app. The goal is to have a solid starting point from which you can create something meaningful for evaluating KMP. We're intentionally limiting the scope to keep focus. + +# The Starter App + +The central part of the "Kit" is the starter app. It includes a set of libraries that we use in our apps that provide for much of the architectural needs of a native mobile application. We've also included a simple set of features you can use as a reference when adding your features. + +## 1) Dev Environment and Build Setup + +You will need the following: + +- JVM 17 +- Android SDK and the latest stable Android Studio (2023.3+) or IntelliJ(2024.1+) +- Mac with Xcode 15+ for the iOS build + +For a more detailed guide targeted at iOS developers, see [DETAILED_DEV_SETUP](docs/DETAILED_DEV_SETUP.md). + +## 2) Clone and Build + +See [APP_BUILD](docs/APP_BUILD.md) for detailed build instructions. By the end of that doc you should be able to build and run both Android and iOS apps. + +--- + +## Sanity Check + +At this point, you should be able to build Android and iOS apps. **If you cannot build, you need to get help.** This sample app is configured to run out of the box, so if it's not working, you have something wrong with your build setup or config. Please [reach out to us](CONTACT_US.md) so we can improve either the config or troubleshooting docs, and/or the Kotlin Slack group mentioned above. + +--- + +## 3) Walk Through App + +Take a walk through the app's code and libraries. Make changes, recompile. See how it works. + +[GENERAL_ARCHITECTURE](docs/GENERAL_ARCHITECTURE.md) + +## 4) Background Education + +If the app is building, it's a good time to take a break and get some background information. + +### 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. + +[Longer intro to KaMP Kit](docs/WHAT_AND_WHY.md) - Original version of this doc's intro. Cut because it was pretty long. + +[Intro to Kotlin Multiplatform](https://vimeo.com/371428809) - General intro to KMP from Oredev in Nov 2019. Good overall summary of the platform. + +### "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. + +## 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. 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 + +The iOS integration process is relatively new and has been iterating fast. Be prepared to spend more time with config related issues when integrating with a production build. + +You can integrate with Cocoapods, or by directly including the Xcode framework. If you are an Android developer without extensive iOS build experience, be aware that this is a risky option. Production build systems, for any ecosystem, tend to be complex. You'll almost certainly need to recruit somebody with experience maintaining your iOS build. + +See [IOS_PROJ_INTEGRATION.md](docs/IOS_PROJ_INTEGRATION.md) for iOS integration information. + +If you are attempting to integrate your KMP project with a production iOS application, please let us know what issues you run into and reach out with questions if stuck. This is an ongoing area of improvement for the KMP platform and we'd like to help make this as smooth as possible. + +--- + +## Troubleshooting + +[TROUBLESHOOTING](docs/TROUBLESHOOTING.md) - We'll be growing this file over time, with your help. Please make sure +to document any issues you run into and [let us know](CONTACT_US.md). + +## More To Come! + +KaMP Kit is just the beginning. Our hope is that after KaMP Kit you’ll have a better sense of what a possible KMP implementation might look like. + +--- + +### About Touchlab + +Touchlab is a mobile-focused development agency based in NYC. We have been working on Android since the beginning, and have worked on a wide range of mobile and hardware projects for the past decade. Over the past few years, we have invested significantly on R&D for code sharing technologies. We believe strongly in KMP's future and have made the Kotlin multiplatform the focus of our business. + +### About The Kit + +We talked to a few teams early on who got to do a "hack week" with KMP. A common story was, if they didn't abandon the project altogether, they didn't have anything running till the week was half over. Even then, picking libraries and architecture ate the rest of the time. Inevitably the result was, "KMP isn't ready". We know that once you're past the setup phase, KMP is really amazing tech. This Kit exists so you're evaluating KMP on Monday afternoon, not Thursday. + +### What We Can Do For You + +We have made KMP the focus of Touchlab. We had possibly the first KMP* app published in the iOS App Store, and have extensive experience in building libraries and the Kotlin platform, including contributions to Kotlin/Native itself. We can establish and accelerate your adoption of shared Kotlin code. See [touchlab.co](https://touchlab.co) for more info. + +> ## Subscribe! +> +> 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/.gitignore b/app/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..5201d28 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,52 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.compose.compiler) +} + +android { + namespace = "co.touchlab.kampkit.android" + compileSdk = libs.versions.compileSdk.get().toInt() + defaultConfig { + applicationId = "co.touchlab.kampkit" + minSdk = libs.versions.minSdk.get().toInt() + targetSdk = libs.versions.targetSdk.get().toInt() + versionCode = 1 + versionName = "1.0" + } + + buildTypes { + getByName("release") { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + isCoreLibraryDesugaringEnabled = true + } + lint { + warningsAsErrors = false + abortOnError = true + } + + buildFeatures { + compose = true + buildConfig = true + } +} + +kotlin { + jvmToolchain(11) +} + +dependencies { + implementation(projects.shared) + implementation(libs.bundles.app.ui) + implementation(libs.multiplatformSettings.common) + implementation(libs.kotlinx.dateTime) + coreLibraryDesugaring(libs.android.desugaring) + implementation(libs.koin.android) +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..f1b4245 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..8554e73 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/kotlin/co/touchlab/kampkit/android/MainActivity.kt b/app/src/main/kotlin/co/touchlab/kampkit/android/MainActivity.kt new file mode 100644 index 0000000..08ae095 --- /dev/null +++ b/app/src/main/kotlin/co/touchlab/kampkit/android/MainActivity.kt @@ -0,0 +1,27 @@ +package co.touchlab.kampkit.android + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import co.touchlab.kampkit.android.ui.MainScreen +import co.touchlab.kampkit.android.ui.theme.KaMPKitTheme +import co.touchlab.kampkit.injectLogger +import co.touchlab.kampkit.models.BreedViewModel +import co.touchlab.kermit.Logger +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.koin.core.component.KoinComponent + +class MainActivity : ComponentActivity(), KoinComponent { + + private val log: Logger by injectLogger("MainActivity") + private val viewModel: BreedViewModel by viewModel() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + KaMPKitTheme { + MainScreen(viewModel, log) + } + } + } +} diff --git a/app/src/main/kotlin/co/touchlab/kampkit/android/MainApp.kt b/app/src/main/kotlin/co/touchlab/kampkit/android/MainApp.kt new file mode 100644 index 0000000..1875626 --- /dev/null +++ b/app/src/main/kotlin/co/touchlab/kampkit/android/MainApp.kt @@ -0,0 +1,39 @@ +package co.touchlab.kampkit.android + +import android.app.Application +import android.content.Context +import android.content.SharedPreferences +import android.util.Log +import co.touchlab.kampkit.AppInfo +import co.touchlab.kampkit.initKoin +import co.touchlab.kampkit.models.BreedViewModel +import org.koin.androidx.viewmodel.dsl.viewModel +import org.koin.core.parameter.parametersOf +import org.koin.dsl.module + +class MainApp : Application() { + + override fun onCreate() { + super.onCreate() + initKoin( + module { + single { this@MainApp } + viewModel { BreedViewModel(get(), get { parametersOf("BreedViewModel") }) } + single { + get().getSharedPreferences( + "KAMPSTARTER_SETTINGS", + Context.MODE_PRIVATE + ) + } + single { AndroidAppInfo } + single { + { Log.i("Startup", "Hello from Android/Kotlin!") } + } + } + ) + } +} + +object AndroidAppInfo : AppInfo { + override val appId: String = BuildConfig.APPLICATION_ID +} 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 new file mode 100644 index 0000000..d92ef37 --- /dev/null +++ b/app/src/main/kotlin/co/touchlab/kampkit/android/ui/Composables.kt @@ -0,0 +1,214 @@ +package co.touchlab.kampkit.android.ui + +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.TweenSpec +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Divider +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import co.touchlab.kampkit.android.R +import co.touchlab.kampkit.db.Breed +import co.touchlab.kampkit.models.BreedViewModel +import co.touchlab.kampkit.models.BreedViewState +import co.touchlab.kermit.Logger +import kotlinx.coroutines.launch + +@Composable +fun MainScreen(viewModel: BreedViewModel, log: Logger) { + val dogsState by viewModel.breedState.collectAsStateWithLifecycle() + val scope = rememberCoroutineScope() + + LaunchedEffect(viewModel) { + viewModel.activate() + } + + MainScreenContent( + dogsState = dogsState, + onRefresh = { scope.launch { viewModel.refreshBreeds() } }, + onSuccess = { data -> log.v { "View updating with ${data.size} breeds" } }, + onError = { exception -> log.e { "Displaying error: $exception" } }, + onFavorite = { scope.launch { viewModel.updateBreedFavorite(it) } } + ) +} + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun MainScreenContent( + dogsState: BreedViewState, + onRefresh: () -> Unit = {}, + onSuccess: (List) -> Unit = {}, + onError: (String) -> Unit = {}, + onFavorite: (Breed) -> Unit = {} +) { + Surface( + color = MaterialTheme.colors.background, + modifier = Modifier.fillMaxSize() + ) { + val refreshState = rememberPullRefreshState(dogsState.isLoading, onRefresh) + + Box(Modifier.pullRefresh(refreshState)) { + when (dogsState) { + is BreedViewState.Empty -> Empty() + is BreedViewState.Content -> { + val breeds = dogsState.breeds + onSuccess(breeds) + Success(successData = breeds, favoriteBreed = onFavorite) + } + + is BreedViewState.Error -> { + val error = dogsState.error + onError(error) + Error(error) + } + + BreedViewState.Initial -> { + // no-op (just show spinner until first data is loaded) + } + } + + PullRefreshIndicator( + dogsState.isLoading, + refreshState, + Modifier.align(Alignment.TopCenter) + ) + } + } +} + +@Composable +fun Empty() { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text(stringResource(R.string.empty_breeds)) + } +} + +@Composable +fun Error(error: String) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text(text = error) + } +} + +@Composable +fun Success(successData: List, favoriteBreed: (Breed) -> Unit) { + DogList(breeds = successData, favoriteBreed) +} + +@Composable +fun DogList(breeds: List, onItemClick: (Breed) -> Unit) { + LazyColumn { + items(breeds) { breed -> + DogRow(breed) { + onItemClick(it) + } + Divider() + } + } +} + +@Composable +fun DogRow(breed: Breed, onClick: (Breed) -> Unit) { + Row( + Modifier + .clickable { onClick(breed) } + .padding(10.dp) + ) { + Text(breed.name, Modifier.weight(1F)) + FavoriteIcon(breed) + } +} + +@Composable +fun FavoriteIcon(breed: Breed) { + Crossfade( + targetState = !breed.favorite, + animationSpec = TweenSpec( + durationMillis = 500, + easing = FastOutSlowInEasing + ), + label = "CrossFadeFavoriteIcon" + ) { fav -> + if (fav) { + Image( + painter = painterResource(id = R.drawable.ic_favorite_border_24px), + contentDescription = stringResource(R.string.favorite_breed, breed.name) + ) + } else { + Image( + painter = painterResource(id = R.drawable.ic_favorite_24px), + contentDescription = stringResource(R.string.unfavorite_breed, breed.name) + ) + } + } +} + +@Preview +@Composable +fun MainScreenContentPreview_Success() { + MainScreenContent( + dogsState = BreedViewState.Content( + breeds = listOf( + Breed(0, "appenzeller", false), + Breed(1, "australian", true) + ) + ) + ) +} + +@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/app/src/main/kotlin/co/touchlab/kampkit/android/ui/theme/Color.kt b/app/src/main/kotlin/co/touchlab/kampkit/android/ui/theme/Color.kt new file mode 100644 index 0000000..287f01c --- /dev/null +++ b/app/src/main/kotlin/co/touchlab/kampkit/android/ui/theme/Color.kt @@ -0,0 +1,8 @@ +package co.touchlab.kampkit.android.ui.theme + +import androidx.compose.ui.graphics.Color + +val Purple200 = Color(0xFFBB86FC) +val Purple500 = Color(0xFF6200EE) +val Purple700 = Color(0xFF3700B3) +val Teal200 = Color(0xFF03DAC5) diff --git a/app/src/main/kotlin/co/touchlab/kampkit/android/ui/theme/Shapes.kt b/app/src/main/kotlin/co/touchlab/kampkit/android/ui/theme/Shapes.kt new file mode 100644 index 0000000..0260e33 --- /dev/null +++ b/app/src/main/kotlin/co/touchlab/kampkit/android/ui/theme/Shapes.kt @@ -0,0 +1,11 @@ +package co.touchlab.kampkit.android.ui.theme + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Shapes +import androidx.compose.ui.unit.dp + +val Shapes = Shapes( + small = RoundedCornerShape(4.dp), + medium = RoundedCornerShape(4.dp), + large = RoundedCornerShape(0.dp) +) diff --git a/app/src/main/kotlin/co/touchlab/kampkit/android/ui/theme/Theme.kt b/app/src/main/kotlin/co/touchlab/kampkit/android/ui/theme/Theme.kt new file mode 100644 index 0000000..0d0bf1f --- /dev/null +++ b/app/src/main/kotlin/co/touchlab/kampkit/android/ui/theme/Theme.kt @@ -0,0 +1,44 @@ +package co.touchlab.kampkit.android.ui.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material.MaterialTheme +import androidx.compose.material.darkColors +import androidx.compose.material.lightColors +import androidx.compose.runtime.Composable + +private val DarkColorPalette = darkColors( + primary = Purple200, + primaryVariant = Purple700, + secondary = Teal200 +) + +private val LightColorPalette = lightColors( + primary = Purple500, + primaryVariant = Purple700, + secondary = Teal200 + + // Other default colors to override + // + // background = Color.White, + // surface = Color.White, + // onPrimary = Color.White, + // onSecondary = Color.Black, + // onBackground = Color.Black, + // onSurface = Color.Black, +) + +@Composable +fun KaMPKitTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) { + val colors = if (darkTheme) { + DarkColorPalette + } else { + LightColorPalette + } + + MaterialTheme( + colors = colors, + typography = Typography, + shapes = Shapes, + content = content + ) +} diff --git a/app/src/main/kotlin/co/touchlab/kampkit/android/ui/theme/Typography.kt b/app/src/main/kotlin/co/touchlab/kampkit/android/ui/theme/Typography.kt new file mode 100644 index 0000000..2c6faca --- /dev/null +++ b/app/src/main/kotlin/co/touchlab/kampkit/android/ui/theme/Typography.kt @@ -0,0 +1,29 @@ +package co.touchlab.kampkit.android.ui.theme + +import androidx.compose.material.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = Typography( + body1 = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp + ) + // Other default text styles to override + // + // button = TextStyle( + // fontFamily = FontFamily.Default, + // fontWeight = FontWeight.W500, + // fontSize = 14.sp + // ), + // + // caption = TextStyle( + // fontFamily = FontFamily.Default, + // fontWeight = FontWeight.Normal, + // fontSize = 12.sp + // ) +) diff --git a/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000..1f6bb29 --- /dev/null +++ b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_favorite_24px.xml b/app/src/main/res/drawable/ic_favorite_24px.xml new file mode 100644 index 0000000..ce351f4 --- /dev/null +++ b/app/src/main/res/drawable/ic_favorite_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_favorite_border_24px.xml b/app/src/main/res/drawable/ic_favorite_border_24px.xml new file mode 100644 index 0000000..e664670 --- /dev/null +++ b/app/src/main/res/drawable/ic_favorite_border_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..0d025f9 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..898f3ed Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..dffca36 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..64ba76f Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..dae5e08 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..e5ed465 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..14ed0af Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..b0907ca Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..d8ae031 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..2c18de9 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..beed3cd Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..69b2233 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,6 @@ + + + #008577 + #00574B + #D81B60 + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..3caf757 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,6 @@ + + KaMP Kit + Favorite %1$s + Unfavorite %1$s + Sorry, no doggos found + diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..5885930 --- /dev/null +++ b/app/src/main/res/values/styles.xml @@ -0,0 +1,11 @@ + + + + + + diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..c3b217f --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,22 @@ +plugins { + alias(libs.plugins.ktlint) apply false + alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.kotlin.multiplatform) apply false + alias(libs.plugins.sqlDelight) apply false + alias(libs.plugins.android.library) apply false + alias(libs.plugins.android.application) apply false + alias(libs.plugins.kotlin.serialization) apply false + alias(libs.plugins.skie) apply false + alias(libs.plugins.compose.compiler) apply false +} + +subprojects { + apply(plugin = rootProject.libs.plugins.ktlint.get().pluginId) + + configure { + verbose.set(true) + filter { + exclude { it.file.path.contains("build/") } + } + } +} diff --git a/docs/APP_BUILD.md b/docs/APP_BUILD.md new file mode 100644 index 0000000..8b5455d --- /dev/null +++ b/docs/APP_BUILD.md @@ -0,0 +1,66 @@ +# Sample App Build + +## Prerequisites +Before you build the app you will require these items: + +* JVM 17 +- Android SDK and the latest stable Android Studio (2023.1+) or IntelliJ(2023.3+) +- Mac with Xcode 14+ for the iOS build + +For more details, check out the [DETAILED_DEV_SETUP](DETAILED_DEV_SETUP.md) document. + +### 1) Clone the app +Run the following on the command line +``` +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 +![](runconfig.png) +3. Run the Android app on either the Emulator or a phone. If the app builds correctly, you should see this: + +![](Screenshots/kampScreenshotAndroid.png) + +### 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). + 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: + +![](Screenshots/kampScreenshotiOS.png) + +## Did that work? + +Congratulations! You have a functional sample app to start working from. Head back to the [README.md](../README.md#Sanity-Check) for next steps. + +### Common Issues + +See [TROUBLESHOOTING](TROUBLESHOOTING.md) + +### CI Hosts +Running your common tests against iOS and testing your native iOS code require a macOS machine. In Github Actions you can specify the machine you want to run your jobs on: + +```yaml +jobs: + build: + runs-on: macos-latest +``` +Most CI hosts, including Github Actions charge for macOS hosts at a higher rate than linux, so it's worthwhile to reduce macOS build times. In KaMP Kit we do this by splitting into two workflows, `KaMPKit-Android.yml` and `KaMPKit-iOS.yml`. Each workflow excludes builds that only have changes to the opposite platform or docs only changes. + +```yaml +pull_request: + paths-ignore: + - "**.md" + - "*.png" + - docs + - app +``` + +### Contact + +If you're having issues, you can view the [contact Document here](https://github.com/touchlab/KaMPKit/blob/master/CONTACT_US.md) for contact information. diff --git a/docs/DEBUGGING_KOTLIN_IN_XCODE.md b/docs/DEBUGGING_KOTLIN_IN_XCODE.md new file mode 100644 index 0000000..9325477 --- /dev/null +++ b/docs/DEBUGGING_KOTLIN_IN_XCODE.md @@ -0,0 +1,37 @@ +# Debugging Kotlin in Xcode + +> Note that if there is a [known issue](https://github.com/touchlab/xcode-kotlin/issues/95) with `xcode-kotlin` plugin on Xcode 15 + +By this point you should be able to build and run KaMP Kit in iOS using Xcode. Great! Maybe you've +changed a variable and want to see if it actually updated successfully, but how do you do that? Well +we at Touchlab have actually created a way to **debug kotlin code in Xcode**. + +### Kotlin Native Xcode Plugin + +The [Kotlin Native Xcode Plugin](https://github.com/touchlab/xcode-kotlin) adds basic highlighting, +allows you to set breakpoints and includes llvm support to view data in the debug window. You can +find the steps to install this plugin on its readMe. Newly a CLI (command line interface) was added - +it is an executable that is installed on your machine and manages the plugin installation(s). It allows: + +- Homebrew installation +- Better Xcode integration (No more "Load Bundle" popups!) +- Easier management of multiple Xcode installations +- Automatic "sync". When Xcode updates, we need to update the plugin config. This previously + required updating the xcode-kotlin project GitHub repo, pulling, and reinstalling. The CLI can do + this locally. +- Better diagnostic info and support for install issues. + +### Kotlin Source in Xcode + +To take advantage of the plugin you will want to add references to your kotlin code in Xcode. This +will allow you to add breakpoints and edit kotlin without switching to Android Studio. You probably +won't want to do your primary kotlin coding like this, but it's helpful when debugging. + +To add the Kotlin source: +1. Right click in the project explorer + +![](Screenshots/AddFiles.png) + +2. In the finder opened, select the kotlin source you want included (commonMain and iosMain). Be sure to select "Create folder references for any added folders" + +![](Screenshots/FolderRef.png) diff --git a/docs/DETAILED_DEV_SETUP.md b/docs/DETAILED_DEV_SETUP.md new file mode 100644 index 0000000..70fb9b9 --- /dev/null +++ b/docs/DETAILED_DEV_SETUP.md @@ -0,0 +1,60 @@ +# 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. + + +## Install JDK + +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: + +- [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 KMP 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 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). + +If you just want one or the other, you can use the following links: + +- [Android Studio docs installation guide](https://developer.android.com/studio/install) (includes download link) +- [IntelliJ download link](https://www.jetbrains.com/idea/download/#section=mac) (select the Community version) +- [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 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. + +## Open IDE + +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` (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, 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 + +Your KMP development environment is ready now. Your next step should be to go to the [APP_BUILD.md doc](APP_BUILD.md), which focuses on building this project, as well as running it on both Android and iOS. + + + diff --git a/docs/GENERAL_ARCHITECTURE.md b/docs/GENERAL_ARCHITECTURE.md new file mode 100644 index 0000000..d9b8186 --- /dev/null +++ b/docs/GENERAL_ARCHITECTURE.md @@ -0,0 +1,169 @@ +# Architecture Overview + + 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) +* [Kotlinx Coroutines](#kotlinx-Coroutines) +* [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 + * [Koin](#Koin) - Dependency Injection +* [Testing](#Testing) + +## Structure of the Project + +KaMP Kit is organized into three main directories: +* shared +* app +* ios + +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 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. + +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 + * androidUnitTest + * iosTest + * commonTest + +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 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 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. + +`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` 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 + +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 -> 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. + +## Kotlinx Coroutines + +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/). + + 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.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 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). + + ##### 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. + +### Ktor +Documentation: https://ktor.io/ + +Usage in the project: *commonMain/kotlin/co/touchlab/kampkit/ktor/DogApiImpl.kt* + +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. + +### 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. + +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, 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`. +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 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 new file mode 100644 index 0000000..688d65a --- /dev/null +++ b/docs/IOS_PROJ_INTEGRATION.md @@ -0,0 +1,115 @@ +# Integrating with Existing iOS Projects + +There are two primary ways to add a KMP library to your existing iOS project: with or without +Cocoapods. Cocoapods is the much simpler method of adding your library. By generating a file in +gradle you can easily insert your library into your iOS project without worrying about build phases +or targets. It's simple and ease-of-use, and we recommend that you use Cocoapods. + +If you don't want to use Cocoapods to add a KMP library to your iOS project, then you can follow the +steps +in [this guide](https://play.kotlinlang.org/hands-on/Targeting%20iOS%20and%20Android%20with%20Kotlin%20Multiplatform/01_Introduction) +from Jetbrains about how to add the library to your iOS project manually. + +If you don't have Cocoapods installed, then follow the instructions in +their [official installation guide](https://guides.cocoapods.org/using/getting-started.html). + +## Cocoapods Overview + +Explaining all of Cocoapods is not within the scope of this document, however a basic introduction +could be helpful in understanding how to integrate Kotlin Native into your iOS Project. In short, +Cocoapods is a dependency manager which uses a `Podfile` to reference a list of dependencies, +or `pods`, that are to be injected. Each `pod` has a reference spec document, or a `podspec`, which +details the pods name, version, source, and other information. By using Cocoapods, we can reference +our shared library and have it directly injected into the iOS Project. + +## Cocoapods gradle Integration + +Starting with 1.3.30, Kotlin has provided a gradle plugin which allows the Kotlin Native library to +be referenced as a Cocoapods dependency. The integration adds a +gradle task that generates a `podspec` that includes everything needed to be referenced by +Cocoapods. Our podspec is located in the `shared/build.gradle`. + +``` +cocoapods { + summary = "Common library for the KaMP starter kit" + homepage = "https://github.com/touchlab/KaMPKit" + framework { + isStatic = false + linkerOpts("-lsqlite3") + export(libs.touchlab.kermit.simple) + } + extraSpecAttributes["swift_version"] = "\"5.0\"" // <- SKIE Needs this! + podfile = project.file("../ios/Podfile") +} +``` +Note that you need to apply the `native.cocoapods` plugin. + +The `framework` block is used to configure the framework generated by Cocoapods. In this case we +use `isStatic = false` to build a dynamic framework (Debugging has issues in static frameworks, for +example the previews don't work). On the other hand, we encountered problems with dynamic frameworks +on Arm64 based simulators, so use `isStatic = true` if you need to use a Arm64 simulator. The export +settings allow configuring and logging with Kermit in swift. Normally dependencies of your shared +module aren't included in the export. + +To generate the podspec, run the `podspec` command, or `./gradlew podspec`. This will generate the +podspec in the root library folder. + +For more detailed information about the +integration, [see more here](https://kotlinlang.org/docs/reference/native/cocoapods.html) + +## Create Podfile + +If your iOS project doesn't have a `Podfile` yet, you'll need one. If your project is already using +Cocoapods, then skip ahead to the next section. + +In the command line, run `touch Podfile` in your iOS project's root directory. Then paste the +following into your new `Podfile`: + + ``` +use_frameworks! + +platform :ios, '15.0' + +install! 'cocoapods', :deterministic_uuids => false + +target 'YourIosAppTargetName' do + // Pods go here +end +``` + +Now, replace `YourIosAppTargetName` with, you guessed it, your iOS app's target name. In the KaMP Kit iOS sample + app, that would be `KaMPKitiOS`. + + +## Add KMP Pod + +Add the following line in your `target` block (replace `// Pods go here` in our example above): + +``` + pod 'shared', :path => '~/[PATH_TO_KaMPKit/shared/]' +``` + +Next, replace `~/[PATH_TO_KaMPKit/shared/]` with the path to your `KaMPKit/shared/` directory. For example: +``` + pod 'shared', :path => '~/Desktop/KaMPKit/shared/' +``` +This path can be either absolute or relative, but we realize that your KaMP Kit project and your existing iOS + project might be in very different places, so we're using an absolute path as an example for simplicity's sake. + + +## Install and Run + +Save the changes to your `Podfile`. Go back to the command line, and in your iOS project's root directory, run `pod + install`. + + This command will create a `Pods/` folder and a `.xcworkspace` file in your iOS project's root directory. Open the + `.xcworkspace` file. Remember that if your project was already using Cocoapods, and you had your `.xcworkspace + ` file open in Xcode, you need to close and reopen it. + +From now on, you will work out of the `.xcworkspace` file instead of the `.xcodeproj` file (which is part of + your `.xcworkspace`). To use code from your `shared` KMP library, at the top of the `.swift` file where you + want to use it, add: + + ``` +import shared +``` diff --git a/docs/Screenshots/AddFiles.png b/docs/Screenshots/AddFiles.png new file mode 100644 index 0000000..04ee9b9 Binary files /dev/null and b/docs/Screenshots/AddFiles.png differ diff --git a/docs/Screenshots/FolderRef.png b/docs/Screenshots/FolderRef.png new file mode 100644 index 0000000..07dc20e Binary files /dev/null and b/docs/Screenshots/FolderRef.png differ diff --git a/docs/Screenshots/kampScreenshotAndroid.png b/docs/Screenshots/kampScreenshotAndroid.png new file mode 100644 index 0000000..87c0490 Binary files /dev/null and b/docs/Screenshots/kampScreenshotAndroid.png differ diff --git a/docs/Screenshots/kampScreenshotiOS.png b/docs/Screenshots/kampScreenshotiOS.png new file mode 100644 index 0000000..9813d15 Binary files /dev/null and b/docs/Screenshots/kampScreenshotiOS.png differ diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md new file mode 100644 index 0000000..2d1befd --- /dev/null +++ b/docs/TROUBLESHOOTING.md @@ -0,0 +1,35 @@ +# Troubleshooting + +**Q:** When I tried to build the library, why did I get the following error? "SDK location not found. Define location with an ANDROID_SDK_ROOT environment variable or by setting up the sdk.dir path in your project's local properties file... " + +**A:** This error occurs when the project does not know the location of your local Android SDK. It should be located at `/Users/[YOUR_USER_NAME]/Library/Android/sdk`, which is where Android Studio recommends you put it during initial setup/installation. + +Opening this project in Android Studio will automatically create and configure a `local.properties` file for you. If you want to do that yourself, create a file called `local.properties` in the root directory of this project. Paste in the following line, replacing [YOUR_USER_NAME] with, you guessed it, the username you're using on your local machine: + +``` +sdk.dir=/Users/[YOUR_USER_NAME]/Library/Android/sdk +``` + +>**Note**: The `local.properties` file should not be committed to version control, as the path will be different for anyone else working on the project. + + +**Q:** When I tried to run the project in Xcode, why did I get the following error? "Framework not found shared_umbrella". + +**A:** You probably opened the `.xcodeproj` file in Xcode instead of the `.xcworkspace`. Close out the `.xcodeproj` and open the `.xcworkspace` and run again. + + To learn more about Cocoapods and how to use them, check out [their official guide](https://guides.cocoapods.org/using/index.html). + + +**Q:** The Xcode project won't compile. On the `import shared` line in Swift, I'm getting a compilation error "no + such module: 'shared'". + +**A:** Try closing Xcode and deleting the `Pods/` folder located in the root directory of the iOS project. Then run the command `pod install` in that same iOS root directory (which is `/KaMPKit/ios/` to be specific). This command will generate a new `Pods` folder. Reopen the `.xcworkspace` file and try to build again. + +> Note: We're still not quite sure as to the cause of this error. Possible factors include differing versions of Cocoapods or Xcode. + +**Q:** My iOS framework binary size is bigger after adding Kotlin/Native code. + +**A:** First confirm the actual impact on file size after uploading to the app store. Your library framework can contain bitcode and debug symbols which won't make it to the user's device. There is still a binary size overhead from adding Kotlin Native to a project, but there are things you can do to reduce it. Primarily, be conscious of what is being exposed from the shared code. If unnecessary code is public, it will add to the size of the generated ObjC headers. Ensure things in commonMain are marked as private or internal unless they need to be exposed to iOS. Using Kotlin's [explicit api mode](https://kotlinlang.org/docs/whatsnew14.html#explicit-api-mode-for-library-authors) can help enforce this. +## More to Come! + +[Let us know](../CONTACT_US.md) what issues you run into. diff --git a/docs/WHAT_AND_WHY.md b/docs/WHAT_AND_WHY.md new file mode 100644 index 0000000..51c219c --- /dev/null +++ b/docs/WHAT_AND_WHY.md @@ -0,0 +1,99 @@ +# KMP: What and Why? + +## 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. + +## What is KMP (Kotlin Multiplatform)? + +Kotlin is generally seen as a replacement JVM language, and by many as an “Android thing”. However, JetBrains and the +Kotlin team have much bigger goals in mind. The ultimate goal of “Kotlin” is a portable platform suitable for any +project. +You can transfer your skills and code to any task at hand. + +> To see more about Kotlin's Multiplatform vision, watch the [Kotlinconf Keynote](https://youtu.be/0xKTM0A8gdI) + +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 +use while learning Kotlin. +Integrating Kotlin is not an abrupt and dramatic (ie RISKY) change to your team’s development process. + +## What is this Kit? + +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 +the place. Most of it is bad, frankly. Usually because it is outdated. + +We have talked to many teams that have evaluated KMP. Like other new tech, the evaluation is generally one or a small +number of developers building a prototype within a fixed time frame. Often referred to as a “hack week”, if you even get +a week. Because of the KMP documentation situation, we commonly hear that it can take several days to get past basic +setup. Often that will mean abandoning the project, but even if the team continues the evaluation, the impression is +that KMP isn’t “ready”. + +We have directly helped some teams avoid that initial setup phase by being a documentation guide and providing basic +support. These teams have a much different experience with the tech. + +The goal of this “Kit” is to get your team to avoid “setup hell” and have you ready to go before lunch on day 1. +As we progress into 2020 and the documentation situation improves, this kit will be less necessary, but right now we +feel this kind of starting point is critical for success. + +Specifically this “Kit” includes introductory info, which you are reading currently, a pre-configured project, necessary +docs for platform information, contact and community info for support, and some “soft skills” docs. + +The project is configured with libraries generally useful for native mobile development, and some very basic examples +of how to use them in a mobile context. We will likely add additional projects as we get community feedback. + +There are aspects of Kotlin/Native that will be new to developers coming from either an Android or iOS background. The +most obvious is the concurrency model, which you’ll need to understand. These docs will provide the MVU, Minimum Viable +Understanding, to be productive, with links to deeper dives. + +A little bit of discussion and feedback can go a long way. There are a few options for community support. +The “soft skills” info is focused around discussing KMP to your team and management. Just because it works doesn’t mean +everybody else will like it. This section will also evolve over time as more of the common concerns and pushback points +are addressed. + +## Why KMP? + +The case for KMP is a longer discussion. I discuss it at some length in various talks (https://vimeo.com/371428809). +Over the past 2 years we’ve constructed a short definition of what we think separates KMP from other options: + +> optional, natively-integrated, open-source, code sharing platform, based on the popular, modern language Kotlin. +> facilitates non-ui logic availability on many platforms (and Jetbrains) + +Optional: KMP interops easily and directly with the native platform, and is designed to be used seamlessly with existing +code. That means you can start small with code sharing and increase as time goes on. You do not need big, risky +rewrites. + +Natively-integrated: On the JVM Kotlin makes JVM bytecode. In JS, Kotlin outputs JS. On iOS you get an Xcode framework. +Kotlin’s Interop story is unique and a distinct advantage. + +* Open Source: It would be difficult to not be open source in 2020, but some tools are not. +* Code sharing: Not a monolithic singular “app”. Kotlin’s focus is code sharing (see optional above). +* Popular: Big, engaged community. Very active library development and support. Training, recruiting, etc. This matters. +* Modern: Kotlin as a platform is being built to last by intentionally not getting old. +* Non-UI: Picking a big, monolithic tech stack for mobile is risky. Shared UI doesn’t have a great history, but shared + logic is the history of computers. Build something that is incremental and “plays nice” with the host system is much + harder and will take longer, but is ultimately the successful strategy. There will be “shared UI” options for KMP. The + good news is they’ll be optional. +* Jetbrains: Jetbrains has built an amazing business on building the best IDE’s. They also make Kotlin the language. + This is a unique combination. They are self-funded, as in there is no VC or public shareholder pressure to have + immediate ROI. Jetbrains is here to stay, and they are committed to Kotlin. The tooling around KMP and Native is + evolving, + but it’s safe to assume Kotlin as a platform will have the best tooling in the industry. + +We, Touchlab, have a clear perspective on the future. That is, the future is very hard to predict. Kotlin as a platform +is a low-risk choice because of the reasons mentioned above. We prefer less risky choices because being “right” about +the future isn’t that important if you can reduce the cost of being “wrong”. diff --git a/docs/runconfig.png b/docs/runconfig.png new file mode 100644 index 0000000..83df0db Binary files /dev/null and b/docs/runconfig.png differ diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..d9775ba --- /dev/null +++ b/gradle.properties @@ -0,0 +1,19 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx6g +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official +# Tell the KMP plugin where the iOS project lives +xcodeproj=./ios +org.gradle.caching=true +org.gradle.parallel=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..a202906 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,114 @@ +[versions] +## SDK Versions +minSdk = "21" +targetSdk = "35" +compileSdk = "35" + +# Dependencies +kotlin = "2.0.21" +android-gradle-plugin = "8.5.2" +ktlint-gradle = "12.1.1" +compose = "1.7.5" +android-desugaring = "2.1.3" +androidx-core = "1.15.0" +androidx-test-junit = "1.2.1" +androidx-activity-compose = "1.9.3" +androidx-lifecycle = "2.8.7" +junit = "4.13.2" +coroutines = "1.9.0" +kotlinx-datetime = "0.6.1" +ktor = "3.0.1" +robolectric = "4.13" +kermit = "2.0.4" +skie = "0.9.3" +koin = "3.5.3" +multiplatformSettings = "1.2.0" +turbine = "1.2.0" +sqlDelight = "2.0.2" + +[libraries] +android-desugaring = { module = "com.android.tools:desugar_jdk_libs", version.ref = "android-desugaring" } +androidx-core = { module = "androidx.core:core-ktx", version.ref = "androidx-core" } +androidx-lifecycle-runtime = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "androidx-lifecycle" } +androidx-lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "androidx-lifecycle" } +androidx-lifecycle-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" } +androidx-test-junit = { module = "androidx.test.ext:junit-ktx", version.ref = "androidx-test-junit" } + +compose-ui = { module = "androidx.compose.ui:ui", version.ref = "compose" } +compose-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose" } +compose-foundation = { module = "androidx.compose.foundation:foundation", version.ref = "compose" } +compose-material = { module = "androidx.compose.material:material", version.ref = "compose" } +compose-activity = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity-compose" } + +coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } +coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } + +koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" } +koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" } +koin-test = { module = "io.insert-koin:koin-test", version.ref = "koin" } + +kotlinx-dateTime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" } + +ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } +ktor-client-ios = { module = "io.ktor:ktor-client-ios", version.ref = "ktor" } +ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } +ktor-client-okHttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } +ktor-client-serialization = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } +ktor-client-contentNegotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } +ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" } + +multiplatformSettings-common = { module = "com.russhwolf:multiplatform-settings", version.ref = "multiplatformSettings" } +multiplatformSettings-test = { module = "com.russhwolf:multiplatform-settings-test", version.ref = "multiplatformSettings" } + +roboelectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } + +sqlDelight-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqlDelight" } +sqlDelight-jvm = { module = "app.cash.sqldelight:sqlite-driver", version.ref = "sqlDelight" } +sqlDelight-coroutinesExt = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqlDelight" } +sqlDelight-native = { module = "app.cash.sqldelight:native-driver", version.ref = "sqlDelight" } + +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" } + +[plugins] +ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint-gradle" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +sqlDelight = { id = "app.cash.sqldelight", version.ref = "sqlDelight" } +android-application = { id = "com.android.application", version.ref = "android-gradle-plugin" } +android-library = { id = "com.android.library", version.ref = "android-gradle-plugin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +skie = { id = "co.touchlab.skie", version.ref = "skie" } +compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } + +[bundles] +app-ui = [ + "androidx-core", + "androidx-lifecycle-runtime", + "androidx-lifecycle-viewmodel", + "androidx-lifecycle-compose", + "compose-ui", + "compose-tooling", + "compose-foundation", + "compose-material", + "compose-activity" +] +ktor-common = ["ktor-client-core", "ktor-client-logging", "ktor-client-serialization", "ktor-client-contentNegotiation"] +shared-commonTest = [ + "kotlin-test", + "multiplatformSettings-test", + "koin-test", + "turbine", + "coroutines-test", + "ktor-client-mock" +] +shared-androidTest = [ + "androidx-test-junit", + "coroutines-test", + "roboelectric", + "sqlDelight-jvm" +] diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..e644113 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..a441313 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..1aa94a4 --- /dev/null +++ b/gradlew @@ -0,0 +1,249 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..7101f8e --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/ios/.gitignore b/ios/.gitignore new file mode 100644 index 0000000..c112d52 --- /dev/null +++ b/ios/.gitignore @@ -0,0 +1,26 @@ +# Created by https://www.gitignore.io/api/xcode +# Edit at https://www.gitignore.io/?templates=xcode + +### Xcode ### +# Xcode +# +# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore + +## User settings +xcuserdata/ + +## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) +*.xcscmblueprint +*.xccheckout + +## Xcode Patch +*.xcodeproj/* +!*.xcodeproj/project.pbxproj +!*.xcodeproj/xcshareddata/ +!*.xcworkspace/contents.xcworkspacedata +/*.gcno + +### Xcode Patch ### +**/xcshareddata/WorkspaceSettings.xcsettings + +# End of https://www.gitignore.io/api/xcode \ No newline at end of file diff --git a/ios/KaMPKitiOS.xcodeproj/project.pbxproj b/ios/KaMPKitiOS.xcodeproj/project.pbxproj new file mode 100644 index 0000000..be06a8a --- /dev/null +++ b/ios/KaMPKitiOS.xcodeproj/project.pbxproj @@ -0,0 +1,644 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 50; + objects = { + +/* Begin PBXBuildFile section */ + 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 */; }; + F1465F0123AA94BF0055F7C3 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1465F0023AA94BF0055F7C3 /* AppDelegate.swift */; }; + F1465F0A23AA94BF0055F7C3 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = F1465F0923AA94BF0055F7C3 /* Assets.xcassets */; }; + F1465F0D23AA94BF0055F7C3 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = F1465F0B23AA94BF0055F7C3 /* LaunchScreen.storyboard */; }; + F1465F1823AA94C00055F7C3 /* KaMPKitiOSTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1465F1723AA94C00055F7C3 /* KaMPKitiOSTests.swift */; }; + F1465F2323AA94C00055F7C3 /* KaMPKitiOSUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1465F2223AA94C00055F7C3 /* KaMPKitiOSUITests.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + F1465F1423AA94C00055F7C3 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = F1465EF523AA94BF0055F7C3 /* Project object */; + proxyType = 1; + remoteGlobalIDString = F1465EFC23AA94BF0055F7C3; + remoteInfo = KaMPKitiOS; + }; + F1465F1F23AA94C00055F7C3 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = F1465EF523AA94BF0055F7C3 /* Project object */; + proxyType = 1; + remoteGlobalIDString = F1465EFC23AA94BF0055F7C3; + remoteInfo = KaMPKitiOS; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + 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 = ""; }; + F1465EFD23AA94BF0055F7C3 /* KaMPKitiOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = KaMPKitiOS.app; sourceTree = BUILT_PRODUCTS_DIR; }; + F1465F0023AA94BF0055F7C3 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + F1465F0923AA94BF0055F7C3 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + F1465F0C23AA94BF0055F7C3 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + F1465F0E23AA94BF0055F7C3 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + F1465F1323AA94C00055F7C3 /* KaMPKitiOSTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = KaMPKitiOSTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + F1465F1723AA94C00055F7C3 /* KaMPKitiOSTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KaMPKitiOSTests.swift; sourceTree = ""; }; + F1465F1923AA94C00055F7C3 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + F1465F1E23AA94C00055F7C3 /* KaMPKitiOSUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = KaMPKitiOSUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + F1465F2223AA94C00055F7C3 /* KaMPKitiOSUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KaMPKitiOSUITests.swift; sourceTree = ""; }; + F1465F2423AA94C00055F7C3 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + F1465EFA23AA94BF0055F7C3 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + F1465F1023AA94C00055F7C3 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + F1465F1B23AA94C00055F7C3 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 6278498AD96A4D949D39BF44 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; + F1465EF423AA94BF0055F7C3 = { + isa = PBXGroup; + children = ( + F1465EFF23AA94BF0055F7C3 /* KaMPKitiOS */, + F1465F1623AA94C00055F7C3 /* KaMPKitiOSTests */, + F1465F2123AA94C00055F7C3 /* KaMPKitiOSUITests */, + F1465EFE23AA94BF0055F7C3 /* Products */, + 6278498AD96A4D949D39BF44 /* Frameworks */, + ); + sourceTree = ""; + }; + F1465EFE23AA94BF0055F7C3 /* Products */ = { + isa = PBXGroup; + children = ( + F1465EFD23AA94BF0055F7C3 /* KaMPKitiOS.app */, + F1465F1323AA94C00055F7C3 /* KaMPKitiOSTests.xctest */, + F1465F1E23AA94C00055F7C3 /* KaMPKitiOSUITests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + F1465EFF23AA94BF0055F7C3 /* KaMPKitiOS */ = { + isa = PBXGroup; + children = ( + F1465F0023AA94BF0055F7C3 /* AppDelegate.swift */, + 46A5B60626B04920002EFEAA /* Main.storyboard */, + 46B5284C249C5CF400A7725D /* Koin.swift */, + F1465F0923AA94BF0055F7C3 /* Assets.xcassets */, + F1465F0B23AA94BF0055F7C3 /* LaunchScreen.storyboard */, + F1465F0E23AA94BF0055F7C3 /* Info.plist */, + 46A5B5EE26AF54F7002EFEAA /* BreedListScreen.swift */, + ); + path = KaMPKitiOS; + sourceTree = ""; + }; + F1465F1623AA94C00055F7C3 /* KaMPKitiOSTests */ = { + isa = PBXGroup; + children = ( + F1465F1723AA94C00055F7C3 /* KaMPKitiOSTests.swift */, + F1465F1923AA94C00055F7C3 /* Info.plist */, + ); + path = KaMPKitiOSTests; + sourceTree = ""; + }; + F1465F2123AA94C00055F7C3 /* KaMPKitiOSUITests */ = { + isa = PBXGroup; + children = ( + F1465F2223AA94C00055F7C3 /* KaMPKitiOSUITests.swift */, + F1465F2423AA94C00055F7C3 /* Info.plist */, + ); + path = KaMPKitiOSUITests; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + F1465EFC23AA94BF0055F7C3 /* KaMPKitiOS */ = { + isa = PBXNativeTarget; + buildConfigurationList = F1465F2723AA94C00055F7C3 /* Build configuration list for PBXNativeTarget "KaMPKitiOS" */; + buildPhases = ( + 9C16E6F82C0618F100154B87 /* ShellScript */, + F1465EF923AA94BF0055F7C3 /* Sources */, + F1465EFA23AA94BF0055F7C3 /* Frameworks */, + F1465EFB23AA94BF0055F7C3 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = KaMPKitiOS; + productName = KaMPKitiOS; + productReference = F1465EFD23AA94BF0055F7C3 /* KaMPKitiOS.app */; + productType = "com.apple.product-type.application"; + }; + F1465F1223AA94C00055F7C3 /* KaMPKitiOSTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = F1465F2A23AA94C00055F7C3 /* Build configuration list for PBXNativeTarget "KaMPKitiOSTests" */; + buildPhases = ( + F1465F0F23AA94C00055F7C3 /* Sources */, + F1465F1023AA94C00055F7C3 /* Frameworks */, + F1465F1123AA94C00055F7C3 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + F1465F1523AA94C00055F7C3 /* PBXTargetDependency */, + ); + name = KaMPKitiOSTests; + productName = KaMPKitiOSTests; + productReference = F1465F1323AA94C00055F7C3 /* KaMPKitiOSTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + F1465F1D23AA94C00055F7C3 /* KaMPKitiOSUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = F1465F2D23AA94C00055F7C3 /* Build configuration list for PBXNativeTarget "KaMPKitiOSUITests" */; + buildPhases = ( + F1465F1A23AA94C00055F7C3 /* Sources */, + F1465F1B23AA94C00055F7C3 /* Frameworks */, + F1465F1C23AA94C00055F7C3 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + F1465F2023AA94C00055F7C3 /* PBXTargetDependency */, + ); + name = KaMPKitiOSUITests; + productName = KaMPKitiOSUITests; + productReference = F1465F1E23AA94C00055F7C3 /* KaMPKitiOSUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + F1465EF523AA94BF0055F7C3 /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 1120; + LastUpgradeCheck = 1220; + ORGANIZATIONNAME = Touchlab; + TargetAttributes = { + F1465EFC23AA94BF0055F7C3 = { + CreatedOnToolsVersion = 11.2.1; + }; + F1465F1223AA94C00055F7C3 = { + CreatedOnToolsVersion = 11.2.1; + TestTargetID = F1465EFC23AA94BF0055F7C3; + }; + F1465F1D23AA94C00055F7C3 = { + CreatedOnToolsVersion = 11.2.1; + TestTargetID = F1465EFC23AA94BF0055F7C3; + }; + }; + }; + buildConfigurationList = F1465EF823AA94BF0055F7C3 /* Build configuration list for PBXProject "KaMPKitiOS" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = F1465EF423AA94BF0055F7C3; + productRefGroup = F1465EFE23AA94BF0055F7C3 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + F1465EFC23AA94BF0055F7C3 /* KaMPKitiOS */, + F1465F1223AA94C00055F7C3 /* KaMPKitiOSTests */, + F1465F1D23AA94C00055F7C3 /* KaMPKitiOSUITests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + F1465EFB23AA94BF0055F7C3 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 46A5B60826B04921002EFEAA /* Main.storyboard in Resources */, + F1465F0D23AA94BF0055F7C3 /* LaunchScreen.storyboard in Resources */, + F1465F0A23AA94BF0055F7C3 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + F1465F1123AA94C00055F7C3 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + F1465F1C23AA94C00055F7C3 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 9C16E6F82C0618F100154B87 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "# Type a script or drag a script file from your workspace to insert its path.\ncd \"$SRCROOT/..\"\n./gradlew embedAndSignAppleFrameworkForXcode\n"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + F1465EF923AA94BF0055F7C3 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 46B5284D249C5CF400A7725D /* Koin.swift in Sources */, + 46A5B5EF26AF54F7002EFEAA /* BreedListScreen.swift in Sources */, + F1465F0123AA94BF0055F7C3 /* AppDelegate.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + F1465F0F23AA94C00055F7C3 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + F1465F1823AA94C00055F7C3 /* KaMPKitiOSTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + F1465F1A23AA94C00055F7C3 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + F1465F2323AA94C00055F7C3 /* KaMPKitiOSUITests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + F1465F1523AA94C00055F7C3 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = F1465EFC23AA94BF0055F7C3 /* KaMPKitiOS */; + targetProxy = F1465F1423AA94C00055F7C3 /* PBXContainerItemProxy */; + }; + F1465F2023AA94C00055F7C3 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = F1465EFC23AA94BF0055F7C3 /* KaMPKitiOS */; + targetProxy = F1465F1F23AA94C00055F7C3 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 46A5B60626B04920002EFEAA /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 46A5B60726B04920002EFEAA /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + F1465F0B23AA94BF0055F7C3 /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + F1465F0C23AA94BF0055F7C3 /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + F1465F2523AA94C00055F7C3 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_IDENTITY = "Apple Development: brady.aiello@gmail.com (94U525PPDD)"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = 6A5MWU525T; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + F1465F2623AA94C00055F7C3 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_IDENTITY = "Apple Development: brady.aiello@gmail.com (94U525PPDD)"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = 6A5MWU525T; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + F1465F2823AA94C00055F7C3 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = ""; + INFOPLIST_FILE = KaMPKitiOS/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + "-l\"c++\"", + "-lsqlite3", + ); + PRODUCT_BUNDLE_IDENTIFIER = co.touchlab.kampkit.ios; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + F1465F2923AA94C00055F7C3 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = ""; + INFOPLIST_FILE = KaMPKitiOS/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + "-l\"c++\"", + "-lsqlite3", + ); + PRODUCT_BUNDLE_IDENTIFIER = co.touchlab.kampkit.ios; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + F1465F2B23AA94C00055F7C3 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = KaMPKitiOSTests/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = touchlab.KaMPKitiOSTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/KaMPKitiOS.app/KaMPKitiOS"; + }; + name = Debug; + }; + F1465F2C23AA94C00055F7C3 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = KaMPKitiOSTests/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = touchlab.KaMPKitiOSTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/KaMPKitiOS.app/KaMPKitiOS"; + }; + name = Release; + }; + F1465F2E23AA94C00055F7C3 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = KaMPKitiOSUITests/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = touchlab.KaMPKitiOSUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = KaMPKitiOS; + }; + name = Debug; + }; + F1465F2F23AA94C00055F7C3 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = KaMPKitiOSUITests/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = touchlab.KaMPKitiOSUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = KaMPKitiOS; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + F1465EF823AA94BF0055F7C3 /* Build configuration list for PBXProject "KaMPKitiOS" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + F1465F2523AA94C00055F7C3 /* Debug */, + F1465F2623AA94C00055F7C3 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + F1465F2723AA94C00055F7C3 /* Build configuration list for PBXNativeTarget "KaMPKitiOS" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + F1465F2823AA94C00055F7C3 /* Debug */, + F1465F2923AA94C00055F7C3 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + F1465F2A23AA94C00055F7C3 /* Build configuration list for PBXNativeTarget "KaMPKitiOSTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + F1465F2B23AA94C00055F7C3 /* Debug */, + F1465F2C23AA94C00055F7C3 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + F1465F2D23AA94C00055F7C3 /* Build configuration list for PBXNativeTarget "KaMPKitiOSUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + F1465F2E23AA94C00055F7C3 /* Debug */, + F1465F2F23AA94C00055F7C3 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = F1465EF523AA94BF0055F7C3 /* Project object */; +} diff --git a/ios/KaMPKitiOS.xcodeproj/xcshareddata/xcschemes/KaMPKitiOS.xcscheme b/ios/KaMPKitiOS.xcodeproj/xcshareddata/xcschemes/KaMPKitiOS.xcscheme new file mode 100644 index 0000000..e9d1867 --- /dev/null +++ b/ios/KaMPKitiOS.xcodeproj/xcshareddata/xcschemes/KaMPKitiOS.xcscheme @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/KaMPKitiOS/AppDelegate.swift b/ios/KaMPKitiOS/AppDelegate.swift new file mode 100644 index 0000000..2592419 --- /dev/null +++ b/ios/KaMPKitiOS/AppDelegate.swift @@ -0,0 +1,34 @@ +// +// AppDelegate.swift +// KaMPKitiOS +// +// Created by Kevin Schildhorn on 12/18/19. +// Copyright © 2019 Touchlab. All rights reserved. +// + +import SwiftUI +import shared + +@UIApplicationMain +class AppDelegate: UIResponder, UIApplicationDelegate { + + var window: UIWindow? + + // Lazy so it doesn't try to initialize before startKoin() is called + lazy var log = koin.loggerWithTag(tag: "AppDelegate") + + func application(_ application: UIApplication, didFinishLaunchingWithOptions + launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + + startKoin() + + let viewController = UIHostingController(rootView: BreedListScreen()) + + self.window = UIWindow(frame: UIScreen.main.bounds) + self.window?.rootViewController = viewController + self.window?.makeKeyAndVisible() + + log.v(message: {"App Started"}) + return true + } +} diff --git a/ios/KaMPKitiOS/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/KaMPKitiOS/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d8db8d6 --- /dev/null +++ b/ios/KaMPKitiOS/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,98 @@ +{ + "images" : [ + { + "idiom" : "iphone", + "size" : "20x20", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "20x20", + "scale" : "3x" + }, + { + "idiom" : "iphone", + "size" : "29x29", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "29x29", + "scale" : "3x" + }, + { + "idiom" : "iphone", + "size" : "40x40", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "40x40", + "scale" : "3x" + }, + { + "idiom" : "iphone", + "size" : "60x60", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "60x60", + "scale" : "3x" + }, + { + "idiom" : "ipad", + "size" : "20x20", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "20x20", + "scale" : "2x" + }, + { + "idiom" : "ipad", + "size" : "29x29", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "29x29", + "scale" : "2x" + }, + { + "idiom" : "ipad", + "size" : "40x40", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "40x40", + "scale" : "2x" + }, + { + "idiom" : "ipad", + "size" : "76x76", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "76x76", + "scale" : "2x" + }, + { + "idiom" : "ipad", + "size" : "83.5x83.5", + "scale" : "2x" + }, + { + "idiom" : "ios-marketing", + "size" : "1024x1024", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/ios/KaMPKitiOS/Assets.xcassets/Contents.json b/ios/KaMPKitiOS/Assets.xcassets/Contents.json new file mode 100644 index 0000000..da4a164 --- /dev/null +++ b/ios/KaMPKitiOS/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/ios/KaMPKitiOS/Base.lproj/LaunchScreen.storyboard b/ios/KaMPKitiOS/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..865e932 --- /dev/null +++ b/ios/KaMPKitiOS/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/KaMPKitiOS/Base.lproj/Main.storyboard b/ios/KaMPKitiOS/Base.lproj/Main.storyboard new file mode 100644 index 0000000..120cb69 --- /dev/null +++ b/ios/KaMPKitiOS/Base.lproj/Main.storyboard @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/KaMPKitiOS/BreedListScreen.swift b/ios/KaMPKitiOS/BreedListScreen.swift new file mode 100644 index 0000000..fce06e0 --- /dev/null +++ b/ios/KaMPKitiOS/BreedListScreen.swift @@ -0,0 +1,139 @@ +// +// BreedListView.swift +// KaMPKitiOS +// +// Created by Russell Wolf on 7/26/21. +// Copyright © 2021 Touchlab. All rights reserved. +// + +import SwiftUI +import shared + +private let log = koin.loggerWithTag(tag: "BreedListScreen") + +struct BreedListScreen: View { + + @State + var viewModel: BreedViewModel? + + @State + var breedState: BreedViewState = .Initial.shared + + var body: some View { + BreedListContent( + state: breedState, + onBreedFavorite: { breed in + Task { + try? await viewModel?.updateBreedFavorite(breed: breed) + } + }, + refresh: { + Task { + try? await viewModel?.refreshBreeds() + } + } + ) + .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 state: BreedViewState + var onBreedFavorite: (Breed) -> Void + var refresh: () -> Void + + var body: some View { + ZStack { + VStack { + switch onEnum(of: state) { + case .content(let content): + List(content.breeds, id: \.id) { breed in + BreedRowView(breed: breed) { + onBreedFavorite(breed) + } + } + 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 state.isLoading { Text("Loading...") } + } + } +} + +struct BreedRowView: View { + var breed: Breed + var onTap: () -> Void + + var body: some View { + Button(action: onTap) { + HStack { + Text(breed.name) + .padding(4.0) + Spacer() + Image(systemName: (!breed.favorite) ? "heart" : "heart.fill") + .padding(4.0) + } + } + } +} + +struct BreedListScreen_Previews: PreviewProvider { + static var previews: some View { + 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/Info.plist b/ios/KaMPKitiOS/Info.plist new file mode 100644 index 0000000..af8868b --- /dev/null +++ b/ios/KaMPKitiOS/Info.plist @@ -0,0 +1,45 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/ios/KaMPKitiOS/Koin.swift b/ios/KaMPKitiOS/Koin.swift new file mode 100644 index 0000000..e919748 --- /dev/null +++ b/ios/KaMPKitiOS/Koin.swift @@ -0,0 +1,38 @@ +// +// KoinApplication.swift +// KaMPStarteriOS +// +// Created by Russell Wolf on 6/18/20. +// Copyright © 2020 Touchlab. All rights reserved. +// + +import Foundation +import shared + +func startKoin() { + // You could just as easily define all these dependencies in Kotlin, + // but this helps demonstrate how you might pass platform-specific + // dependencies in a larger scale project where declaring them in + // Kotlin is more difficult, or where they're also used in + // iOS-specific code. + + let userDefaults = UserDefaults(suiteName: "KAMPSTARTER_SETTINGS")! + let iosAppInfo = IosAppInfo() + let doOnStartup = { NSLog("Hello from iOS/Swift!") } + + let koinApplication = KoinIOSKt.doInitKoinIos( + userDefaults: userDefaults, + appInfo: iosAppInfo, + doOnStartup: doOnStartup + ) + _koin = koinApplication.koin +} + +private var _koin: Koin_coreKoin? +var koin: Koin_coreKoin { + return _koin! +} + +class IosAppInfo: AppInfo { + let appId: String = Bundle.main.bundleIdentifier! +} diff --git a/ios/KaMPKitiOSTests/Info.plist b/ios/KaMPKitiOSTests/Info.plist new file mode 100644 index 0000000..64d65ca --- /dev/null +++ b/ios/KaMPKitiOSTests/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/ios/KaMPKitiOSTests/KaMPKitiOSTests.swift b/ios/KaMPKitiOSTests/KaMPKitiOSTests.swift new file mode 100644 index 0000000..3298a66 --- /dev/null +++ b/ios/KaMPKitiOSTests/KaMPKitiOSTests.swift @@ -0,0 +1,34 @@ +// +// KaMPKitiOSTests.swift +// KaMPKitiOSTests +// +// Created by Kevin Schildhorn on 12/18/19. +// Copyright © 2019 Touchlab. All rights reserved. +// + +import XCTest +@testable import KaMPKitiOS + +class KaMPKitiOSTests: XCTestCase { + + override func setUp() { + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDown() { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + func testExample() { + // This is an example of a functional test case. + // Use XCTAssert and related functions to verify your tests produce the correct results. + } + + func testPerformanceExample() { + // This is an example of a performance test case. + self.measure { + // Put the code you want to measure the time of here. + } + } + +} diff --git a/ios/KaMPKitiOSUITests/Info.plist b/ios/KaMPKitiOSUITests/Info.plist new file mode 100644 index 0000000..64d65ca --- /dev/null +++ b/ios/KaMPKitiOSUITests/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/ios/KaMPKitiOSUITests/KaMPKitiOSUITests.swift b/ios/KaMPKitiOSUITests/KaMPKitiOSUITests.swift new file mode 100644 index 0000000..9ef3123 --- /dev/null +++ b/ios/KaMPKitiOSUITests/KaMPKitiOSUITests.swift @@ -0,0 +1,44 @@ +// +// KaMPKitiOSUITests.swift +// KaMPKitiOSUITests +// +// Created by Kevin Schildhorn on 12/18/19. +// Copyright © 2019 Touchlab. All rights reserved. +// + +import XCTest + +class KaMPKitiOSUITests: XCTestCase { + + override func setUp() { + // Put setup code here. This method is called before the invocation of each test method in the class. + + // In UI tests it is usually best to stop immediately when a failure occurs. + continueAfterFailure = false + + // In UI tests it’s important to set the initial state - such as interface orientation - + // required for your tests before they run. The setUp method is a good place to do this. + } + + override func tearDown() { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + func testExample() { + // UI tests must launch the application that they test. + let app = XCUIApplication() + app.launch() + + // Use recording to get started writing UI tests. + // Use XCTAssert and related functions to verify your tests produce the correct results. + } + + func testLaunchPerformance() { + if #available(macOS 10.15, iOS 13.0, tvOS 13.0, *) { + // This measures how long it takes to launch your application. + measure(metrics: [XCTOSSignpostMetric.applicationLaunch]) { + XCUIApplication().launch() + } + } + } +} diff --git a/kampkit.png b/kampkit.png new file mode 100644 index 0000000..d6f8296 Binary files /dev/null and b/kampkit.png differ diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..9e3b243 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,36 @@ +enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") + +pluginManagement { + repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + @Suppress("UnstableApiUsage") + repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } + mavenCentral() + } +} + +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version("0.8.0") +} + +include(":app", ":shared") +rootProject.name = "KaMPKit" diff --git a/shared/.gitignore b/shared/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/shared/.gitignore @@ -0,0 +1 @@ +/build diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts new file mode 100644 index 0000000..6cea070 --- /dev/null +++ b/shared/build.gradle.kts @@ -0,0 +1,99 @@ +import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi +import org.jetbrains.kotlin.gradle.plugin.KotlinSourceSetTree + +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.android.library) + alias(libs.plugins.sqlDelight) + alias(libs.plugins.skie) +} + +android { + namespace = "co.touchlab.kampkit" + compileSdk = libs.versions.compileSdk.get().toInt() + defaultConfig { + minSdk = libs.versions.minSdk.get().toInt() + } + @Suppress("UnstableApiUsage") + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } + + lint { + warningsAsErrors = true + abortOnError = true + } +} + +version = "1.2" + +kotlin { + jvmToolchain(11) + // https://kotlinlang.org/docs/multiplatform-expect-actual.html#expected-and-actual-classes + // To suppress this warning about usage of expected and actual classes + @OptIn(ExperimentalKotlinGradlePluginApi::class) + compilerOptions { + freeCompilerArgs.add("-Xexpect-actual-classes") + } + androidTarget { + @Suppress("OPT_IN_USAGE") + unitTestVariant.sourceSetTree.set(KotlinSourceSetTree.test) + } + listOf( + iosX64(), + iosArm64(), + iosSimulatorArm64() + ).forEach { + it.binaries.framework { + isStatic = false + linkerOpts("-lsqlite3") + export(libs.touchlab.kermit.simple) + } + } + + sourceSets { + all { + languageSettings.apply { + optIn("kotlin.RequiresOptIn") + optIn("kotlinx.coroutines.ExperimentalCoroutinesApi") + optIn("kotlin.time.ExperimentalTime") + } + } + + commonMain.dependencies { + implementation(libs.koin.core) + implementation(libs.coroutines.core) + implementation(libs.sqlDelight.coroutinesExt) + implementation(libs.bundles.ktor.common) + implementation(libs.multiplatformSettings.common) + implementation(libs.kotlinx.dateTime) + implementation(libs.touchlab.skie.annotations) + api(libs.touchlab.kermit) + } + commonTest.dependencies { + implementation(libs.bundles.shared.commonTest) + } + androidMain.dependencies { + implementation(libs.androidx.lifecycle.viewmodel) + implementation(libs.sqlDelight.android) + implementation(libs.ktor.client.okHttp) + } + getByName("androidUnitTest").dependencies { + implementation(libs.bundles.shared.androidTest) + } + iosMain.dependencies { + implementation(libs.sqlDelight.native) + implementation(libs.ktor.client.ios) + api(libs.touchlab.kermit.simple) + } + } +} + +sqldelight { + databases.create("KaMPKitDb") { + packageName.set("co.touchlab.kampkit.db") + } +} diff --git a/shared/consumer-rules.pro b/shared/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/shared/proguard-rules.pro b/shared/proguard-rules.pro new file mode 100644 index 0000000..f1b4245 --- /dev/null +++ b/shared/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/shared/src/androidMain/kotlin/co/touchlab/kampkit/KoinAndroid.kt b/shared/src/androidMain/kotlin/co/touchlab/kampkit/KoinAndroid.kt new file mode 100644 index 0000000..2d2b8ff --- /dev/null +++ b/shared/src/androidMain/kotlin/co/touchlab/kampkit/KoinAndroid.kt @@ -0,0 +1,28 @@ +package co.touchlab.kampkit + +import app.cash.sqldelight.db.SqlDriver +import app.cash.sqldelight.driver.android.AndroidSqliteDriver +import co.touchlab.kampkit.db.KaMPKitDb +import com.russhwolf.settings.Settings +import com.russhwolf.settings.SharedPreferencesSettings +import io.ktor.client.engine.okhttp.OkHttp +import org.koin.core.module.Module +import org.koin.dsl.module + +actual val platformModule: Module = module { + single { + AndroidSqliteDriver( + KaMPKitDb.Schema, + get(), + "KampkitDb" + ) + } + + single { + SharedPreferencesSettings(get()) + } + + single { + OkHttp.create() + } +} diff --git a/shared/src/androidMain/kotlin/co/touchlab/kampkit/models/ViewModel.kt b/shared/src/androidMain/kotlin/co/touchlab/kampkit/models/ViewModel.kt new file mode 100644 index 0000000..9623482 --- /dev/null +++ b/shared/src/androidMain/kotlin/co/touchlab/kampkit/models/ViewModel.kt @@ -0,0 +1,9 @@ +package co.touchlab.kampkit.models + +import androidx.lifecycle.ViewModel as AndroidXViewModel + +actual abstract class ViewModel actual constructor() : AndroidXViewModel() { + actual override fun onCleared() { + super.onCleared() + } +} diff --git a/shared/src/androidUnitTest/kotlin/co/touchlab/kampkit/KoinTest.kt b/shared/src/androidUnitTest/kotlin/co/touchlab/kampkit/KoinTest.kt new file mode 100644 index 0000000..ba8c2b8 --- /dev/null +++ b/shared/src/androidUnitTest/kotlin/co/touchlab/kampkit/KoinTest.kt @@ -0,0 +1,42 @@ +package co.touchlab.kampkit + +import android.app.Application +import android.content.Context +import androidx.test.core.app.ApplicationProvider.getApplicationContext +import androidx.test.ext.junit.runners.AndroidJUnit4 +import co.touchlab.kermit.Logger +import kotlin.test.AfterTest +import kotlin.test.Test +import org.junit.experimental.categories.Category +import org.junit.runner.RunWith +import org.koin.core.context.stopKoin +import org.koin.core.parameter.parametersOf +import org.koin.dsl.module +import org.koin.test.category.CheckModuleTest +import org.koin.test.check.checkModules +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Category(CheckModuleTest::class) +@Config(sdk = [32]) +class KoinTest { + + @Test + fun checkAllModules() { + initKoin( + module { + single { getApplicationContext() } + single { get().getSharedPreferences("TEST", Context.MODE_PRIVATE) } + single { TestAppInfo } + single { {} } + } + ).checkModules { + withParameters { parametersOf("TestTag") } + } + } + + @AfterTest + fun breakdown() { + stopKoin() + } +} diff --git a/shared/src/androidUnitTest/kotlin/co/touchlab/kampkit/TestUtilAndroid.kt b/shared/src/androidUnitTest/kotlin/co/touchlab/kampkit/TestUtilAndroid.kt new file mode 100644 index 0000000..c338bd3 --- /dev/null +++ b/shared/src/androidUnitTest/kotlin/co/touchlab/kampkit/TestUtilAndroid.kt @@ -0,0 +1,8 @@ +package co.touchlab.kampkit + +import app.cash.sqldelight.db.SqlDriver +import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver +import co.touchlab.kampkit.db.KaMPKitDb + +internal actual fun testDbConnection(): SqlDriver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY) + .also { KaMPKitDb.Schema.create(it) } diff --git a/shared/src/commonMain/kotlin/co/touchlab/kampkit/AppInfo.kt b/shared/src/commonMain/kotlin/co/touchlab/kampkit/AppInfo.kt new file mode 100644 index 0000000..f2f339d --- /dev/null +++ b/shared/src/commonMain/kotlin/co/touchlab/kampkit/AppInfo.kt @@ -0,0 +1,5 @@ +package co.touchlab.kampkit + +interface AppInfo { + val appId: String +} diff --git a/shared/src/commonMain/kotlin/co/touchlab/kampkit/DatabaseHelper.kt b/shared/src/commonMain/kotlin/co/touchlab/kampkit/DatabaseHelper.kt new file mode 100644 index 0000000..6575c12 --- /dev/null +++ b/shared/src/commonMain/kotlin/co/touchlab/kampkit/DatabaseHelper.kt @@ -0,0 +1,56 @@ +package co.touchlab.kampkit + +import app.cash.sqldelight.coroutines.asFlow +import app.cash.sqldelight.coroutines.mapToList +import app.cash.sqldelight.db.SqlDriver +import co.touchlab.kampkit.db.Breed +import co.touchlab.kampkit.db.KaMPKitDb +import co.touchlab.kampkit.sqldelight.transactionWithContext +import co.touchlab.kermit.Logger +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOn + +class DatabaseHelper( + sqlDriver: SqlDriver, + private val log: Logger, + private val backgroundDispatcher: CoroutineDispatcher +) { + private val dbRef: KaMPKitDb = KaMPKitDb(sqlDriver) + + fun selectAllItems(): Flow> = dbRef.tableQueries + .selectAll() + .asFlow() + .mapToList(Dispatchers.Default) + .flowOn(backgroundDispatcher) + + suspend fun insertBreeds(breeds: List) { + log.d { "Inserting ${breeds.size} breeds into database" } + dbRef.transactionWithContext(backgroundDispatcher) { + breeds.forEach { breed -> + dbRef.tableQueries.insertBreed(breed) + } + } + } + + fun selectById(id: Long): Flow> = dbRef.tableQueries + .selectById(id) + .asFlow() + .mapToList(Dispatchers.Default) + .flowOn(backgroundDispatcher) + + suspend fun deleteAll() { + log.i { "Database Cleared" } + dbRef.transactionWithContext(backgroundDispatcher) { + dbRef.tableQueries.deleteAll() + } + } + + suspend fun updateFavorite(breedId: Long, favorite: Boolean) { + log.i { "Breed $breedId: Favorited $favorite" } + dbRef.transactionWithContext(backgroundDispatcher) { + dbRef.tableQueries.updateFavorite(favorite, breedId) + } + } +} diff --git a/shared/src/commonMain/kotlin/co/touchlab/kampkit/Koin.kt b/shared/src/commonMain/kotlin/co/touchlab/kampkit/Koin.kt new file mode 100644 index 0000000..f954ec0 --- /dev/null +++ b/shared/src/commonMain/kotlin/co/touchlab/kampkit/Koin.kt @@ -0,0 +1,87 @@ +package co.touchlab.kampkit + +import co.touchlab.kampkit.ktor.DogApi +import co.touchlab.kampkit.ktor.DogApiImpl +import co.touchlab.kampkit.models.BreedRepository +import co.touchlab.kermit.Logger +import co.touchlab.kermit.StaticConfig +import co.touchlab.kermit.platformLogWriter +import kotlinx.coroutines.Dispatchers +import kotlinx.datetime.Clock +import org.koin.core.KoinApplication +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import org.koin.core.context.startKoin +import org.koin.core.module.Module +import org.koin.core.parameter.parametersOf +import org.koin.core.scope.Scope +import org.koin.dsl.module + +fun initKoin(appModule: Module): KoinApplication { + val koinApplication = startKoin { + modules( + appModule, + platformModule, + coreModule + ) + } + + // Dummy initialization logic, making use of appModule declarations for demonstration purposes. + val koin = koinApplication.koin + // doOnStartup is a lambda which is implemented in Swift on iOS side + val doOnStartup = koin.get<() -> Unit>() + doOnStartup.invoke() + + val kermit = koin.get { parametersOf(null) } + // AppInfo is a Kotlin interface with separate Android and iOS implementations + val appInfo = koin.get() + kermit.v { "App Id ${appInfo.appId}" } + + return koinApplication +} + +private val coreModule = module { + single { + DatabaseHelper( + get(), + getWith("DatabaseHelper"), + Dispatchers.Default + ) + } + single { + DogApiImpl( + getWith("DogApiImpl"), + get() + ) + } + single { + Clock.System + } + + // platformLogWriter() is a relatively simple config option, useful for local debugging. For production + // uses you *may* want to have a more robust configuration from the native platform. In KaMP Kit, + // that would likely go into platformModule expect/actual. + // See https://github.com/touchlab/Kermit + val baseLogger = + Logger(config = StaticConfig(logWriterList = listOf(platformLogWriter())), "KampKit") + factory { (tag: String?) -> if (tag != null) baseLogger.withTag(tag) else baseLogger } + + single { + BreedRepository( + get(), + get(), + get(), + getWith("BreedRepository"), + get() + ) + } +} + +internal inline fun Scope.getWith(vararg params: Any?): T { + return get(parameters = { parametersOf(*params) }) +} + +// Simple function to clean up the syntax a bit +fun KoinComponent.injectLogger(tag: String): Lazy = inject { parametersOf(tag) } + +expect val platformModule: Module diff --git a/shared/src/commonMain/kotlin/co/touchlab/kampkit/ktor/DogApi.kt b/shared/src/commonMain/kotlin/co/touchlab/kampkit/ktor/DogApi.kt new file mode 100644 index 0000000..0e296ee --- /dev/null +++ b/shared/src/commonMain/kotlin/co/touchlab/kampkit/ktor/DogApi.kt @@ -0,0 +1,7 @@ +package co.touchlab.kampkit.ktor + +import co.touchlab.kampkit.response.BreedResult + +interface DogApi { + suspend fun getJsonFromApi(): BreedResult +} diff --git a/shared/src/commonMain/kotlin/co/touchlab/kampkit/ktor/DogApiImpl.kt b/shared/src/commonMain/kotlin/co/touchlab/kampkit/ktor/DogApiImpl.kt new file mode 100644 index 0000000..b4c4eeb --- /dev/null +++ b/shared/src/commonMain/kotlin/co/touchlab/kampkit/ktor/DogApiImpl.kt @@ -0,0 +1,56 @@ +package co.touchlab.kampkit.ktor + +import co.touchlab.kampkit.response.BreedResult +import co.touchlab.kermit.Logger as KermitLogger +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.engine.HttpClientEngine +import io.ktor.client.plugins.HttpTimeout +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.logging.LogLevel +import io.ktor.client.plugins.logging.Logger as KtorLogger +import io.ktor.client.plugins.logging.Logging +import io.ktor.client.request.HttpRequestBuilder +import io.ktor.client.request.get +import io.ktor.http.encodedPath +import io.ktor.http.takeFrom +import io.ktor.serialization.kotlinx.json.json + +class DogApiImpl(private val log: KermitLogger, engine: HttpClientEngine) : DogApi { + + private val client = HttpClient(engine) { + expectSuccess = true + install(ContentNegotiation) { + json() + } + install(Logging) { + logger = object : KtorLogger { + override fun log(message: String) { + log.v { message } + } + } + + level = LogLevel.INFO + } + install(HttpTimeout) { + val timeout = 30000L + connectTimeoutMillis = timeout + requestTimeoutMillis = timeout + socketTimeoutMillis = timeout + } + } + + override suspend fun getJsonFromApi(): BreedResult { + log.d { "Fetching Breeds from network" } + return client.get { + dogs("api/breeds/list/all") + }.body() + } + + private fun HttpRequestBuilder.dogs(path: String) { + url { + takeFrom("https://dog.ceo/") + encodedPath = path + } + } +} diff --git a/shared/src/commonMain/kotlin/co/touchlab/kampkit/models/BreedRepository.kt b/shared/src/commonMain/kotlin/co/touchlab/kampkit/models/BreedRepository.kt new file mode 100644 index 0000000..fd9a431 --- /dev/null +++ b/shared/src/commonMain/kotlin/co/touchlab/kampkit/models/BreedRepository.kt @@ -0,0 +1,58 @@ +package co.touchlab.kampkit.models + +import co.touchlab.kampkit.DatabaseHelper +import co.touchlab.kampkit.db.Breed +import co.touchlab.kampkit.ktor.DogApi +import co.touchlab.kermit.Logger +import com.russhwolf.settings.Settings +import kotlinx.coroutines.flow.Flow +import kotlinx.datetime.Clock + +class BreedRepository( + private val dbHelper: DatabaseHelper, + private val settings: Settings, + private val dogApi: DogApi, + log: Logger, + private val clock: Clock +) { + + private val log = log.withTag("BreedModel") + + companion object { + internal const val DB_TIMESTAMP_KEY = "DbTimestampKey" + } + + fun getBreeds(): Flow> = dbHelper.selectAllItems() + + suspend fun refreshBreedsIfStale() { + if (isBreedListStale()) { + refreshBreeds() + } + } + + suspend fun refreshBreeds() { + val breedResult = dogApi.getJsonFromApi() + log.v { "Breed network result: ${breedResult.status}" } + val breedList = breedResult.message.keys.sorted().toList() + log.v { "Fetched ${breedList.size} breeds from network" } + settings.putLong(DB_TIMESTAMP_KEY, clock.now().toEpochMilliseconds()) + + if (breedList.isNotEmpty()) { + dbHelper.insertBreeds(breedList) + } + } + + suspend fun updateBreedFavorite(breed: Breed) { + dbHelper.updateFavorite(breed.id, !breed.favorite) + } + + private fun isBreedListStale(): Boolean { + val lastDownloadTimeMS = settings.getLong(DB_TIMESTAMP_KEY, 0) + val oneHourMS = 60 * 60 * 1000 + val stale = lastDownloadTimeMS + oneHourMS < clock.now().toEpochMilliseconds() + if (!stale) { + log.i { "Breeds not fetched from network. Recently updated" } + } + return stale + } +} diff --git a/shared/src/commonMain/kotlin/co/touchlab/kampkit/models/BreedViewModel.kt b/shared/src/commonMain/kotlin/co/touchlab/kampkit/models/BreedViewModel.kt new file mode 100644 index 0000000..5dffe45 --- /dev/null +++ b/shared/src/commonMain/kotlin/co/touchlab/kampkit/models/BreedViewModel.kt @@ -0,0 +1,131 @@ +package co.touchlab.kampkit.models + +import co.touchlab.kampkit.db.Breed +import co.touchlab.kermit.Logger +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 + +class BreedViewModel( + private val breedRepository: BreedRepository, + private val log: Logger +) : ViewModel() { + + private val mutableBreedState: MutableStateFlow = + MutableStateFlow(BreedViewState.Initial) + + val breedState: StateFlow = mutableBreedState.asStateFlow() + + /** + * 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() + } + + override fun onCleared() { + log.v("Clearing BreedViewModel") + } + + private suspend fun observeBreeds() { + // Refresh breeds, and emit any exception that was thrown so we can handle it downstream + val refreshFlow = flow { + try { + breedRepository.refreshBreedsIfStale() + emit(null) + } catch (exception: Exception) { + emit(exception) + } + } + + 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() + } + } + } + } + + suspend fun refreshBreeds() { + // Set loading state, which will be cleared when the repository re-emits + 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) + } + } + + 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 { + 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" + ) + } + } + } +} + +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 new file mode 100644 index 0000000..f292265 --- /dev/null +++ b/shared/src/commonMain/kotlin/co/touchlab/kampkit/models/ViewModel.kt @@ -0,0 +1,5 @@ +package co.touchlab.kampkit.models + +expect abstract class ViewModel() { + protected open fun onCleared() +} diff --git a/shared/src/commonMain/kotlin/co/touchlab/kampkit/response/BreedResult.kt b/shared/src/commonMain/kotlin/co/touchlab/kampkit/response/BreedResult.kt new file mode 100644 index 0000000..4978459 --- /dev/null +++ b/shared/src/commonMain/kotlin/co/touchlab/kampkit/response/BreedResult.kt @@ -0,0 +1,9 @@ +package co.touchlab.kampkit.response + +import kotlinx.serialization.Serializable + +@Serializable +data class BreedResult( + val message: Map>, + var status: String +) diff --git a/shared/src/commonMain/kotlin/co/touchlab/kampkit/sqldelight/CoroutinesExtensions.kt b/shared/src/commonMain/kotlin/co/touchlab/kampkit/sqldelight/CoroutinesExtensions.kt new file mode 100644 index 0000000..b383cc0 --- /dev/null +++ b/shared/src/commonMain/kotlin/co/touchlab/kampkit/sqldelight/CoroutinesExtensions.kt @@ -0,0 +1,18 @@ +package co.touchlab.kampkit.sqldelight + +import app.cash.sqldelight.Transacter +import app.cash.sqldelight.TransactionWithoutReturn +import kotlin.coroutines.CoroutineContext +import kotlinx.coroutines.withContext + +suspend fun Transacter.transactionWithContext( + coroutineContext: CoroutineContext, + noEnclosing: Boolean = false, + body: TransactionWithoutReturn.() -> Unit +) { + withContext(coroutineContext) { + this@transactionWithContext.transaction(noEnclosing) { + body() + } + } +} diff --git a/shared/src/commonMain/sqldelight/co/touchlab/kampkit/db/Table.sq b/shared/src/commonMain/sqldelight/co/touchlab/kampkit/db/Table.sq new file mode 100644 index 0000000..a8b5135 --- /dev/null +++ b/shared/src/commonMain/sqldelight/co/touchlab/kampkit/db/Table.sq @@ -0,0 +1,26 @@ +import kotlin.Boolean; + +CREATE TABLE Breed ( +id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, +name TEXT NOT NULL UNIQUE, +favorite INTEGER AS Boolean NOT NULL DEFAULT 0 +); + +selectAll: +SELECT * FROM Breed; + +selectById: +SELECT * FROM Breed WHERE id = ?; + +selectByName: +SELECT * FROM Breed WHERE name = ?; + +insertBreed: +INSERT OR IGNORE INTO Breed(name) +VALUES (?); + +deleteAll: +DELETE FROM Breed; + +updateFavorite: +UPDATE Breed SET favorite = ? WHERE id = ?; diff --git a/shared/src/commonTest/kotlin/co/touchlab/kampkit/BreedRepositoryTest.kt b/shared/src/commonTest/kotlin/co/touchlab/kampkit/BreedRepositoryTest.kt new file mode 100644 index 0000000..da9a59d --- /dev/null +++ b/shared/src/commonTest/kotlin/co/touchlab/kampkit/BreedRepositoryTest.kt @@ -0,0 +1,157 @@ +package co.touchlab.kampkit + +import app.cash.turbine.test +import co.touchlab.kampkit.db.Breed +import co.touchlab.kampkit.mock.ClockMock +import co.touchlab.kampkit.mock.DogApiMock +import co.touchlab.kampkit.models.BreedRepository +import co.touchlab.kermit.Logger +import co.touchlab.kermit.StaticConfig +import com.russhwolf.settings.MapSettings +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFails +import kotlin.time.Duration.Companion.hours +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.Clock + +class BreedRepositoryTest { + + private var kermit = Logger(StaticConfig()) + private var testDbConnection = testDbConnection() + private var dbHelper = DatabaseHelper( + testDbConnection, + kermit, + Dispatchers.Default + ) + private val settings = MapSettings() + private val ktorApi = DogApiMock() + + // Need to start at non-zero time because the default value for db timestamp is 0 + private val clock = ClockMock(Clock.System.now()) + + private val repository: BreedRepository = + BreedRepository(dbHelper, settings, ktorApi, kermit, clock) + + 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 breedsNoFavorite = listOf(appenzeller, australianNoLike) + private val breedsFavorite = listOf(appenzeller, australianLike) + private val breedNames = breedsFavorite.map { it.name } + } + + @AfterTest + fun tearDown() = runTest { + testDbConnection.close() + } + + @Test + fun `Get breeds without cache`() = runTest { + ktorApi.prepareResult(ktorApi.successResult()) + repository.refreshBreedsIfStale() + repository.getBreeds().test { + assertEquals(breedsNoFavorite, awaitItem()) + } + } + + @Test + fun `Get updated breeds with cache and preserve favorites`() = runTest { + val successResult = ktorApi.successResult() + val resultWithExtraBreed = successResult.copy( + message = successResult.message + ("extra" to emptyList()) + ) + ktorApi.prepareResult(resultWithExtraBreed) + + dbHelper.insertBreeds(breedNames) + dbHelper.updateFavorite(australianLike.id, true) + + repository.getBreeds().test { + assertEquals(breedsFavorite, awaitItem()) + expectNoEvents() + + repository.refreshBreeds() + // id is 5 here because it incremented twice when trying to insert duplicate breeds + assertEquals(breedsFavorite + Breed(5, "extra", false), awaitItem()) + } + } + + @Test + fun `Get updated breeds when stale and preserve favorites`() = runTest { + settings.putLong( + BreedRepository.DB_TIMESTAMP_KEY, + (clock.currentInstant - 2.hours).toEpochMilliseconds() + ) + + val successResult = ktorApi.successResult() + val resultWithExtraBreed = successResult.copy( + message = successResult.message + ("extra" to emptyList()) + ) + ktorApi.prepareResult(resultWithExtraBreed) + + dbHelper.insertBreeds(breedNames) + dbHelper.updateFavorite(australianLike.id, true) + + repository.refreshBreedsIfStale() + repository.getBreeds().test { + // id is 5 here because it incremented twice when trying to insert duplicate breeds + assertEquals(breedsFavorite + Breed(5, "extra", false), awaitItem()) + } + } + + @Test + fun `Toggle favorite cached breed`() = runTest { + dbHelper.insertBreeds(breedNames) + dbHelper.updateFavorite(australianLike.id, true) + + repository.getBreeds().test { + assertEquals(breedsFavorite, awaitItem()) + expectNoEvents() + + repository.updateBreedFavorite(australianLike) + assertEquals(breedsNoFavorite, awaitItem()) + } + } + + @Test + fun `No web call if data is not stale`() = runTest { + settings.putLong( + BreedRepository.DB_TIMESTAMP_KEY, + clock.currentInstant.toEpochMilliseconds() + ) + ktorApi.prepareResult(ktorApi.successResult()) + + repository.refreshBreedsIfStale() + assertEquals(0, ktorApi.calledCount) + + repository.refreshBreeds() + assertEquals(1, ktorApi.calledCount) + } + + @Test + fun `Rethrow on API error`() = runTest { + ktorApi.throwOnCall(RuntimeException("Test error")) + + val throwable = assertFails { + repository.refreshBreeds() + } + assertEquals("Test error", throwable.message) + } + + @Test + fun `Rethrow on API error when stale`() = runTest { + settings.putLong( + BreedRepository.DB_TIMESTAMP_KEY, + (clock.currentInstant - 2.hours).toEpochMilliseconds() + ) + ktorApi.throwOnCall(RuntimeException("Test error")) + + val throwable = assertFails { + repository.refreshBreedsIfStale() + } + assertEquals("Test error", throwable.message) + } +} diff --git a/shared/src/commonTest/kotlin/co/touchlab/kampkit/BreedViewModelTest.kt b/shared/src/commonTest/kotlin/co/touchlab/kampkit/BreedViewModelTest.kt new file mode 100644 index 0000000..4e733dd --- /dev/null +++ b/shared/src/commonTest/kotlin/co/touchlab/kampkit/BreedViewModelTest.kt @@ -0,0 +1,298 @@ +package co.touchlab.kampkit + +import app.cash.turbine.ReceiveTurbine +import app.cash.turbine.test +import co.touchlab.kampkit.db.Breed +import co.touchlab.kampkit.mock.ClockMock +import co.touchlab.kampkit.mock.DogApiMock +import co.touchlab.kampkit.models.BreedRepository +import co.touchlab.kampkit.models.BreedViewModel +import co.touchlab.kampkit.models.BreedViewState +import co.touchlab.kampkit.response.BreedResult +import co.touchlab.kermit.Logger +import co.touchlab.kermit.StaticConfig +import com.russhwolf.settings.MapSettings +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Duration.Companion.hours +import kotlinx.coroutines.DelicateCoroutinesApi +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 +import kotlinx.datetime.Clock + +class BreedViewModelTest { + private var kermit = Logger(StaticConfig()) + private var testDbConnection = testDbConnection() + private var dbHelper = DatabaseHelper( + testDbConnection, + kermit, + Dispatchers.Default + ) + private val settings = MapSettings() + private val ktorApi = DogApiMock() + + // Need to start at non-zero time because the default value for db timestamp is 0 + private val clock = ClockMock(Clock.System.now()) + + private val repository: BreedRepository = + BreedRepository(dbHelper, settings, ktorApi, kermit, clock) + + @OptIn(DelicateCoroutinesApi::class) + 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.Content( + breeds = listOf(appenzeller, australianNoLike) + ) + private val breedViewStateSuccessFavorite = BreedViewState.Content( + breeds = listOf(appenzeller, australianLike) + ) + private val breedNames = breedViewStateSuccessNoFavorite.breeds.map { it.name } + } + + @BeforeTest + fun setup() { + Dispatchers.setMain(Dispatchers.Unconfined) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + testDbConnection.close() + } + + @Test + fun `Get breeds without cache`() = runTest { + ktorApi.prepareResult(ktorApi.successResult()) + + viewModel.breedState.test { + assertEquals( + breedViewStateSuccessNoFavorite, + awaitItemPrecededBy(BreedViewState.Initial, BreedViewState.Empty()) + ) + } + } + + @Test + fun `Get breeds empty`() = runTest { + ktorApi.prepareResult(BreedResult(emptyMap(), "success")) + + viewModel.breedState.test { + assertEquals( + BreedViewState.Empty(), + awaitItemPrecededBy(BreedViewState.Initial) + ) + } + } + + @Test + fun `Get updated breeds with cache and preserve favorites`() = runTest { + settings.putLong( + BreedRepository.DB_TIMESTAMP_KEY, + clock.currentInstant.toEpochMilliseconds() + ) + + val successResult = ktorApi.successResult() + val resultWithExtraBreed = successResult.copy( + message = successResult.message + ("extra" to emptyList()) + ) + ktorApi.prepareResult(resultWithExtraBreed) + + dbHelper.insertBreeds(breedNames) + dbHelper.updateFavorite(australianLike.id, true) + + viewModel.breedState.test { + assertEquals(breedViewStateSuccessFavorite, awaitItemPrecededBy(BreedViewState.Initial)) + expectNoEvents() + + viewModel.refreshBreeds() + // id is 5 here because it incremented twice when trying to insert duplicate breeds + assertEquals( + BreedViewState.Content( + breedViewStateSuccessFavorite.breeds + Breed(5, "extra", false) + ), + awaitItemPrecededBy(breedViewStateSuccessFavorite.copy(isLoading = true)) + ) + } + } + + @Test + fun `Get updated breeds when stale and preserve favorites`() = runTest { + settings.putLong( + BreedRepository.DB_TIMESTAMP_KEY, + (clock.currentInstant - 2.hours).toEpochMilliseconds() + ) + + val successResult = ktorApi.successResult() + val resultWithExtraBreed = successResult.copy( + message = successResult.message + ("extra" to emptyList()) + ) + ktorApi.prepareResult(resultWithExtraBreed) + + dbHelper.insertBreeds(breedNames) + dbHelper.updateFavorite(australianLike.id, true) + + viewModel.breedState.test { + // id is 5 here because it incremented twice when trying to insert duplicate breeds + assertEquals( + BreedViewState.Content( + breedViewStateSuccessFavorite.breeds + Breed(5, "extra", false) + ), + awaitItemPrecededBy(BreedViewState.Initial, breedViewStateSuccessFavorite) + ) + } + } + + @Test + fun `Toggle favorite cached breed`() = runTest { + settings.putLong( + BreedRepository.DB_TIMESTAMP_KEY, + clock.currentInstant.toEpochMilliseconds() + ) + + dbHelper.insertBreeds(breedNames) + dbHelper.updateFavorite(australianLike.id, true) + + viewModel.breedState.test { + assertEquals(breedViewStateSuccessFavorite, awaitItemPrecededBy(BreedViewState.Initial)) + expectNoEvents() + + viewModel.updateBreedFavorite(australianLike) + assertEquals( + breedViewStateSuccessNoFavorite, + awaitItemPrecededBy(breedViewStateSuccessFavorite.copy(isLoading = true)) + ) + } + } + + @Test + fun `No web call if data is not stale`() = runTest { + settings.putLong( + BreedRepository.DB_TIMESTAMP_KEY, + clock.currentInstant.toEpochMilliseconds() + ) + ktorApi.prepareResult(ktorApi.successResult()) + dbHelper.insertBreeds(breedNames) + + viewModel.breedState.test { + assertEquals( + breedViewStateSuccessNoFavorite, + awaitItemPrecededBy(BreedViewState.Initial) + ) + assertEquals(0, ktorApi.calledCount) + expectNoEvents() + + viewModel.refreshBreeds() + assertEquals( + breedViewStateSuccessNoFavorite, + awaitItemPrecededBy(breedViewStateSuccessNoFavorite.copy(isLoading = true)) + ) + assertEquals(1, ktorApi.calledCount) + } + } + + @Test + fun `Display API error on first run`() = runTest { + ktorApi.throwOnCall(RuntimeException("Test error")) + + viewModel.breedState.test { + assertEquals( + BreedViewState.Error(error = "Unable to download breed list"), + awaitItemPrecededBy(BreedViewState.Initial, BreedViewState.Empty()) + ) + } + } + + @Test + fun `Ignore API error with cache`() = runTest { + dbHelper.insertBreeds(breedNames) + settings.putLong( + BreedRepository.DB_TIMESTAMP_KEY, + (clock.currentInstant - 2.hours).toEpochMilliseconds() + ) + ktorApi.throwOnCall(RuntimeException("Test error")) + + viewModel.breedState.test { + assertEquals( + breedViewStateSuccessNoFavorite, + awaitItemPrecededBy(BreedViewState.Initial) + ) + expectNoEvents() + + ktorApi.prepareResult(ktorApi.successResult()) + viewModel.refreshBreeds() + + assertEquals( + breedViewStateSuccessNoFavorite, + awaitItemPrecededBy(breedViewStateSuccessNoFavorite.copy(isLoading = true)) + ) + } + } + + @Test + fun `Ignore API error on refresh with cache`() = runTest { + ktorApi.prepareResult(ktorApi.successResult()) + + viewModel.breedState.test { + assertEquals( + breedViewStateSuccessNoFavorite, + awaitItemPrecededBy(BreedViewState.Initial, BreedViewState.Empty()) + ) + expectNoEvents() + + ktorApi.throwOnCall(RuntimeException("Test error")) + viewModel.refreshBreeds() + + assertEquals( + breedViewStateSuccessNoFavorite, + awaitItemPrecededBy(breedViewStateSuccessNoFavorite.copy(isLoading = true)) + ) + } + } + + @Test + fun `Show API error on refresh without cache`() = runTest { + settings.putLong( + BreedRepository.DB_TIMESTAMP_KEY, + clock.currentInstant.toEpochMilliseconds() + ) + ktorApi.throwOnCall(RuntimeException("Test error")) + + viewModel.breedState.test { + assertEquals(BreedViewState.Empty(), awaitItemPrecededBy(BreedViewState.Initial)) + expectNoEvents() + + viewModel.refreshBreeds() + assertEquals( + BreedViewState.Error(error = "Unable to refresh breed list"), + awaitItemPrecededBy(BreedViewState.Empty(isLoading = true)) + ) + } + } +} + +// There's a race condition where intermediate states can get missed if the next state comes too fast. +// This function addresses that by awaiting an item that may or may not be preceded by the specified other items +private suspend fun ReceiveTurbine.awaitItemPrecededBy( + vararg items: BreedViewState +): BreedViewState { + var nextItem = awaitItem() + for (item in items) { + if (item == nextItem) { + nextItem = awaitItem() + } + } + return nextItem +} diff --git a/shared/src/commonTest/kotlin/co/touchlab/kampkit/DogApiTest.kt b/shared/src/commonTest/kotlin/co/touchlab/kampkit/DogApiTest.kt new file mode 100644 index 0000000..d390d87 --- /dev/null +++ b/shared/src/commonTest/kotlin/co/touchlab/kampkit/DogApiTest.kt @@ -0,0 +1,73 @@ +package co.touchlab.kampkit + +import co.touchlab.kampkit.ktor.DogApiImpl +import co.touchlab.kampkit.response.BreedResult +import co.touchlab.kermit.LogWriter +import co.touchlab.kermit.Logger +import co.touchlab.kermit.LoggerConfig +import co.touchlab.kermit.Severity +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.plugins.ClientRequestException +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlinx.coroutines.test.runTest + +class DogApiTest { + private val emptyLogger = Logger( + config = object : LoggerConfig { + override val logWriterList: List = emptyList() + override val minSeverity: Severity = Severity.Assert + }, + tag = "" + ) + + @Test + fun success() = runTest { + val engine = MockEngine { + assertEquals("https://dog.ceo/api/breeds/list/all", it.url.toString()) + respond( + content = """ + {"message":{"affenpinscher":[],"african":["shepherd"]},"status":"success"} + """.trimIndent(), + headers = headersOf( + HttpHeaders.ContentType, + ContentType.Application.Json.toString() + ) + ) + } + val dogApi = DogApiImpl(emptyLogger, engine) + + val result = dogApi.getJsonFromApi() + assertEquals( + BreedResult( + mapOf( + "affenpinscher" to emptyList(), + "african" to listOf("shepherd") + ), + "success" + ), + result + ) + } + + @Test + fun failure() = runTest { + val engine = MockEngine { + respond( + content = "", + status = HttpStatusCode.NotFound + ) + } + val dogApi = DogApiImpl(emptyLogger, engine) + + assertFailsWith { + dogApi.getJsonFromApi() + } + } +} diff --git a/shared/src/commonTest/kotlin/co/touchlab/kampkit/SqlDelightTest.kt b/shared/src/commonTest/kotlin/co/touchlab/kampkit/SqlDelightTest.kt new file mode 100644 index 0000000..09c5958 --- /dev/null +++ b/shared/src/commonTest/kotlin/co/touchlab/kampkit/SqlDelightTest.kt @@ -0,0 +1,79 @@ +package co.touchlab.kampkit + +import co.touchlab.kermit.Logger +import co.touchlab.kermit.StaticConfig +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest + +class SqlDelightTest { + + private lateinit var dbHelper: DatabaseHelper + + private suspend fun DatabaseHelper.insertBreed(name: String) { + insertBreeds(listOf(name)) + } + + @BeforeTest + fun setup() = runTest { + dbHelper = DatabaseHelper( + testDbConnection(), + Logger(StaticConfig()), + Dispatchers.Default + ) + dbHelper.deleteAll() + dbHelper.insertBreed("Beagle") + } + + @Test + fun `Select All Items Success`() = runTest { + val breeds = dbHelper.selectAllItems().first() + assertNotNull( + breeds.find { it.name == "Beagle" }, + "Could not retrieve Breed" + ) + } + + @Test + fun `Select Item by Id Success`() = runTest { + val breeds = dbHelper.selectAllItems().first() + val firstBreed = breeds.first() + assertNotNull( + dbHelper.selectById(firstBreed.id), + "Could not retrieve Breed by Id" + ) + } + + @Test + fun `Update Favorite Success`() = runTest { + val breeds = dbHelper.selectAllItems().first() + val firstBreed = breeds.first() + dbHelper.updateFavorite(firstBreed.id, true) + val newBreed = dbHelper.selectById(firstBreed.id).first().first() + assertNotNull( + newBreed, + "Could not retrieve Breed by Id" + ) + assertTrue( + newBreed.favorite, + "Favorite Did Not Save" + ) + } + + @Test + fun `Delete All Success`() = runTest { + dbHelper.insertBreed("Poodle") + dbHelper.insertBreed("Schnauzer") + assertTrue(dbHelper.selectAllItems().first().isNotEmpty()) + dbHelper.deleteAll() + + assertTrue( + dbHelper.selectAllItems().first().isEmpty(), + "Delete All did not work" + ) + } +} diff --git a/shared/src/commonTest/kotlin/co/touchlab/kampkit/TestAppInfo.kt b/shared/src/commonTest/kotlin/co/touchlab/kampkit/TestAppInfo.kt new file mode 100644 index 0000000..3e24355 --- /dev/null +++ b/shared/src/commonTest/kotlin/co/touchlab/kampkit/TestAppInfo.kt @@ -0,0 +1,5 @@ +package co.touchlab.kampkit + +object TestAppInfo : AppInfo { + override val appId: String = "Test" +} diff --git a/shared/src/commonTest/kotlin/co/touchlab/kampkit/TestUtil.kt b/shared/src/commonTest/kotlin/co/touchlab/kampkit/TestUtil.kt new file mode 100644 index 0000000..f45ccc2 --- /dev/null +++ b/shared/src/commonTest/kotlin/co/touchlab/kampkit/TestUtil.kt @@ -0,0 +1,5 @@ +package co.touchlab.kampkit + +import app.cash.sqldelight.db.SqlDriver + +internal expect fun testDbConnection(): SqlDriver diff --git a/shared/src/commonTest/kotlin/co/touchlab/kampkit/mock/ClockMock.kt b/shared/src/commonTest/kotlin/co/touchlab/kampkit/mock/ClockMock.kt new file mode 100644 index 0000000..0592199 --- /dev/null +++ b/shared/src/commonTest/kotlin/co/touchlab/kampkit/mock/ClockMock.kt @@ -0,0 +1,8 @@ +package co.touchlab.kampkit.mock + +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant + +class ClockMock(var currentInstant: Instant) : Clock { + override fun now(): Instant = currentInstant +} diff --git a/shared/src/commonTest/kotlin/co/touchlab/kampkit/mock/DogApiMock.kt b/shared/src/commonTest/kotlin/co/touchlab/kampkit/mock/DogApiMock.kt new file mode 100644 index 0000000..5460f84 --- /dev/null +++ b/shared/src/commonTest/kotlin/co/touchlab/kampkit/mock/DogApiMock.kt @@ -0,0 +1,33 @@ +package co.touchlab.kampkit.mock + +import co.touchlab.kampkit.ktor.DogApi +import co.touchlab.kampkit.response.BreedResult + +// TODO convert this to use Ktor's MockEngine +class DogApiMock : DogApi { + private var nextResult: () -> BreedResult = { error("Uninitialized!") } + var calledCount = 0 + private set + + override suspend fun getJsonFromApi(): BreedResult { + val result = nextResult() + calledCount++ + return result + } + + fun successResult(): BreedResult { + val map = HashMap>().apply { + put("appenzeller", emptyList()) + put("australian", listOf("shepherd")) + } + return BreedResult(map, "success") + } + + fun prepareResult(breedResult: BreedResult) { + nextResult = { breedResult } + } + + fun throwOnCall(throwable: Throwable) { + nextResult = { throw throwable } + } +} diff --git a/shared/src/iosMain/kotlin/co/touchlab/kampkit/KermitExceptionHandler.kt b/shared/src/iosMain/kotlin/co/touchlab/kampkit/KermitExceptionHandler.kt new file mode 100644 index 0000000..e13f9ed --- /dev/null +++ b/shared/src/iosMain/kotlin/co/touchlab/kampkit/KermitExceptionHandler.kt @@ -0,0 +1,9 @@ +package co.touchlab.kampkit + +import co.touchlab.kermit.Logger +import kotlinx.coroutines.CoroutineExceptionHandler + +fun kermitExceptionHandler(log: Logger) = CoroutineExceptionHandler { _, throwable -> + throwable.printStackTrace() + log.e(throwable = throwable) { "Error in MainScope" } +} diff --git a/shared/src/iosMain/kotlin/co/touchlab/kampkit/KoinIOS.kt b/shared/src/iosMain/kotlin/co/touchlab/kampkit/KoinIOS.kt new file mode 100644 index 0000000..148244d --- /dev/null +++ b/shared/src/iosMain/kotlin/co/touchlab/kampkit/KoinIOS.kt @@ -0,0 +1,45 @@ +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 +import io.ktor.client.engine.darwin.Darwin +import org.koin.core.Koin +import org.koin.core.KoinApplication +import org.koin.core.component.KoinComponent +import org.koin.core.parameter.parametersOf +import org.koin.dsl.module +import platform.Foundation.NSUserDefaults + +fun initKoinIos( + userDefaults: NSUserDefaults, + appInfo: AppInfo, + doOnStartup: () -> Unit +): KoinApplication = initKoin( + module { + single { NSUserDefaultsSettings(userDefaults) } + single { appInfo } + single { doOnStartup } + } +) + +actual val platformModule = module { + single { NativeSqliteDriver(KaMPKitDb.Schema, "KampkitDb") } + + single { Darwin.create() } + + single { BreedViewModel(get(), getWith("BreedViewModel")) } +} + +// Access from Swift to create a logger +@Suppress("unused") +fun Koin.loggerWithTag(tag: String) = get(qualifier = null) { parametersOf(tag) } + +@Suppress("unused") // Called from Swift +object KotlinDependencies : KoinComponent { + 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 new file mode 100644 index 0000000..62dce1a --- /dev/null +++ b/shared/src/iosMain/kotlin/co/touchlab/kampkit/models/ViewModel.kt @@ -0,0 +1,18 @@ +package co.touchlab.kampkit.models + +/** + * Base class that provides a Kotlin/Native equivalent to the AndroidX `ViewModel`. + */ +actual abstract class ViewModel actual constructor() { + + /** + * Override this to do any cleanup immediately before the internal [CoroutineScope][kotlinx.coroutines.CoroutineScope] + * is cancelled. + */ + protected actual open fun onCleared() { + } + + fun clear() { + onCleared() + } +} diff --git a/shared/src/iosTest/kotlin/co/touchlab/kampkit/KoinTest.kt b/shared/src/iosTest/kotlin/co/touchlab/kampkit/KoinTest.kt new file mode 100644 index 0000000..834b8df --- /dev/null +++ b/shared/src/iosTest/kotlin/co/touchlab/kampkit/KoinTest.kt @@ -0,0 +1,27 @@ +package co.touchlab.kampkit + +import co.touchlab.kermit.Logger +import kotlin.test.AfterTest +import kotlin.test.Test +import org.koin.core.context.stopKoin +import org.koin.core.parameter.parametersOf +import org.koin.test.check.checkModules +import platform.Foundation.NSUserDefaults + +class KoinTest { + @Test + fun checkAllModules() { + initKoinIos( + userDefaults = NSUserDefaults.standardUserDefaults, + appInfo = TestAppInfo, + doOnStartup = { } + ).checkModules { + withParameters { parametersOf("TestTag") } + } + } + + @AfterTest + fun breakdown() { + stopKoin() + } +} diff --git a/shared/src/iosTest/kotlin/co/touchlab/kampkit/TestUtilIOS.kt b/shared/src/iosTest/kotlin/co/touchlab/kampkit/TestUtilIOS.kt new file mode 100644 index 0000000..09128f4 --- /dev/null +++ b/shared/src/iosTest/kotlin/co/touchlab/kampkit/TestUtilIOS.kt @@ -0,0 +1,9 @@ +package co.touchlab.kampkit + +import app.cash.sqldelight.db.SqlDriver +import app.cash.sqldelight.driver.native.inMemoryDriver +import co.touchlab.kampkit.db.KaMPKitDb + +internal actual fun testDbConnection(): SqlDriver { + return inMemoryDriver(KaMPKitDb.Schema) +} diff --git a/tl2.png b/tl2.png new file mode 100644 index 0000000..7737ae5 Binary files /dev/null and b/tl2.png differ