├── .editorconfig ├── .github ├── dependabot.yml ├── pull_request_template.md └── workflows │ ├── KaMPKit-Android.yml │ └── KaMPKit-iOS.yml ├── .gitignore ├── .idea ├── codeStyles │ ├── Project.xml │ └── codeStyleConfig.xml └── inspectionProfiles │ ├── ktlint.xml │ └── profiles_settings.xml ├── CONTACT_US.md ├── LICENSE.txt ├── README.md ├── app ├── .gitignore ├── build.gradle.kts ├── proguard-rules.pro └── src │ └── main │ ├── AndroidManifest.xml │ ├── kotlin │ └── co │ │ └── touchlab │ │ └── kampkit │ │ └── android │ │ ├── MainActivity.kt │ │ ├── MainApp.kt │ │ └── ui │ │ ├── Composables.kt │ │ └── theme │ │ ├── Color.kt │ │ ├── Shapes.kt │ │ ├── Theme.kt │ │ └── Typography.kt │ └── res │ ├── drawable-v24 │ └── ic_launcher_foreground.xml │ ├── drawable │ ├── ic_favorite_24px.xml │ ├── ic_favorite_border_24px.xml │ └── ic_launcher_background.xml │ ├── mipmap-anydpi-v26 │ ├── ic_launcher.xml │ └── ic_launcher_round.xml │ ├── mipmap-hdpi │ ├── ic_launcher.png │ └── ic_launcher_round.png │ ├── mipmap-mdpi │ ├── ic_launcher.png │ └── ic_launcher_round.png │ ├── mipmap-xhdpi │ ├── ic_launcher.png │ └── ic_launcher_round.png │ ├── mipmap-xxhdpi │ ├── ic_launcher.png │ └── ic_launcher_round.png │ ├── mipmap-xxxhdpi │ ├── ic_launcher.png │ └── ic_launcher_round.png │ └── values │ ├── colors.xml │ ├── strings.xml │ └── styles.xml ├── build.gradle.kts ├── docs ├── APP_BUILD.md ├── DEBUGGING_KOTLIN_IN_XCODE.md ├── DETAILED_DEV_SETUP.md ├── GENERAL_ARCHITECTURE.md ├── IOS_PROJ_INTEGRATION.md ├── Screenshots │ ├── AddFiles.png │ ├── FolderRef.png │ ├── kampScreenshotAndroid.png │ └── kampScreenshotiOS.png ├── TROUBLESHOOTING.md ├── WHAT_AND_WHY.md └── runconfig.png ├── gradle.properties ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── ios ├── .gitignore ├── KaMPKitiOS.xcodeproj │ ├── project.pbxproj │ └── xcshareddata │ │ └── xcschemes │ │ └── KaMPKitiOS.xcscheme ├── KaMPKitiOS │ ├── AppDelegate.swift │ ├── Assets.xcassets │ │ ├── AppIcon.appiconset │ │ │ └── Contents.json │ │ └── Contents.json │ ├── Base.lproj │ │ ├── LaunchScreen.storyboard │ │ └── Main.storyboard │ ├── BreedListScreen.swift │ ├── Info.plist │ └── Koin.swift ├── KaMPKitiOSTests │ ├── Info.plist │ └── KaMPKitiOSTests.swift └── KaMPKitiOSUITests │ ├── Info.plist │ └── KaMPKitiOSUITests.swift ├── kampkit.png ├── settings.gradle.kts ├── shared ├── .gitignore ├── build.gradle.kts ├── consumer-rules.pro ├── proguard-rules.pro └── src │ ├── androidMain │ └── kotlin │ │ └── co │ │ └── touchlab │ │ └── kampkit │ │ ├── KoinAndroid.kt │ │ └── models │ │ └── ViewModel.kt │ ├── androidUnitTest │ └── kotlin │ │ └── co │ │ └── touchlab │ │ └── kampkit │ │ ├── KoinTest.kt │ │ └── TestUtilAndroid.kt │ ├── commonMain │ ├── kotlin │ │ └── co │ │ │ └── touchlab │ │ │ └── kampkit │ │ │ ├── AppInfo.kt │ │ │ ├── DatabaseHelper.kt │ │ │ ├── Koin.kt │ │ │ ├── ktor │ │ │ ├── DogApi.kt │ │ │ └── DogApiImpl.kt │ │ │ ├── models │ │ │ ├── BreedRepository.kt │ │ │ ├── BreedViewModel.kt │ │ │ └── ViewModel.kt │ │ │ ├── response │ │ │ └── BreedResult.kt │ │ │ └── sqldelight │ │ │ └── CoroutinesExtensions.kt │ └── sqldelight │ │ └── co │ │ └── touchlab │ │ └── kampkit │ │ └── db │ │ └── Table.sq │ ├── commonTest │ └── kotlin │ │ └── co │ │ └── touchlab │ │ └── kampkit │ │ ├── BreedRepositoryTest.kt │ │ ├── BreedViewModelTest.kt │ │ ├── DogApiTest.kt │ │ ├── SqlDelightTest.kt │ │ ├── TestAppInfo.kt │ │ ├── TestUtil.kt │ │ └── mock │ │ ├── ClockMock.kt │ │ └── DogApiMock.kt │ ├── iosMain │ └── kotlin │ │ └── co │ │ └── touchlab │ │ └── kampkit │ │ ├── KermitExceptionHandler.kt │ │ ├── KoinIOS.kt │ │ └── models │ │ └── ViewModel.kt │ └── iosTest │ └── kotlin │ └── co │ └── touchlab │ └── kampkit │ ├── KoinTest.kt │ └── TestUtilIOS.kt └── tl2.png /.editorconfig: -------------------------------------------------------------------------------- 1 | # noinspection EditorConfigKeyCorrectness 2 | [*.{kt,kts}] 3 | ktlint_code_style = android_studio 4 | ktlint_function_naming_ignore_when_annotated_with=Composable -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gradle" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | day: monday 8 | labels: ["dependencies"] 9 | ignore: 10 | - dependency-name: "*" 11 | update-types: ["version-update:semver-patch"] -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Issue: https://github.com/touchlab/KaMPKit/issues/[issue number] 5 | 6 | ## Summary 7 | 8 | 9 | ## Fix 10 | 11 | 12 | ## Testing 13 | 14 | - `./gradlew :app:build` 15 | - `./gradlew :shared:build` 16 | - `xcodebuild -workspace ios/KaMPKitiOS.xcworkspace -scheme KaMPKitiOS 17 | -sdk iphoneos -configuration Debug build -destination name="iPhone 8"` 18 | - manual testing 19 | 20 | 21 | ### **Screenshot / Video of App working with the Changes** 22 | fix in action -------------------------------------------------------------------------------- /.github/workflows/KaMPKit-Android.yml: -------------------------------------------------------------------------------- 1 | name: KaMPKit-Android 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - main 8 | pull_request: 9 | paths-ignore: 10 | - "**.md" 11 | - "*.png" 12 | - docs 13 | - ios 14 | 15 | jobs: 16 | build: 17 | runs-on: ubuntu-latest 18 | 19 | steps: 20 | - uses: actions/checkout@v4 21 | - uses: actions/setup-java@v4 22 | with: 23 | distribution: corretto 24 | java-version: 17 25 | 26 | - name: Setup Gradle 27 | uses: gradle/actions/setup-gradle@v4 28 | 29 | - name: Build 30 | run: ./gradlew build 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /.github/workflows/KaMPKit-iOS.yml: -------------------------------------------------------------------------------- 1 | name: KaMPKit-iOS 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - main 8 | pull_request: 9 | paths-ignore: 10 | - "**.md" 11 | - "*.png" 12 | - docs 13 | - app 14 | 15 | jobs: 16 | build: 17 | runs-on: macos-latest 18 | 19 | steps: 20 | - uses: actions/checkout@v4 21 | - uses: actions/setup-java@v4 22 | with: 23 | distribution: corretto 24 | java-version: 17 25 | 26 | - name: Setup Gradle 27 | uses: gradle/actions/setup-gradle@v4 28 | 29 | - name: Run tests 30 | run: ./gradlew :shared:iosX64Test 31 | 32 | - name: Build 33 | uses: sersoft-gmbh/xcodebuild-action@v1 34 | with: 35 | project: ios/KaMPKitiOS.xcodeproj 36 | scheme: KaMPKitiOS 37 | destination: name=iPhone 8 38 | sdk: iphoneos 39 | configuration: Debug 40 | action: build 41 | use-xcpretty: false 42 | build-settings: CODE_SIGN_IDENTITY= CODE_SIGNING_REQUIRED=NO 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea 5 | .DS_Store 6 | /build 7 | /buildSrc/build 8 | /captures 9 | .externalNativeBuild 10 | .cxx 11 | *.xcuserstate 12 | *.xcbkptlist 13 | !/.idea/codeStyles/* 14 | !/.idea/inspectionProfiles/* 15 | .kotlin -------------------------------------------------------------------------------- /.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 11 | 12 | 13 | 15 | 16 | 17 |
18 | 19 | 20 | 21 | xmlns:android 22 | 23 | ^$ 24 | 25 | 26 | 27 |
28 |
29 | 30 | 31 | 32 | xmlns:.* 33 | 34 | ^$ 35 | 36 | 37 | BY_NAME 38 | 39 |
40 |
41 | 42 | 43 | 44 | .*:id 45 | 46 | http://schemas.android.com/apk/res/android 47 | 48 | 49 | 50 |
51 |
52 | 53 | 54 | 55 | .*:name 56 | 57 | http://schemas.android.com/apk/res/android 58 | 59 | 60 | 61 |
62 |
63 | 64 | 65 | 66 | name 67 | 68 | ^$ 69 | 70 | 71 | 72 |
73 |
74 | 75 | 76 | 77 | style 78 | 79 | ^$ 80 | 81 | 82 | 83 |
84 |
85 | 86 | 87 | 88 | .* 89 | 90 | ^$ 91 | 92 | 93 | BY_NAME 94 | 95 |
96 |
97 | 98 | 99 | 100 | .* 101 | 102 | http://schemas.android.com/apk/res/android 103 | 104 | 105 | ANDROID_ATTRIBUTE_ORDER 106 | 107 |
108 |
109 | 110 | 111 | 112 | .* 113 | 114 | .* 115 | 116 | 117 | BY_NAME 118 | 119 |
120 |
121 |
122 |
123 | 124 | 135 |
136 |
-------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/ktlint.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 43 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/profiles_settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | -------------------------------------------------------------------------------- /CONTACT_US.md: -------------------------------------------------------------------------------- 1 | # Contact Us 2 | 3 | KaMP Kit support can be found in the Kotlin Community Slack. Look for the `kampkit-support` channel. 4 | 5 | To join the Kotlin Community Slack, [request access here](http://slack.kotlinlang.org/) 6 | 7 | For direct assistance, please [reach out to Touchlab](https://go.touchlab.co/contactkamp) to discuss support options. 8 | 9 | If you find any bugs or issues in with project, you can create an issue in 10 | the [GitHub repository](https://github.com/touchlab/KaMPKit), but please don't mistake it with general KMP helpline. You 11 | can get answers for general questions in Slack. 12 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2020 Touchlab 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![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) 2 | [![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) 3 | 4 | # KaMP Kit 5 | 6 | ![KaMP Kit Image](kampkit.png) 7 | 8 | ***Welcome to KaMP Kit!*** 9 | 10 | ## Intro 11 | 12 | KaMP Kit started in early 2020 with the goal of helping developers interested in Kotlin Multiplatform (aka KMP) get started 13 | quickly with a great set of libraries and patterns. At the time, there were not many sample apps and getting started 14 | was not trivial. The KMP situation has improved considerably since then, and various barriers to entry have been 15 | removed. 16 | 17 | Whereas KaMP Kit started with the goal of being a minimal sample, we now intend it to be less "getting started" and 18 | more "best practice model". Watch this repo and follow [@TouchlabHQ](https://twitter.com/TouchlabHQ) for updates! 19 | 20 | ### 2023 Update 21 | 22 | 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) 23 | 24 | > ## Subscribe! 25 | > 26 | > 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. 27 | [Sign up here](https://touchlab.co/?s=shownewsletter)! 28 | 29 | ## Getting Help 30 | 31 | 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. 32 | 33 | For direct assistance, please [contact Touchlab](https://go.touchlab.co/contactkamp) to discuss support options. 34 | 35 | ## About 36 | 37 | ### Goal 38 | 39 | The goal of KaMP Kit is to facilitate your evaluation of KMP. It is a collection of code and 40 | tools designed to get you started quickly. It's also a showcase of Touchlab's typical choices for architecture, 41 | libraries, and other best practices. 42 | 43 | The KMP ecosystem has generated a lot of excitement, and has evolved very rapidly. As a result, there's a lot of old or 44 | 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. 45 | 46 | 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. 47 | 48 | #### *Very Important Message!!!* 49 | 50 | 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. 51 | 52 | ### Audience 53 | 54 | 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. 55 | 56 | ## What's Included? 57 | 58 | 1. The Starter App - A native mobile KMP app with a small functional feature set. 59 | 2. Educational Resources - Introductory information on KMP and Kotlin/Native. 60 | 3. Integration Information - If you're integrating shared code into an existing application, guides to assist with that effort. 61 | 62 | ## What's *Not* Included? 63 | 64 | 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. 65 | 66 | # The Starter App 67 | 68 | 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. 69 | 70 | ## 1) Dev Environment and Build Setup 71 | 72 | You will need the following: 73 | 74 | - JVM 17 75 | - Android SDK and the latest stable Android Studio (2023.3+) or IntelliJ(2024.1+) 76 | - Mac with Xcode 15+ for the iOS build 77 | 78 | For a more detailed guide targeted at iOS developers, see [DETAILED_DEV_SETUP](docs/DETAILED_DEV_SETUP.md). 79 | 80 | ## 2) Clone and Build 81 | 82 | 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. 83 | 84 | --- 85 | 86 | ## Sanity Check 87 | 88 | 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. 89 | 90 | --- 91 | 92 | ## 3) Walk Through App 93 | 94 | Take a walk through the app's code and libraries. Make changes, recompile. See how it works. 95 | 96 | [GENERAL_ARCHITECTURE](docs/GENERAL_ARCHITECTURE.md) 97 | 98 | ## 4) Background Education 99 | 100 | If the app is building, it's a good time to take a break and get some background information. 101 | 102 | ### KMP Intro 103 | 104 | 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. 105 | 106 | [Longer intro to KaMP Kit](docs/WHAT_AND_WHY.md) - Original version of this doc's intro. Cut because it was pretty long. 107 | 108 | [Intro to Kotlin Multiplatform](https://vimeo.com/371428809) - General intro to KMP from Oredev in Nov 2019. Good overall summary of the platform. 109 | 110 | ### "Selling" KMP 111 | 112 | 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: 113 | 114 | [Kotlin Multiplatform Mobile for Teams](https://www.youtube.com/watch?v=-tJvCOfJesk&t=2145s) 115 | 116 | [Building a Business Case for KMP](https://touchlab.co/building-business-case-kotlin-multiplatform/) 117 | 118 | [7 ways to convince your engineering manager to pilot Kotlin Multiplatform](https://touchlab.co/7-ways-convince-engineering-manager-pilot-kotlin-multiplatform/) 119 | 120 | ### Xcode Debugging 121 | 122 | For information on how to debug Kotlin in Xcode, check out the [Debugging Kotlin In Xcode](docs/DEBUGGING_KOTLIN_IN_XCODE.md) doc. 123 | 124 | ## 5) Integrating 'shared' With Existing Apps 125 | 126 | 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. 127 | 128 | [Adopting Kotlin Multiplatform In Brownfield Applications](https://www.youtube.com/watch?v=rF-w_jL0qsI) 129 | 130 | ### Android 131 | 132 | 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. 133 | 134 | ### iOS 135 | 136 | 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. 137 | 138 | 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. 139 | 140 | See [IOS_PROJ_INTEGRATION.md](docs/IOS_PROJ_INTEGRATION.md) for iOS integration information. 141 | 142 | 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. 143 | 144 | --- 145 | 146 | ## Troubleshooting 147 | 148 | [TROUBLESHOOTING](docs/TROUBLESHOOTING.md) - We'll be growing this file over time, with your help. Please make sure 149 | to document any issues you run into and [let us know](CONTACT_US.md). 150 | 151 | ## More To Come! 152 | 153 | 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. 154 | 155 | --- 156 | 157 | ### About Touchlab 158 | 159 | 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. 160 | 161 | ### About The Kit 162 | 163 | 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. 164 | 165 | ### What We Can Do For You 166 | 167 | 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. 168 | 169 | > ## Subscribe! 170 | > 171 | > 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. 172 | [Sign up here](https://go.touchlab.co/newsletter)! 173 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /app/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.android.application) 3 | alias(libs.plugins.kotlin.android) 4 | alias(libs.plugins.compose.compiler) 5 | } 6 | 7 | android { 8 | namespace = "co.touchlab.kampkit.android" 9 | compileSdk = libs.versions.compileSdk.get().toInt() 10 | defaultConfig { 11 | applicationId = "co.touchlab.kampkit" 12 | minSdk = libs.versions.minSdk.get().toInt() 13 | targetSdk = libs.versions.targetSdk.get().toInt() 14 | versionCode = 1 15 | versionName = "1.0" 16 | } 17 | 18 | buildTypes { 19 | getByName("release") { 20 | isMinifyEnabled = false 21 | proguardFiles( 22 | getDefaultProguardFile("proguard-android-optimize.txt"), 23 | "proguard-rules.pro" 24 | ) 25 | } 26 | } 27 | compileOptions { 28 | isCoreLibraryDesugaringEnabled = true 29 | } 30 | lint { 31 | warningsAsErrors = false 32 | abortOnError = true 33 | } 34 | 35 | buildFeatures { 36 | compose = true 37 | buildConfig = true 38 | } 39 | } 40 | 41 | kotlin { 42 | jvmToolchain(11) 43 | } 44 | 45 | dependencies { 46 | implementation(projects.shared) 47 | implementation(libs.bundles.app.ui) 48 | implementation(libs.multiplatformSettings.common) 49 | implementation(libs.kotlinx.dateTime) 50 | coreLibraryDesugaring(libs.android.desugaring) 51 | implementation(libs.koin.android) 52 | } 53 | -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile 22 | -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 16 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /app/src/main/kotlin/co/touchlab/kampkit/android/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package co.touchlab.kampkit.android 2 | 3 | import android.os.Bundle 4 | import androidx.activity.ComponentActivity 5 | import androidx.activity.compose.setContent 6 | import co.touchlab.kampkit.android.ui.MainScreen 7 | import co.touchlab.kampkit.android.ui.theme.KaMPKitTheme 8 | import co.touchlab.kampkit.injectLogger 9 | import co.touchlab.kampkit.models.BreedViewModel 10 | import co.touchlab.kermit.Logger 11 | import org.koin.androidx.viewmodel.ext.android.viewModel 12 | import org.koin.core.component.KoinComponent 13 | 14 | class MainActivity : ComponentActivity(), KoinComponent { 15 | 16 | private val log: Logger by injectLogger("MainActivity") 17 | private val viewModel: BreedViewModel by viewModel() 18 | 19 | override fun onCreate(savedInstanceState: Bundle?) { 20 | super.onCreate(savedInstanceState) 21 | setContent { 22 | KaMPKitTheme { 23 | MainScreen(viewModel, log) 24 | } 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /app/src/main/kotlin/co/touchlab/kampkit/android/MainApp.kt: -------------------------------------------------------------------------------- 1 | package co.touchlab.kampkit.android 2 | 3 | import android.app.Application 4 | import android.content.Context 5 | import android.content.SharedPreferences 6 | import android.util.Log 7 | import co.touchlab.kampkit.AppInfo 8 | import co.touchlab.kampkit.initKoin 9 | import co.touchlab.kampkit.models.BreedViewModel 10 | import org.koin.core.module.dsl.viewModel 11 | import org.koin.core.parameter.parametersOf 12 | import org.koin.dsl.module 13 | 14 | class MainApp : Application() { 15 | 16 | override fun onCreate() { 17 | super.onCreate() 18 | initKoin( 19 | module { 20 | single { this@MainApp } 21 | viewModel { BreedViewModel(get(), get { parametersOf("BreedViewModel") }) } 22 | single { 23 | get().getSharedPreferences( 24 | "KAMPSTARTER_SETTINGS", 25 | Context.MODE_PRIVATE 26 | ) 27 | } 28 | single { AndroidAppInfo } 29 | single { 30 | { Log.i("Startup", "Hello from Android/Kotlin!") } 31 | } 32 | } 33 | ) 34 | } 35 | } 36 | 37 | object AndroidAppInfo : AppInfo { 38 | override val appId: String = BuildConfig.APPLICATION_ID 39 | } 40 | -------------------------------------------------------------------------------- /app/src/main/kotlin/co/touchlab/kampkit/android/ui/Composables.kt: -------------------------------------------------------------------------------- 1 | package co.touchlab.kampkit.android.ui 2 | 3 | import androidx.compose.animation.Crossfade 4 | import androidx.compose.animation.core.FastOutSlowInEasing 5 | import androidx.compose.animation.core.TweenSpec 6 | import androidx.compose.foundation.Image 7 | import androidx.compose.foundation.clickable 8 | import androidx.compose.foundation.layout.Arrangement 9 | import androidx.compose.foundation.layout.Box 10 | import androidx.compose.foundation.layout.Column 11 | import androidx.compose.foundation.layout.Row 12 | import androidx.compose.foundation.layout.fillMaxSize 13 | import androidx.compose.foundation.layout.padding 14 | import androidx.compose.foundation.lazy.LazyColumn 15 | import androidx.compose.foundation.lazy.items 16 | import androidx.compose.foundation.rememberScrollState 17 | import androidx.compose.foundation.verticalScroll 18 | import androidx.compose.material.Divider 19 | import androidx.compose.material.ExperimentalMaterialApi 20 | import androidx.compose.material.MaterialTheme 21 | import androidx.compose.material.Surface 22 | import androidx.compose.material.Text 23 | import androidx.compose.material.pullrefresh.PullRefreshIndicator 24 | import androidx.compose.material.pullrefresh.pullRefresh 25 | import androidx.compose.material.pullrefresh.rememberPullRefreshState 26 | import androidx.compose.runtime.Composable 27 | import androidx.compose.runtime.LaunchedEffect 28 | import androidx.compose.runtime.getValue 29 | import androidx.compose.runtime.rememberCoroutineScope 30 | import androidx.compose.ui.Alignment 31 | import androidx.compose.ui.Modifier 32 | import androidx.compose.ui.res.painterResource 33 | import androidx.compose.ui.res.stringResource 34 | import androidx.compose.ui.tooling.preview.Preview 35 | import androidx.compose.ui.unit.dp 36 | import androidx.lifecycle.compose.collectAsStateWithLifecycle 37 | import co.touchlab.kampkit.android.R 38 | import co.touchlab.kampkit.db.Breed 39 | import co.touchlab.kampkit.models.BreedViewModel 40 | import co.touchlab.kampkit.models.BreedViewState 41 | import co.touchlab.kermit.Logger 42 | import kotlinx.coroutines.launch 43 | 44 | @Composable 45 | fun MainScreen(viewModel: BreedViewModel, log: Logger) { 46 | val dogsState by viewModel.breedState.collectAsStateWithLifecycle() 47 | val scope = rememberCoroutineScope() 48 | 49 | LaunchedEffect(viewModel) { 50 | viewModel.activate() 51 | } 52 | 53 | MainScreenContent( 54 | dogsState = dogsState, 55 | onRefresh = { scope.launch { viewModel.refreshBreeds() } }, 56 | onSuccess = { data -> log.v { "View updating with ${data.size} breeds" } }, 57 | onError = { exception -> log.e { "Displaying error: $exception" } }, 58 | onFavorite = { scope.launch { viewModel.updateBreedFavorite(it) } } 59 | ) 60 | } 61 | 62 | @OptIn(ExperimentalMaterialApi::class) 63 | @Composable 64 | fun MainScreenContent( 65 | dogsState: BreedViewState, 66 | onRefresh: () -> Unit = {}, 67 | onSuccess: (List) -> Unit = {}, 68 | onError: (String) -> Unit = {}, 69 | onFavorite: (Breed) -> Unit = {} 70 | ) { 71 | Surface( 72 | color = MaterialTheme.colors.background, 73 | modifier = Modifier.fillMaxSize() 74 | ) { 75 | val refreshState = rememberPullRefreshState(dogsState.isLoading, onRefresh) 76 | 77 | Box(Modifier.pullRefresh(refreshState)) { 78 | when (dogsState) { 79 | is BreedViewState.Empty -> Empty() 80 | is BreedViewState.Content -> { 81 | val breeds = dogsState.breeds 82 | onSuccess(breeds) 83 | Success(successData = breeds, favoriteBreed = onFavorite) 84 | } 85 | 86 | is BreedViewState.Error -> { 87 | val error = dogsState.error 88 | onError(error) 89 | Error(error) 90 | } 91 | 92 | BreedViewState.Initial -> { 93 | // no-op (just show spinner until first data is loaded) 94 | } 95 | } 96 | 97 | PullRefreshIndicator( 98 | dogsState.isLoading, 99 | refreshState, 100 | Modifier.align(Alignment.TopCenter) 101 | ) 102 | } 103 | } 104 | } 105 | 106 | @Composable 107 | fun Empty() { 108 | Column( 109 | modifier = Modifier 110 | .fillMaxSize() 111 | .verticalScroll(rememberScrollState()), 112 | verticalArrangement = Arrangement.Center, 113 | horizontalAlignment = Alignment.CenterHorizontally 114 | ) { 115 | Text(stringResource(R.string.empty_breeds)) 116 | } 117 | } 118 | 119 | @Composable 120 | fun Error(error: String) { 121 | Column( 122 | modifier = Modifier 123 | .fillMaxSize() 124 | .verticalScroll(rememberScrollState()), 125 | verticalArrangement = Arrangement.Center, 126 | horizontalAlignment = Alignment.CenterHorizontally 127 | ) { 128 | Text(text = error) 129 | } 130 | } 131 | 132 | @Composable 133 | fun Success(successData: List, favoriteBreed: (Breed) -> Unit) { 134 | DogList(breeds = successData, favoriteBreed) 135 | } 136 | 137 | @Composable 138 | fun DogList(breeds: List, onItemClick: (Breed) -> Unit) { 139 | LazyColumn { 140 | items(breeds) { breed -> 141 | DogRow(breed) { 142 | onItemClick(it) 143 | } 144 | Divider() 145 | } 146 | } 147 | } 148 | 149 | @Composable 150 | fun DogRow(breed: Breed, onClick: (Breed) -> Unit) { 151 | Row( 152 | Modifier 153 | .clickable { onClick(breed) } 154 | .padding(10.dp) 155 | ) { 156 | Text(breed.name, Modifier.weight(1F)) 157 | FavoriteIcon(breed) 158 | } 159 | } 160 | 161 | @Composable 162 | fun FavoriteIcon(breed: Breed) { 163 | Crossfade( 164 | targetState = !breed.favorite, 165 | animationSpec = TweenSpec( 166 | durationMillis = 500, 167 | easing = FastOutSlowInEasing 168 | ), 169 | label = "CrossFadeFavoriteIcon" 170 | ) { fav -> 171 | if (fav) { 172 | Image( 173 | painter = painterResource(id = R.drawable.ic_favorite_border_24px), 174 | contentDescription = stringResource(R.string.favorite_breed, breed.name) 175 | ) 176 | } else { 177 | Image( 178 | painter = painterResource(id = R.drawable.ic_favorite_24px), 179 | contentDescription = stringResource(R.string.unfavorite_breed, breed.name) 180 | ) 181 | } 182 | } 183 | } 184 | 185 | @Preview 186 | @Composable 187 | fun MainScreenContentPreview_Success() { 188 | MainScreenContent( 189 | dogsState = BreedViewState.Content( 190 | breeds = listOf( 191 | Breed(0, "appenzeller", false), 192 | Breed(1, "australian", true) 193 | ) 194 | ) 195 | ) 196 | } 197 | 198 | @Preview 199 | @Composable 200 | fun MainScreenContentPreview_Initial() { 201 | MainScreenContent(dogsState = BreedViewState.Initial) 202 | } 203 | 204 | @Preview 205 | @Composable 206 | fun MainScreenContentPreview_Empty() { 207 | MainScreenContent(dogsState = BreedViewState.Empty()) 208 | } 209 | 210 | @Preview 211 | @Composable 212 | fun MainScreenContentPreview_Error() { 213 | MainScreenContent(dogsState = BreedViewState.Error("Something went wrong!")) 214 | } 215 | -------------------------------------------------------------------------------- /app/src/main/kotlin/co/touchlab/kampkit/android/ui/theme/Color.kt: -------------------------------------------------------------------------------- 1 | package co.touchlab.kampkit.android.ui.theme 2 | 3 | import androidx.compose.ui.graphics.Color 4 | 5 | val Purple200 = Color(0xFFBB86FC) 6 | val Purple500 = Color(0xFF6200EE) 7 | val Purple700 = Color(0xFF3700B3) 8 | val Teal200 = Color(0xFF03DAC5) 9 | -------------------------------------------------------------------------------- /app/src/main/kotlin/co/touchlab/kampkit/android/ui/theme/Shapes.kt: -------------------------------------------------------------------------------- 1 | package co.touchlab.kampkit.android.ui.theme 2 | 3 | import androidx.compose.foundation.shape.RoundedCornerShape 4 | import androidx.compose.material.Shapes 5 | import androidx.compose.ui.unit.dp 6 | 7 | val Shapes = Shapes( 8 | small = RoundedCornerShape(4.dp), 9 | medium = RoundedCornerShape(4.dp), 10 | large = RoundedCornerShape(0.dp) 11 | ) 12 | -------------------------------------------------------------------------------- /app/src/main/kotlin/co/touchlab/kampkit/android/ui/theme/Theme.kt: -------------------------------------------------------------------------------- 1 | package co.touchlab.kampkit.android.ui.theme 2 | 3 | import androidx.compose.foundation.isSystemInDarkTheme 4 | import androidx.compose.material.MaterialTheme 5 | import androidx.compose.material.darkColors 6 | import androidx.compose.material.lightColors 7 | import androidx.compose.runtime.Composable 8 | 9 | private val DarkColorPalette = darkColors( 10 | primary = Purple200, 11 | primaryVariant = Purple700, 12 | secondary = Teal200 13 | ) 14 | 15 | private val LightColorPalette = lightColors( 16 | primary = Purple500, 17 | primaryVariant = Purple700, 18 | secondary = Teal200 19 | 20 | // Other default colors to override 21 | // 22 | // background = Color.White, 23 | // surface = Color.White, 24 | // onPrimary = Color.White, 25 | // onSecondary = Color.Black, 26 | // onBackground = Color.Black, 27 | // onSurface = Color.Black, 28 | ) 29 | 30 | @Composable 31 | fun KaMPKitTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) { 32 | val colors = if (darkTheme) { 33 | DarkColorPalette 34 | } else { 35 | LightColorPalette 36 | } 37 | 38 | MaterialTheme( 39 | colors = colors, 40 | typography = Typography, 41 | shapes = Shapes, 42 | content = content 43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /app/src/main/kotlin/co/touchlab/kampkit/android/ui/theme/Typography.kt: -------------------------------------------------------------------------------- 1 | package co.touchlab.kampkit.android.ui.theme 2 | 3 | import androidx.compose.material.Typography 4 | import androidx.compose.ui.text.TextStyle 5 | import androidx.compose.ui.text.font.FontFamily 6 | import androidx.compose.ui.text.font.FontWeight 7 | import androidx.compose.ui.unit.sp 8 | 9 | // Set of Material typography styles to start with 10 | val Typography = Typography( 11 | body1 = TextStyle( 12 | fontFamily = FontFamily.Default, 13 | fontWeight = FontWeight.Normal, 14 | fontSize = 16.sp 15 | ) 16 | // Other default text styles to override 17 | // 18 | // button = TextStyle( 19 | // fontFamily = FontFamily.Default, 20 | // fontWeight = FontWeight.W500, 21 | // fontSize = 14.sp 22 | // ), 23 | // 24 | // caption = TextStyle( 25 | // fontFamily = FontFamily.Default, 26 | // fontWeight = FontWeight.Normal, 27 | // fontSize = 12.sp 28 | // ) 29 | ) 30 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 12 | 13 | 19 | 22 | 25 | 26 | 27 | 28 | 34 | 35 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_favorite_24px.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_favorite_border_24px.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 15 | 20 | 25 | 30 | 35 | 40 | 45 | 50 | 55 | 60 | 65 | 70 | 75 | 80 | 85 | 90 | 95 | 100 | 105 | 110 | 115 | 120 | 125 | 130 | 135 | 140 | 145 | 150 | 155 | 160 | 165 | 170 | 171 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/touchlab/KaMPKit/4e430ed74e4ecf38ea036dbccaccc3442c13df47/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/touchlab/KaMPKit/4e430ed74e4ecf38ea036dbccaccc3442c13df47/app/src/main/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/touchlab/KaMPKit/4e430ed74e4ecf38ea036dbccaccc3442c13df47/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/touchlab/KaMPKit/4e430ed74e4ecf38ea036dbccaccc3442c13df47/app/src/main/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/touchlab/KaMPKit/4e430ed74e4ecf38ea036dbccaccc3442c13df47/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/touchlab/KaMPKit/4e430ed74e4ecf38ea036dbccaccc3442c13df47/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/touchlab/KaMPKit/4e430ed74e4ecf38ea036dbccaccc3442c13df47/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/touchlab/KaMPKit/4e430ed74e4ecf38ea036dbccaccc3442c13df47/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/touchlab/KaMPKit/4e430ed74e4ecf38ea036dbccaccc3442c13df47/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/touchlab/KaMPKit/4e430ed74e4ecf38ea036dbccaccc3442c13df47/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #008577 4 | #00574B 5 | #D81B60 6 | 7 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | KaMP Kit 3 | Favorite %1$s 4 | Unfavorite %1$s 5 | Sorry, no doggos found 6 | 7 | -------------------------------------------------------------------------------- /app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.ktlint) apply false 3 | alias(libs.plugins.kotlin.android) apply false 4 | alias(libs.plugins.kotlin.multiplatform) apply false 5 | alias(libs.plugins.sqlDelight) apply false 6 | alias(libs.plugins.android.library) apply false 7 | alias(libs.plugins.android.application) apply false 8 | alias(libs.plugins.kotlin.serialization) apply false 9 | alias(libs.plugins.skie) apply false 10 | alias(libs.plugins.compose.compiler) apply false 11 | } 12 | 13 | subprojects { 14 | apply(plugin = rootProject.libs.plugins.ktlint.get().pluginId) 15 | 16 | configure { 17 | verbose.set(true) 18 | filter { 19 | exclude { it.file.path.contains("build/") } 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /docs/APP_BUILD.md: -------------------------------------------------------------------------------- 1 | # Sample App Build 2 | 3 | ## Prerequisites 4 | Before you build the app you will require these items: 5 | 6 | * JVM 17 7 | - Android SDK and the latest stable Android Studio (2023.1+) or IntelliJ(2023.3+) 8 | - Mac with Xcode 14+ for the iOS build 9 | 10 | For more details, check out the [DETAILED_DEV_SETUP](DETAILED_DEV_SETUP.md) document. 11 | 12 | ### 1) Clone the app 13 | Run the following on the command line 14 | ``` 15 | git clone https://github.com/touchlab/KaMPKit.git 16 | ``` 17 | 18 | ### 2) Build Android 19 | 1. Open the project in Android Studio/IntelliJ and wait for indexing to finish. 20 | 2. Make sure you see the run config for the Android app 21 | ![](runconfig.png) 22 | 3. Run the Android app on either the Emulator or a phone. If the app builds correctly, you should see this: 23 | 24 | ![](Screenshots/kampScreenshotAndroid.png) 25 | 26 | ### 3) Build iOS 27 | 28 | 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. 29 | 1. Open a Terminal window or use the one at the bottom of Android Studio/IntelliJ. 30 | 2. Navigate to the project's root directory (`KaMPKit/` - not `KaMPKit/ios/` - which is iOS project's root directory). 31 | 3. Run the command `./gradlew build` which will build the shared library. 32 | 2. Open Xcode **workspace** project in the `ios/` folder: `KaMPKitiOS.xcworkspace`. 33 | 3. Run the iOS app on either the Simulator or a phone. If the app builds correctly, you should see this: 34 | 35 | ![](Screenshots/kampScreenshotiOS.png) 36 | 37 | ## Did that work? 38 | 39 | Congratulations! You have a functional sample app to start working from. Head back to the [README.md](../README.md#Sanity-Check) for next steps. 40 | 41 | ### Common Issues 42 | 43 | See [TROUBLESHOOTING](TROUBLESHOOTING.md) 44 | 45 | ### CI Hosts 46 | 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: 47 | 48 | ```yaml 49 | jobs: 50 | build: 51 | runs-on: macos-latest 52 | ``` 53 | 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. 54 | 55 | ```yaml 56 | pull_request: 57 | paths-ignore: 58 | - "**.md" 59 | - "*.png" 60 | - docs 61 | - app 62 | ``` 63 | 64 | ### Contact 65 | 66 | 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. 67 | -------------------------------------------------------------------------------- /docs/DEBUGGING_KOTLIN_IN_XCODE.md: -------------------------------------------------------------------------------- 1 | # Debugging Kotlin in Xcode 2 | 3 | > Note that if there is a [known issue](https://github.com/touchlab/xcode-kotlin/issues/95) with `xcode-kotlin` plugin on Xcode 15 4 | 5 | By this point you should be able to build and run KaMP Kit in iOS using Xcode. Great! Maybe you've 6 | changed a variable and want to see if it actually updated successfully, but how do you do that? Well 7 | we at Touchlab have actually created a way to **debug kotlin code in Xcode**. 8 | 9 | ### Kotlin Native Xcode Plugin 10 | 11 | The [Kotlin Native Xcode Plugin](https://github.com/touchlab/xcode-kotlin) adds basic highlighting, 12 | allows you to set breakpoints and includes llvm support to view data in the debug window. You can 13 | find the steps to install this plugin on its readMe. Newly a CLI (command line interface) was added - 14 | it is an executable that is installed on your machine and manages the plugin installation(s). It allows: 15 | 16 | - Homebrew installation 17 | - Better Xcode integration (No more "Load Bundle" popups!) 18 | - Easier management of multiple Xcode installations 19 | - Automatic "sync". When Xcode updates, we need to update the plugin config. This previously 20 | required updating the xcode-kotlin project GitHub repo, pulling, and reinstalling. The CLI can do 21 | this locally. 22 | - Better diagnostic info and support for install issues. 23 | 24 | ### Kotlin Source in Xcode 25 | 26 | To take advantage of the plugin you will want to add references to your kotlin code in Xcode. This 27 | will allow you to add breakpoints and edit kotlin without switching to Android Studio. You probably 28 | won't want to do your primary kotlin coding like this, but it's helpful when debugging. 29 | 30 | To add the Kotlin source: 31 | 1. Right click in the project explorer 32 | 33 | ![](Screenshots/AddFiles.png) 34 | 35 | 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" 36 | 37 | ![](Screenshots/FolderRef.png) 38 | -------------------------------------------------------------------------------- /docs/DETAILED_DEV_SETUP.md: -------------------------------------------------------------------------------- 1 | # KMP Development Environment Setup 2 | 3 | Not assuming anything if you're an iOS developer. You may not have the Android/JVM setup necessary to run everything. 4 | 5 | 6 | ## Install JDK 7 | 8 | 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 9 | [Amazon Corretto](https://docs.aws.amazon.com/corretto/latest/corretto-17-ug/macos-install.html). Download the pkg 10 | installer and go through the setup instructions. 11 | 12 | Some alternative options, if desired: 13 | 14 | - [SDKMan](https://sdkman.io/) - JDK version manager and installer. 15 | - [AdoptOpenJDK](https://adoptopenjdk.net/) - Alternate JDK distribution. 16 | 17 | ## Install the IDE(s) 18 | 19 | You'll also need either Android Studio, IntelliJ, or both. Android Studio is an Android development 20 | focused skin of IntelliJ, which is more platform agnostic. There is a built-in KMP plugin in the 21 | Android Studio, which enables you to run and debug the iOS part of your application on iOS targets 22 | straight from Android Studio. IntelliJ IDEA has a newer Kotlin API platform and gets bugfixes 23 | sooner, but it has an older version of Android Gradle Plugin. If you don't have either, we recommend 24 | installing both through 25 | the [Jetbrains Toolbox](https://www.jetbrains.com/toolbox-app/download/download-thanks.html). 26 | 27 | If you just want one or the other, you can use the following links: 28 | 29 | - [Android Studio docs installation guide](https://developer.android.com/studio/install) (includes download link) 30 | - [IntelliJ download link](https://www.jetbrains.com/idea/download/#section=mac) (select the Community version) 31 | - [IntelliJ setup guide](https://www.jetbrains.com/help/idea/run-for-the-first-time.html) 32 | 33 | You can use [KDoctor](https://github.com/Kotlin/kdoctor) to help you set-up your environment for 34 | Kotlin Multiplatform app development. It ensures that all required components are properly 35 | installed and ready for use. If something is missed or not configured KDoctor highlights the problem 36 | and suggests how to fix the problem. 37 | 38 | ## Open IDE 39 | 40 | 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. 41 | 42 | 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). 43 | 44 | 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_). 45 | 46 | 47 | ## Install an Android Emulator 48 | 49 | 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. 50 | 51 | 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...**. 52 | 53 | 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**. 54 | 55 | ## Next Steps 56 | 57 | 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. 58 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /docs/GENERAL_ARCHITECTURE.md: -------------------------------------------------------------------------------- 1 | # Architecture Overview 2 | 3 | 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. 4 | 5 | * [Structure of the Project](#Structure-of-the-Project) 6 | * [Overall Architecture](#Overall-Architecture) 7 | * [Kotlinx Coroutines](#kotlinx-Coroutines) 8 | * [Libraries and Dependencies](#Libraries-and-Dependencies) 9 | * [SKIE](#SKIE) - Swift-friendly API generator 10 | * [Kermit](#Kermit) - Logging 11 | * [SqlDelight](#SqlDelight) - Database 12 | * [Ktor](#Ktor) - Networking 13 | * [Multiplatform Settings](#Multiplatform-Settings) - Settings 14 | * [Koin](#Koin) - Dependency Injection 15 | * [Testing](#Testing) 16 | 17 | ## Structure of the Project 18 | 19 | KaMP Kit is organized into three main directories: 20 | * shared 21 | * app 22 | * ios 23 | 24 | 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 25 | 26 | 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. 27 | 28 | 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: 29 | 30 | * androidMain 31 | * iosMain 32 | * commonMain 33 | * androidUnitTest 34 | * iosTest 35 | * commonTest 36 | 37 | Each of these directories maintains a consistent structure: the programming language followed by the package name (e.g., *"kotlin/co/touchlab/kampkit/"*). 38 | 39 | ## Overall Architecture 40 | 41 | #### Platform 42 | 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. 43 | 44 | #### ViewModel 45 | 46 | From the platforms views we then have the ViewModel layer that bridges our shared data with the views. 47 | 48 | If you want your shared viewmodel to be an `androidx.lifecycle.ViewModel` 49 | on the Android side, you can take either a composition or inheritence approach. 50 | 51 | For this project we chose the inheritence approach, because Android can use the 52 | common viewmodel directly. To enable sharing of presentation logic between platforms, we 53 | define `expect abstract class ViewModel` in `commonMain`, with platform specific implementations 54 | provided in `androidMain` and `iosMain`. The android implementation simply extends the Jetpack 55 | ViewModel, while an equivalent is implemented for iOS. 56 | 57 | `ViewModel` sharing used to a bit more convoluted but now with Touchlab's [Skie](#Skie) tool, iOS code can reference the common `BreedViewModel` directly. 58 | 59 | #### Repository 60 | 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`. 61 | 62 | > Note that the BreedRepository references the interface DogApi. This is so we can test the Model using a Mock Api 63 | 64 | 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. 65 | 66 | In Short: 67 | **Platform -> BreedViewModel -> BreedRepository -> DogApiImpl -> BreedModel -> DatabaseHelper -> BreedRepository -> BreedViewModel -> Platform** 68 | 69 | 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. 70 | 71 | ## Kotlinx Coroutines 72 | 73 | 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) 74 | and [our Blogpost](https://touchlab.co/testing-the-kotlin-native-memory-model/). 75 | 76 | Explore the implementations in [DogApiImpl.kt](https://github.com/touchlab/KaMPKit/blob/5376b4c2dd4be7f2436e10dddbf56b0d5ab33443/shared/src/commonMain/kotlin/co/touchlab/kampkit/ktor/DogApiImpl.kt#L36) 77 | and [BreedModel.kt](https://github.com/touchlab/KaMPKit/blob/b2e8a330f8c12429255711c4c55a328885615d8b/shared/src/commonMain/kotlin/co/touchlab/kampkit/models/BreedModel.kt#L49) 78 | 79 | ## Libraries and Dependencies 80 | 81 | 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. 82 | 83 | 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`. 84 | 85 | Below is some information about some of the libraries used in the project. 86 | 87 | * [SKIE](#SKIE) 88 | * [Kermit](#Kermit) 89 | * [SqlDelight](#SqlDelight) 90 | * [Ktor](#Ktor) 91 | * [Multiplatform Settings](#Multiplatform-Settings) 92 | * [Koin](#Koin) 93 | * [Turbine](#Turbine) 94 | 95 | ### SKIE 96 | 97 | Documentation: https://skie.touchlab.co/intro 98 | 99 | 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. 100 | 101 | 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. 102 | 103 | ### Kermit 104 | 105 | Documentation: https://kermit.touchlab.co/ 106 | 107 | 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. 108 | 109 | ### SqlDelight 110 | Documentation: [https://github.com/cashapp/sqldelight](https://github.com/cashapp/sqldelight) 111 | 112 | Usage in the project: *commonMain/kotlin/co/touchlab/kampkit/DatabaseHelper.kt* 113 | 114 | SQL Location in the project: *commonMain/sqldelight/co/touchlab/kampkit/Table.sq* 115 | 116 | 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. 117 | ex: *"commonMain/sqldelight/co/touchlab/kampkit/Table.sq"* 118 | 119 | 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). 120 | 121 | ##### Flow 122 | 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. 123 | 124 | ### Ktor 125 | Documentation: https://ktor.io/ 126 | 127 | Usage in the project: *commonMain/kotlin/co/touchlab/kampkit/ktor/DogApiImpl.kt* 128 | 129 | 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. 130 | 131 | ### Multiplatform Settings 132 | Documentation: https://github.com/russhwolf/multiplatform-settings 133 | 134 | Usage in the project: *commonMain/kotlin/co/touchlab/kampkit/models/BreedModel.kt* 135 | 136 | 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. 137 | 138 | ### Koin 139 | Documentation: https://insert-koin.io/ 140 | 141 | Usage in the project: *commonMain/kotlin/co/touchlab/kampkit/Koin.kt* 142 | 143 | Koin is a lightweight dependency injection framework. It is being used in the *koin.kt* file to inject modules into the BreedModel. 144 | 145 | 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`). 146 | 147 | ## Testing 148 | 149 | 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. 150 | 151 | The shared tests can be found in the `commonTest` 152 | directory, while the implementations can be found in the `androidTest` and `iosTest` directories. 153 | 154 | 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. 155 | 156 | For running tests we use `kotlinx.coroutines.test.runTest`. For specifying a test 157 | runner we use `@RunWith()` annotation. Platform-specific implementations of `testDbConnection()` are stored in *TestUtilAndroid.kt* and *TestUtilIOS.kt*. 158 | 159 | ### Turbine 160 | Check out the [Repository](https://github.com/cashapp/turbine) for more info. 161 | 162 | Turbine is a small testing library for `kotlinx.coroutines Flow`. 163 | A practical example can be found in `BreedViewModelTest.kt`. 164 | 165 | ### Android 166 | 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'`. 167 | 168 | ### iOS 169 | 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`. 170 | -------------------------------------------------------------------------------- /docs/IOS_PROJ_INTEGRATION.md: -------------------------------------------------------------------------------- 1 | # Integrating with Existing iOS Projects 2 | 3 | There are two primary ways to add a KMP library to your existing iOS project: with or without 4 | Cocoapods. Cocoapods is the much simpler method of adding your library. By generating a file in 5 | gradle you can easily insert your library into your iOS project without worrying about build phases 6 | or targets. It's simple and ease-of-use, and we recommend that you use Cocoapods. 7 | 8 | If you don't want to use Cocoapods to add a KMP library to your iOS project, then you can follow the 9 | steps 10 | in [this guide](https://play.kotlinlang.org/hands-on/Targeting%20iOS%20and%20Android%20with%20Kotlin%20Multiplatform/01_Introduction) 11 | from Jetbrains about how to add the library to your iOS project manually. 12 | 13 | If you don't have Cocoapods installed, then follow the instructions in 14 | their [official installation guide](https://guides.cocoapods.org/using/getting-started.html). 15 | 16 | ## Cocoapods Overview 17 | 18 | Explaining all of Cocoapods is not within the scope of this document, however a basic introduction 19 | could be helpful in understanding how to integrate Kotlin Native into your iOS Project. In short, 20 | Cocoapods is a dependency manager which uses a `Podfile` to reference a list of dependencies, 21 | or `pods`, that are to be injected. Each `pod` has a reference spec document, or a `podspec`, which 22 | details the pods name, version, source, and other information. By using Cocoapods, we can reference 23 | our shared library and have it directly injected into the iOS Project. 24 | 25 | ## Cocoapods gradle Integration 26 | 27 | Starting with 1.3.30, Kotlin has provided a gradle plugin which allows the Kotlin Native library to 28 | be referenced as a Cocoapods dependency. The integration adds a 29 | gradle task that generates a `podspec` that includes everything needed to be referenced by 30 | Cocoapods. Our podspec is located in the `shared/build.gradle`. 31 | 32 | ``` 33 | cocoapods { 34 | summary = "Common library for the KaMP starter kit" 35 | homepage = "https://github.com/touchlab/KaMPKit" 36 | framework { 37 | isStatic = false 38 | linkerOpts("-lsqlite3") 39 | export(libs.touchlab.kermit.simple) 40 | } 41 | extraSpecAttributes["swift_version"] = "\"5.0\"" // <- SKIE Needs this! 42 | podfile = project.file("../ios/Podfile") 43 | } 44 | ``` 45 | Note that you need to apply the `native.cocoapods` plugin. 46 | 47 | The `framework` block is used to configure the framework generated by Cocoapods. In this case we 48 | use `isStatic = false` to build a dynamic framework (Debugging has issues in static frameworks, for 49 | example the previews don't work). On the other hand, we encountered problems with dynamic frameworks 50 | on Arm64 based simulators, so use `isStatic = true` if you need to use a Arm64 simulator. The export 51 | settings allow configuring and logging with Kermit in swift. Normally dependencies of your shared 52 | module aren't included in the export. 53 | 54 | To generate the podspec, run the `podspec` command, or `./gradlew podspec`. This will generate the 55 | podspec in the root library folder. 56 | 57 | For more detailed information about the 58 | integration, [see more here](https://kotlinlang.org/docs/reference/native/cocoapods.html) 59 | 60 | ## Create Podfile 61 | 62 | If your iOS project doesn't have a `Podfile` yet, you'll need one. If your project is already using 63 | Cocoapods, then skip ahead to the next section. 64 | 65 | In the command line, run `touch Podfile` in your iOS project's root directory. Then paste the 66 | following into your new `Podfile`: 67 | 68 | ``` 69 | use_frameworks! 70 | 71 | platform :ios, '15.0' 72 | 73 | install! 'cocoapods', :deterministic_uuids => false 74 | 75 | target 'YourIosAppTargetName' do 76 | // Pods go here 77 | end 78 | ``` 79 | 80 | Now, replace `YourIosAppTargetName` with, you guessed it, your iOS app's target name. In the KaMP Kit iOS sample 81 | app, that would be `KaMPKitiOS`. 82 | 83 | 84 | ## Add KMP Pod 85 | 86 | Add the following line in your `target` block (replace `// Pods go here` in our example above): 87 | 88 | ``` 89 | pod 'shared', :path => '~/[PATH_TO_KaMPKit/shared/]' 90 | ``` 91 | 92 | Next, replace `~/[PATH_TO_KaMPKit/shared/]` with the path to your `KaMPKit/shared/` directory. For example: 93 | ``` 94 | pod 'shared', :path => '~/Desktop/KaMPKit/shared/' 95 | ``` 96 | This path can be either absolute or relative, but we realize that your KaMP Kit project and your existing iOS 97 | project might be in very different places, so we're using an absolute path as an example for simplicity's sake. 98 | 99 | 100 | ## Install and Run 101 | 102 | Save the changes to your `Podfile`. Go back to the command line, and in your iOS project's root directory, run `pod 103 | install`. 104 | 105 | This command will create a `Pods/` folder and a `.xcworkspace` file in your iOS project's root directory. Open the 106 | `.xcworkspace` file. Remember that if your project was already using Cocoapods, and you had your `.xcworkspace 107 | ` file open in Xcode, you need to close and reopen it. 108 | 109 | From now on, you will work out of the `.xcworkspace` file instead of the `.xcodeproj` file (which is part of 110 | your `.xcworkspace`). To use code from your `shared` KMP library, at the top of the `.swift` file where you 111 | want to use it, add: 112 | 113 | ``` 114 | import shared 115 | ``` 116 | -------------------------------------------------------------------------------- /docs/Screenshots/AddFiles.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/touchlab/KaMPKit/4e430ed74e4ecf38ea036dbccaccc3442c13df47/docs/Screenshots/AddFiles.png -------------------------------------------------------------------------------- /docs/Screenshots/FolderRef.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/touchlab/KaMPKit/4e430ed74e4ecf38ea036dbccaccc3442c13df47/docs/Screenshots/FolderRef.png -------------------------------------------------------------------------------- /docs/Screenshots/kampScreenshotAndroid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/touchlab/KaMPKit/4e430ed74e4ecf38ea036dbccaccc3442c13df47/docs/Screenshots/kampScreenshotAndroid.png -------------------------------------------------------------------------------- /docs/Screenshots/kampScreenshotiOS.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/touchlab/KaMPKit/4e430ed74e4ecf38ea036dbccaccc3442c13df47/docs/Screenshots/kampScreenshotiOS.png -------------------------------------------------------------------------------- /docs/TROUBLESHOOTING.md: -------------------------------------------------------------------------------- 1 | # Troubleshooting 2 | 3 | **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... " 4 | 5 | **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. 6 | 7 | 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: 8 | 9 | ``` 10 | sdk.dir=/Users/[YOUR_USER_NAME]/Library/Android/sdk 11 | ``` 12 | 13 | >**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. 14 | 15 | 16 | **Q:** When I tried to run the project in Xcode, why did I get the following error? "Framework not found shared_umbrella". 17 | 18 | **A:** You probably opened the `.xcodeproj` file in Xcode instead of the `.xcworkspace`. Close out the `.xcodeproj` and open the `.xcworkspace` and run again. 19 | 20 | To learn more about Cocoapods and how to use them, check out [their official guide](https://guides.cocoapods.org/using/index.html). 21 | 22 | 23 | **Q:** The Xcode project won't compile. On the `import shared` line in Swift, I'm getting a compilation error "no 24 | such module: 'shared'". 25 | 26 | **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. 27 | 28 | > Note: We're still not quite sure as to the cause of this error. Possible factors include differing versions of Cocoapods or Xcode. 29 | 30 | **Q:** My iOS framework binary size is bigger after adding Kotlin/Native code. 31 | 32 | **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. 33 | ## More to Come! 34 | 35 | [Let us know](../CONTACT_US.md) what issues you run into. 36 | -------------------------------------------------------------------------------- /docs/WHAT_AND_WHY.md: -------------------------------------------------------------------------------- 1 | # KMP: What and Why? 2 | 3 | ## 2023 Update 4 | 5 | This document describes the original vision and goals of KaMP Kit. Many of these ideas have evolved since then, but this 6 | writeup is still here so you can see where it all came from. 7 | 8 | ## What is KMP (Kotlin Multiplatform)? 9 | 10 | Kotlin is generally seen as a replacement JVM language, and by many as an “Android thing”. However, JetBrains and the 11 | Kotlin team have much bigger goals in mind. The ultimate goal of “Kotlin” is a portable platform suitable for any 12 | project. 13 | You can transfer your skills and code to any task at hand. 14 | 15 | > To see more about Kotlin's Multiplatform vision, watch the [Kotlinconf Keynote](https://youtu.be/0xKTM0A8gdI) 16 | 17 | Kotlin can output JVM bytecode, Javascript, and an array of LLVM-based native executable code. Describing the entirety 18 | of KMP would take some time, but the KaMP Kit is focused on native mobile development, so we’ll speak to that 19 | specifiically. 20 | 21 | KMP enables optional shared architecture and logic, that can be used in both Android and iOS. Kotlin is already the 22 | default 23 | language for Android, which means unlike all other “cross platform” options, it is fully “native” to the platform (and, 24 | really, any JVM environment). 25 | 26 | On iOS, the Kotlin Native compiler generates an Xcode Framework that you can include into Xcode and call from Swift or 27 | Objective-C. Using [a 3rd party plugin](https://github.com/touchlab/xcode-kotlin) (*cough* by Touchlab *cough*) you can 28 | debug Kotlin directly in Xcode. iOS developers (soon to be “mobile developers”) can stick to the tools they currently 29 | use while learning Kotlin. 30 | Integrating Kotlin is not an abrupt and dramatic (ie RISKY) change to your team’s development process. 31 | 32 | ## What is this Kit? 33 | 34 | KMP is new tech, supporting many features and platforms, and has had rapid development over the past 3 years. As a 35 | result, the documentation ecosystem right now can be difficult to navigate. The official Jetbrains docs cover a wide 36 | range of options but can be difficult to navigate. That situation is being addressed, at the same time that the platform 37 | itself is stabilizing. The documentation out on the web is often more focused on mobile specifically, but is all over 38 | the place. Most of it is bad, frankly. Usually because it is outdated. 39 | 40 | We have talked to many teams that have evaluated KMP. Like other new tech, the evaluation is generally one or a small 41 | number of developers building a prototype within a fixed time frame. Often referred to as a “hack week”, if you even get 42 | a week. Because of the KMP documentation situation, we commonly hear that it can take several days to get past basic 43 | setup. Often that will mean abandoning the project, but even if the team continues the evaluation, the impression is 44 | that KMP isn’t “ready”. 45 | 46 | We have directly helped some teams avoid that initial setup phase by being a documentation guide and providing basic 47 | support. These teams have a much different experience with the tech. 48 | 49 | 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. 50 | As we progress into 2020 and the documentation situation improves, this kit will be less necessary, but right now we 51 | feel this kind of starting point is critical for success. 52 | 53 | Specifically this “Kit” includes introductory info, which you are reading currently, a pre-configured project, necessary 54 | docs for platform information, contact and community info for support, and some “soft skills” docs. 55 | 56 | The project is configured with libraries generally useful for native mobile development, and some very basic examples 57 | of how to use them in a mobile context. We will likely add additional projects as we get community feedback. 58 | 59 | There are aspects of Kotlin/Native that will be new to developers coming from either an Android or iOS background. The 60 | most obvious is the concurrency model, which you’ll need to understand. These docs will provide the MVU, Minimum Viable 61 | Understanding, to be productive, with links to deeper dives. 62 | 63 | A little bit of discussion and feedback can go a long way. There are a few options for community support. 64 | The “soft skills” info is focused around discussing KMP to your team and management. Just because it works doesn’t mean 65 | everybody else will like it. This section will also evolve over time as more of the common concerns and pushback points 66 | are addressed. 67 | 68 | ## Why KMP? 69 | 70 | The case for KMP is a longer discussion. I discuss it at some length in various talks (https://vimeo.com/371428809). 71 | Over the past 2 years we’ve constructed a short definition of what we think separates KMP from other options: 72 | 73 | > optional, natively-integrated, open-source, code sharing platform, based on the popular, modern language Kotlin. 74 | > facilitates non-ui logic availability on many platforms (and Jetbrains) 75 | 76 | Optional: KMP interops easily and directly with the native platform, and is designed to be used seamlessly with existing 77 | code. That means you can start small with code sharing and increase as time goes on. You do not need big, risky 78 | rewrites. 79 | 80 | Natively-integrated: On the JVM Kotlin makes JVM bytecode. In JS, Kotlin outputs JS. On iOS you get an Xcode framework. 81 | Kotlin’s Interop story is unique and a distinct advantage. 82 | 83 | * Open Source: It would be difficult to not be open source in 2020, but some tools are not. 84 | * Code sharing: Not a monolithic singular “app”. Kotlin’s focus is code sharing (see optional above). 85 | * Popular: Big, engaged community. Very active library development and support. Training, recruiting, etc. This matters. 86 | * Modern: Kotlin as a platform is being built to last by intentionally not getting old. 87 | * Non-UI: Picking a big, monolithic tech stack for mobile is risky. Shared UI doesn’t have a great history, but shared 88 | logic is the history of computers. Build something that is incremental and “plays nice” with the host system is much 89 | harder and will take longer, but is ultimately the successful strategy. There will be “shared UI” options for KMP. The 90 | good news is they’ll be optional. 91 | * Jetbrains: Jetbrains has built an amazing business on building the best IDE’s. They also make Kotlin the language. 92 | This is a unique combination. They are self-funded, as in there is no VC or public shareholder pressure to have 93 | immediate ROI. Jetbrains is here to stay, and they are committed to Kotlin. The tooling around KMP and Native is 94 | evolving, 95 | but it’s safe to assume Kotlin as a platform will have the best tooling in the industry. 96 | 97 | We, Touchlab, have a clear perspective on the future. That is, the future is very hard to predict. Kotlin as a platform 98 | is a low-risk choice because of the reasons mentioned above. We prefer less risky choices because being “right” about 99 | the future isn’t that important if you can reduce the cost of being “wrong”. 100 | -------------------------------------------------------------------------------- /docs/runconfig.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/touchlab/KaMPKit/4e430ed74e4ecf38ea036dbccaccc3442c13df47/docs/runconfig.png -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | # IDE (e.g. Android Studio) users: 3 | # Gradle settings configured through the IDE *will override* 4 | # any settings specified in this file. 5 | # For more details on how to configure your build environment visit 6 | # http://www.gradle.org/docs/current/userguide/build_environment.html 7 | # Specifies the JVM arguments used for the daemon process. 8 | # The setting is particularly useful for tweaking memory settings. 9 | org.gradle.jvmargs=-Xmx6g 10 | # AndroidX package structure to make it clearer which packages are bundled with the 11 | # Android operating system, and which are packaged with your app's APK 12 | # https://developer.android.com/topic/libraries/support-library/androidx-rn 13 | android.useAndroidX=true 14 | # Kotlin code style for this project: "official" or "obsolete": 15 | kotlin.code.style=official 16 | # Tell the KMP plugin where the iOS project lives 17 | xcodeproj=./ios 18 | org.gradle.caching=true 19 | org.gradle.parallel=true 20 | -------------------------------------------------------------------------------- /gradle/libs.versions.toml: -------------------------------------------------------------------------------- 1 | [versions] 2 | ## SDK Versions 3 | minSdk = "21" 4 | targetSdk = "35" 5 | compileSdk = "35" 6 | 7 | # Dependencies 8 | kotlin = "2.1.10" 9 | android-gradle-plugin = "8.9.0" 10 | ktlint-gradle = "12.2.0" 11 | compose = "1.7.8" 12 | android-desugaring = "2.1.5" 13 | androidx-core = "1.15.0" 14 | androidx-test-junit = "1.2.1" 15 | androidx-activity-compose = "1.10.1" 16 | androidx-lifecycle = "2.8.7" 17 | coroutines = "1.10.1" 18 | kotlinx-datetime = "0.6.2" 19 | ktor = "3.1.1" 20 | robolectric = "4.14.1" 21 | kermit = "2.0.5" 22 | skie = "0.10.1" 23 | koin = "4.0.2" 24 | multiplatformSettings = "1.3.0" 25 | turbine = "1.2.0" 26 | sqlDelight = "2.0.2" 27 | 28 | [libraries] 29 | android-desugaring = { module = "com.android.tools:desugar_jdk_libs", version.ref = "android-desugaring" } 30 | androidx-core = { module = "androidx.core:core-ktx", version.ref = "androidx-core" } 31 | androidx-lifecycle-runtime = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "androidx-lifecycle" } 32 | androidx-lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "androidx-lifecycle" } 33 | androidx-lifecycle-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" } 34 | androidx-test-junit = { module = "androidx.test.ext:junit-ktx", version.ref = "androidx-test-junit" } 35 | 36 | compose-ui = { module = "androidx.compose.ui:ui", version.ref = "compose" } 37 | compose-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose" } 38 | compose-foundation = { module = "androidx.compose.foundation:foundation", version.ref = "compose" } 39 | compose-material = { module = "androidx.compose.material:material", version.ref = "compose" } 40 | compose-activity = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity-compose" } 41 | 42 | coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } 43 | coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } 44 | 45 | koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" } 46 | koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" } 47 | koin-test = { module = "io.insert-koin:koin-test", version.ref = "koin" } 48 | koin-view-model = { module = "io.insert-koin:koin-core-viewmodel", version.ref = "koin" } 49 | kotlinx-dateTime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" } 50 | 51 | ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } 52 | ktor-client-ios = { module = "io.ktor:ktor-client-ios", version.ref = "ktor" } 53 | ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } 54 | ktor-client-okHttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } 55 | ktor-client-serialization = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } 56 | ktor-client-contentNegotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } 57 | ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" } 58 | 59 | multiplatformSettings-common = { module = "com.russhwolf:multiplatform-settings", version.ref = "multiplatformSettings" } 60 | multiplatformSettings-test = { module = "com.russhwolf:multiplatform-settings-test", version.ref = "multiplatformSettings" } 61 | 62 | roboelectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } 63 | 64 | sqlDelight-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqlDelight" } 65 | sqlDelight-jvm = { module = "app.cash.sqldelight:sqlite-driver", version.ref = "sqlDelight" } 66 | sqlDelight-coroutinesExt = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqlDelight" } 67 | sqlDelight-native = { module = "app.cash.sqldelight:native-driver", version.ref = "sqlDelight" } 68 | 69 | touchlab-kermit = { module = "co.touchlab:kermit", version.ref = "kermit" } 70 | touchlab-kermit-simple = { module = "co.touchlab:kermit-simple", version.ref = "kermit" } 71 | touchlab-skie-annotations = { module = "co.touchlab.skie:configuration-annotations", version.ref = "skie" } 72 | 73 | turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" } 74 | kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } 75 | 76 | [plugins] 77 | ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint-gradle" } 78 | kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } 79 | kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } 80 | sqlDelight = { id = "app.cash.sqldelight", version.ref = "sqlDelight" } 81 | android-application = { id = "com.android.application", version.ref = "android-gradle-plugin" } 82 | android-library = { id = "com.android.library", version.ref = "android-gradle-plugin" } 83 | kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } 84 | skie = { id = "co.touchlab.skie", version.ref = "skie" } 85 | compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } 86 | 87 | [bundles] 88 | app-ui = [ 89 | "androidx-core", 90 | "androidx-lifecycle-runtime", 91 | "androidx-lifecycle-viewmodel", 92 | "androidx-lifecycle-compose", 93 | "compose-ui", 94 | "compose-tooling", 95 | "compose-foundation", 96 | "compose-material", 97 | "compose-activity" 98 | ] 99 | ktor-common = ["ktor-client-core", "ktor-client-logging", "ktor-client-serialization", "ktor-client-contentNegotiation"] 100 | shared-commonTest = [ 101 | "kotlin-test", 102 | "multiplatformSettings-test", 103 | "koin-test", 104 | "turbine", 105 | "coroutines-test", 106 | "ktor-client-mock" 107 | ] 108 | shared-androidTest = [ 109 | "androidx-test-junit", 110 | "coroutines-test", 111 | "roboelectric", 112 | "sqlDelight-jvm" 113 | ] 114 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/touchlab/KaMPKit/4e430ed74e4ecf38ea036dbccaccc3442c13df47/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | # 21 | # Gradle start up script for POSIX generated by Gradle. 22 | # 23 | # Important for running: 24 | # 25 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 26 | # noncompliant, but you have some other compliant shell such as ksh or 27 | # bash, then to run this script, type that shell name before the whole 28 | # command line, like: 29 | # 30 | # ksh Gradle 31 | # 32 | # Busybox and similar reduced shells will NOT work, because this script 33 | # requires all of these POSIX shell features: 34 | # * functions; 35 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 36 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 37 | # * compound commands having a testable exit status, especially «case»; 38 | # * various built-in commands including «command», «set», and «ulimit». 39 | # 40 | # Important for patching: 41 | # 42 | # (2) This script targets any POSIX shell, so it avoids extensions provided 43 | # by Bash, Ksh, etc; in particular arrays are avoided. 44 | # 45 | # The "traditional" practice of packing multiple parameters into a 46 | # space-separated string is a well documented source of bugs and security 47 | # problems, so this is (mostly) avoided, by progressively accumulating 48 | # options in "$@", and eventually passing that to Java. 49 | # 50 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 51 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 52 | # see the in-line comments for details. 53 | # 54 | # There are tweaks for specific operating systems such as AIX, CygWin, 55 | # Darwin, MinGW, and NonStop. 56 | # 57 | # (3) This script is generated from the Groovy template 58 | # https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 59 | # within the Gradle project. 60 | # 61 | # You can find Gradle at https://github.com/gradle/gradle/. 62 | # 63 | ############################################################################## 64 | 65 | # Attempt to set APP_HOME 66 | 67 | # Resolve links: $0 may be a link 68 | app_path=$0 69 | 70 | # Need this for daisy-chained symlinks. 71 | while 72 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 73 | [ -h "$app_path" ] 74 | do 75 | ls=$( ls -ld "$app_path" ) 76 | link=${ls#*' -> '} 77 | case $link in #( 78 | /*) app_path=$link ;; #( 79 | *) app_path=$APP_HOME$link ;; 80 | esac 81 | done 82 | 83 | # This is normally unused 84 | # shellcheck disable=SC2034 85 | APP_BASE_NAME=${0##*/} 86 | # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) 87 | APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit 88 | 89 | # Use the maximum available, or set MAX_FD != -1 to use that value. 90 | MAX_FD=maximum 91 | 92 | warn () { 93 | echo "$*" 94 | } >&2 95 | 96 | die () { 97 | echo 98 | echo "$*" 99 | echo 100 | exit 1 101 | } >&2 102 | 103 | # OS specific support (must be 'true' or 'false'). 104 | cygwin=false 105 | msys=false 106 | darwin=false 107 | nonstop=false 108 | case "$( uname )" in #( 109 | CYGWIN* ) cygwin=true ;; #( 110 | Darwin* ) darwin=true ;; #( 111 | MSYS* | MINGW* ) msys=true ;; #( 112 | NONSTOP* ) nonstop=true ;; 113 | esac 114 | 115 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 116 | 117 | 118 | # Determine the Java command to use to start the JVM. 119 | if [ -n "$JAVA_HOME" ] ; then 120 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 121 | # IBM's JDK on AIX uses strange locations for the executables 122 | JAVACMD=$JAVA_HOME/jre/sh/java 123 | else 124 | JAVACMD=$JAVA_HOME/bin/java 125 | fi 126 | if [ ! -x "$JAVACMD" ] ; then 127 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 128 | 129 | Please set the JAVA_HOME variable in your environment to match the 130 | location of your Java installation." 131 | fi 132 | else 133 | JAVACMD=java 134 | if ! command -v java >/dev/null 2>&1 135 | then 136 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 137 | 138 | Please set the JAVA_HOME variable in your environment to match the 139 | location of your Java installation." 140 | fi 141 | fi 142 | 143 | # Increase the maximum file descriptors if we can. 144 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 145 | case $MAX_FD in #( 146 | max*) 147 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. 148 | # shellcheck disable=SC2039,SC3045 149 | MAX_FD=$( ulimit -H -n ) || 150 | warn "Could not query maximum file descriptor limit" 151 | esac 152 | case $MAX_FD in #( 153 | '' | soft) :;; #( 154 | *) 155 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. 156 | # shellcheck disable=SC2039,SC3045 157 | ulimit -n "$MAX_FD" || 158 | warn "Could not set maximum file descriptor limit to $MAX_FD" 159 | esac 160 | fi 161 | 162 | # Collect all arguments for the java command, stacking in reverse order: 163 | # * args from the command line 164 | # * the main class name 165 | # * -classpath 166 | # * -D...appname settings 167 | # * --module-path (only if needed) 168 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 169 | 170 | # For Cygwin or MSYS, switch paths to Windows format before running java 171 | if "$cygwin" || "$msys" ; then 172 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 173 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 174 | 175 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 176 | 177 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 178 | for arg do 179 | if 180 | case $arg in #( 181 | -*) false ;; # don't mess with options #( 182 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 183 | [ -e "$t" ] ;; #( 184 | *) false ;; 185 | esac 186 | then 187 | arg=$( cygpath --path --ignore --mixed "$arg" ) 188 | fi 189 | # Roll the args list around exactly as many times as the number of 190 | # args, so each arg winds up back in the position where it started, but 191 | # possibly modified. 192 | # 193 | # NB: a `for` loop captures its iteration list before it begins, so 194 | # changing the positional parameters here affects neither the number of 195 | # iterations, nor the values presented in `arg`. 196 | shift # remove old arg 197 | set -- "$@" "$arg" # push replacement arg 198 | done 199 | fi 200 | 201 | 202 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 203 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 204 | 205 | # Collect all arguments for the java command: 206 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, 207 | # and any embedded shellness will be escaped. 208 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be 209 | # treated as '${Hostname}' itself on the command line. 210 | 211 | set -- \ 212 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 213 | -classpath "$CLASSPATH" \ 214 | org.gradle.wrapper.GradleWrapperMain \ 215 | "$@" 216 | 217 | # Stop when "xargs" is not available. 218 | if ! command -v xargs >/dev/null 2>&1 219 | then 220 | die "xargs is not available" 221 | fi 222 | 223 | # Use "xargs" to parse quoted args. 224 | # 225 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 226 | # 227 | # In Bash we could simply go: 228 | # 229 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 230 | # set -- "${ARGS[@]}" "$@" 231 | # 232 | # but POSIX shell has neither arrays nor command substitution, so instead we 233 | # post-process each arg (as a line of input to sed) to backslash-escape any 234 | # character that might be a shell metacharacter, then use eval to reverse 235 | # that process (while maintaining the separation between arguments), and wrap 236 | # the whole thing up as a single "set" statement. 237 | # 238 | # This will of course break if any of these variables contains a newline or 239 | # an unmatched quote. 240 | # 241 | 242 | eval "set -- $( 243 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 244 | xargs -n1 | 245 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 246 | tr '\n' ' ' 247 | )" '"$@"' 248 | 249 | exec "$JAVACMD" "$@" 250 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%"=="" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%"=="" set DIRNAME=. 29 | @rem This is normally unused 30 | set APP_BASE_NAME=%~n0 31 | set APP_HOME=%DIRNAME% 32 | 33 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 34 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 35 | 36 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 37 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 38 | 39 | @rem Find java.exe 40 | if defined JAVA_HOME goto findJavaFromJavaHome 41 | 42 | set JAVA_EXE=java.exe 43 | %JAVA_EXE% -version >NUL 2>&1 44 | if %ERRORLEVEL% equ 0 goto execute 45 | 46 | echo. 1>&2 47 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 48 | echo. 1>&2 49 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 50 | echo location of your Java installation. 1>&2 51 | 52 | goto fail 53 | 54 | :findJavaFromJavaHome 55 | set JAVA_HOME=%JAVA_HOME:"=% 56 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 57 | 58 | if exist "%JAVA_EXE%" goto execute 59 | 60 | echo. 1>&2 61 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 62 | echo. 1>&2 63 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 64 | echo location of your Java installation. 1>&2 65 | 66 | goto fail 67 | 68 | :execute 69 | @rem Setup the command line 70 | 71 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 72 | 73 | 74 | @rem Execute Gradle 75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 76 | 77 | :end 78 | @rem End local scope for the variables with windows NT shell 79 | if %ERRORLEVEL% equ 0 goto mainEnd 80 | 81 | :fail 82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 83 | rem the _cmd.exe /c_ return code! 84 | set EXIT_CODE=%ERRORLEVEL% 85 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 86 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 87 | exit /b %EXIT_CODE% 88 | 89 | :mainEnd 90 | if "%OS%"=="Windows_NT" endlocal 91 | 92 | :omega 93 | -------------------------------------------------------------------------------- /ios/.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.gitignore.io/api/xcode 2 | # Edit at https://www.gitignore.io/?templates=xcode 3 | 4 | ### Xcode ### 5 | # Xcode 6 | # 7 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 8 | 9 | ## User settings 10 | xcuserdata/ 11 | 12 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 13 | *.xcscmblueprint 14 | *.xccheckout 15 | 16 | ## Xcode Patch 17 | *.xcodeproj/* 18 | !*.xcodeproj/project.pbxproj 19 | !*.xcodeproj/xcshareddata/ 20 | !*.xcworkspace/contents.xcworkspacedata 21 | /*.gcno 22 | 23 | ### Xcode Patch ### 24 | **/xcshareddata/WorkspaceSettings.xcsettings 25 | 26 | # End of https://www.gitignore.io/api/xcode -------------------------------------------------------------------------------- /ios/KaMPKitiOS.xcodeproj/xcshareddata/xcschemes/KaMPKitiOS.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 33 | 39 | 40 | 41 | 43 | 49 | 50 | 51 | 52 | 53 | 63 | 65 | 71 | 72 | 73 | 74 | 80 | 82 | 88 | 89 | 90 | 91 | 93 | 94 | 97 | 98 | 99 | -------------------------------------------------------------------------------- /ios/KaMPKitiOS/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // KaMPKitiOS 4 | // 5 | // Created by Kevin Schildhorn on 12/18/19. 6 | // Copyright © 2019 Touchlab. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import shared 11 | 12 | @UIApplicationMain 13 | class AppDelegate: UIResponder, UIApplicationDelegate { 14 | 15 | var window: UIWindow? 16 | 17 | // Lazy so it doesn't try to initialize before startKoin() is called 18 | lazy var log = koin.loggerWithTag(tag: "AppDelegate") 19 | 20 | func application(_ application: UIApplication, didFinishLaunchingWithOptions 21 | launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 22 | 23 | startKoin() 24 | 25 | let viewController = UIHostingController(rootView: BreedListScreen()) 26 | 27 | self.window = UIWindow(frame: UIScreen.main.bounds) 28 | self.window?.rootViewController = viewController 29 | self.window?.makeKeyAndVisible() 30 | 31 | log.v(message: {"App Started"}) 32 | return true 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /ios/KaMPKitiOS/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "size" : "20x20", 6 | "scale" : "2x" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "size" : "20x20", 11 | "scale" : "3x" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "size" : "29x29", 16 | "scale" : "2x" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "size" : "29x29", 21 | "scale" : "3x" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "size" : "40x40", 26 | "scale" : "2x" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "size" : "40x40", 31 | "scale" : "3x" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "size" : "60x60", 36 | "scale" : "2x" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "size" : "60x60", 41 | "scale" : "3x" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "size" : "20x20", 46 | "scale" : "1x" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "size" : "20x20", 51 | "scale" : "2x" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "size" : "29x29", 56 | "scale" : "1x" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "size" : "29x29", 61 | "scale" : "2x" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "size" : "40x40", 66 | "scale" : "1x" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "size" : "40x40", 71 | "scale" : "2x" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "size" : "76x76", 76 | "scale" : "1x" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "size" : "76x76", 81 | "scale" : "2x" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "size" : "83.5x83.5", 86 | "scale" : "2x" 87 | }, 88 | { 89 | "idiom" : "ios-marketing", 90 | "size" : "1024x1024", 91 | "scale" : "1x" 92 | } 93 | ], 94 | "info" : { 95 | "version" : 1, 96 | "author" : "xcode" 97 | } 98 | } -------------------------------------------------------------------------------- /ios/KaMPKitiOS/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /ios/KaMPKitiOS/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /ios/KaMPKitiOS/Base.lproj/Main.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 35 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | -------------------------------------------------------------------------------- /ios/KaMPKitiOS/BreedListScreen.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BreedListView.swift 3 | // KaMPKitiOS 4 | // 5 | // Created by Russell Wolf on 7/26/21. 6 | // Copyright © 2021 Touchlab. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import shared 11 | 12 | private let log = koin.loggerWithTag(tag: "BreedListScreen") 13 | 14 | struct BreedListScreen: View { 15 | 16 | @State 17 | var viewModel: BreedViewModel? 18 | 19 | @State 20 | var breedState: BreedViewState = .Initial.shared 21 | 22 | var body: some View { 23 | BreedListContent( 24 | state: breedState, 25 | onBreedFavorite: { breed in 26 | Task { 27 | try? await viewModel?.updateBreedFavorite(breed: breed) 28 | } 29 | }, 30 | refresh: { 31 | Task { 32 | try? await viewModel?.refreshBreeds() 33 | } 34 | } 35 | ) 36 | .task { 37 | let viewModel = KotlinDependencies.shared.getBreedViewModel() 38 | await withTaskCancellationHandler( 39 | operation: { 40 | self.viewModel = viewModel 41 | Task { 42 | try? await viewModel.activate() 43 | } 44 | for await breedState in viewModel.breedState { 45 | self.breedState = breedState 46 | } 47 | }, 48 | onCancel: { 49 | viewModel.clear() 50 | self.viewModel = nil 51 | } 52 | ) 53 | } 54 | } 55 | } 56 | 57 | struct BreedListContent: View { 58 | var state: BreedViewState 59 | var onBreedFavorite: (Breed) -> Void 60 | var refresh: () -> Void 61 | 62 | var body: some View { 63 | ZStack { 64 | VStack { 65 | switch onEnum(of: state) { 66 | case .content(let content): 67 | List(content.breeds, id: \.id) { breed in 68 | BreedRowView(breed: breed) { 69 | onBreedFavorite(breed) 70 | } 71 | } 72 | case .error(let error): 73 | Spacer() 74 | Text(error.error) 75 | .foregroundColor(.red) 76 | Spacer() 77 | case .empty: 78 | Spacer() 79 | Text("Sorry, no doggos found") 80 | Spacer() 81 | case .initial: 82 | Spacer() 83 | } 84 | 85 | Button("Refresh") { 86 | refresh() 87 | } 88 | } 89 | if state.isLoading { Text("Loading...") } 90 | } 91 | } 92 | } 93 | 94 | struct BreedRowView: View { 95 | var breed: Breed 96 | var onTap: () -> Void 97 | 98 | var body: some View { 99 | Button(action: onTap) { 100 | HStack { 101 | Text(breed.name) 102 | .padding(4.0) 103 | Spacer() 104 | Image(systemName: (!breed.favorite) ? "heart" : "heart.fill") 105 | .padding(4.0) 106 | } 107 | } 108 | } 109 | } 110 | 111 | struct BreedListScreen_Previews: PreviewProvider { 112 | static var previews: some View { 113 | Group { 114 | BreedListContent( 115 | state: .Content(breeds: [ 116 | Breed(id: 0, name: "appenzeller", favorite: false), 117 | Breed(id: 1, name: "australian", favorite: true) 118 | ]), 119 | onBreedFavorite: { _ in }, 120 | refresh: {} 121 | ) 122 | BreedListContent( 123 | state: .Initial.shared, 124 | onBreedFavorite: { _ in }, 125 | refresh: {} 126 | ) 127 | BreedListContent( 128 | state: .Empty(), 129 | onBreedFavorite: { _ in }, 130 | refresh: {} 131 | ) 132 | BreedListContent( 133 | state: .Error(error: "Something went wrong!"), 134 | onBreedFavorite: { _ in }, 135 | refresh: {} 136 | ) 137 | } 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /ios/KaMPKitiOS/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | LSRequiresIPhoneOS 22 | 23 | UILaunchStoryboardName 24 | LaunchScreen 25 | UIMainStoryboardFile 26 | Main 27 | UIRequiredDeviceCapabilities 28 | 29 | armv7 30 | 31 | UISupportedInterfaceOrientations 32 | 33 | UIInterfaceOrientationPortrait 34 | UIInterfaceOrientationLandscapeLeft 35 | UIInterfaceOrientationLandscapeRight 36 | 37 | UISupportedInterfaceOrientations~ipad 38 | 39 | UIInterfaceOrientationPortrait 40 | UIInterfaceOrientationPortraitUpsideDown 41 | UIInterfaceOrientationLandscapeLeft 42 | UIInterfaceOrientationLandscapeRight 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /ios/KaMPKitiOS/Koin.swift: -------------------------------------------------------------------------------- 1 | // 2 | // KoinApplication.swift 3 | // KaMPStarteriOS 4 | // 5 | // Created by Russell Wolf on 6/18/20. 6 | // Copyright © 2020 Touchlab. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import shared 11 | 12 | func startKoin() { 13 | // You could just as easily define all these dependencies in Kotlin, 14 | // but this helps demonstrate how you might pass platform-specific 15 | // dependencies in a larger scale project where declaring them in 16 | // Kotlin is more difficult, or where they're also used in 17 | // iOS-specific code. 18 | 19 | let userDefaults = UserDefaults(suiteName: "KAMPSTARTER_SETTINGS")! 20 | let iosAppInfo = IosAppInfo() 21 | let doOnStartup = { NSLog("Hello from iOS/Swift!") } 22 | 23 | let koinApplication = KoinIOSKt.doInitKoinIos( 24 | userDefaults: userDefaults, 25 | appInfo: iosAppInfo, 26 | doOnStartup: doOnStartup 27 | ) 28 | _koin = koinApplication.koin 29 | } 30 | 31 | private var _koin: Koin_coreKoin? 32 | var koin: Koin_coreKoin { 33 | return _koin! 34 | } 35 | 36 | class IosAppInfo: AppInfo { 37 | let appId: String = Bundle.main.bundleIdentifier! 38 | } 39 | -------------------------------------------------------------------------------- /ios/KaMPKitiOSTests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /ios/KaMPKitiOSTests/KaMPKitiOSTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // KaMPKitiOSTests.swift 3 | // KaMPKitiOSTests 4 | // 5 | // Created by Kevin Schildhorn on 12/18/19. 6 | // Copyright © 2019 Touchlab. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import KaMPKitiOS 11 | 12 | class KaMPKitiOSTests: XCTestCase { 13 | 14 | override func setUp() { 15 | // Put setup code here. This method is called before the invocation of each test method in the class. 16 | } 17 | 18 | override func tearDown() { 19 | // Put teardown code here. This method is called after the invocation of each test method in the class. 20 | } 21 | 22 | func testExample() { 23 | // This is an example of a functional test case. 24 | // Use XCTAssert and related functions to verify your tests produce the correct results. 25 | } 26 | 27 | func testPerformanceExample() { 28 | // This is an example of a performance test case. 29 | self.measure { 30 | // Put the code you want to measure the time of here. 31 | } 32 | } 33 | 34 | } 35 | -------------------------------------------------------------------------------- /ios/KaMPKitiOSUITests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /ios/KaMPKitiOSUITests/KaMPKitiOSUITests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // KaMPKitiOSUITests.swift 3 | // KaMPKitiOSUITests 4 | // 5 | // Created by Kevin Schildhorn on 12/18/19. 6 | // Copyright © 2019 Touchlab. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | 11 | class KaMPKitiOSUITests: XCTestCase { 12 | 13 | override func setUp() { 14 | // Put setup code here. This method is called before the invocation of each test method in the class. 15 | 16 | // In UI tests it is usually best to stop immediately when a failure occurs. 17 | continueAfterFailure = false 18 | 19 | // In UI tests it’s important to set the initial state - such as interface orientation - 20 | // required for your tests before they run. The setUp method is a good place to do this. 21 | } 22 | 23 | override func tearDown() { 24 | // Put teardown code here. This method is called after the invocation of each test method in the class. 25 | } 26 | 27 | func testExample() { 28 | // UI tests must launch the application that they test. 29 | let app = XCUIApplication() 30 | app.launch() 31 | 32 | // Use recording to get started writing UI tests. 33 | // Use XCTAssert and related functions to verify your tests produce the correct results. 34 | } 35 | 36 | func testLaunchPerformance() { 37 | if #available(macOS 10.15, iOS 13.0, tvOS 13.0, *) { 38 | // This measures how long it takes to launch your application. 39 | measure(metrics: [XCTOSSignpostMetric.applicationLaunch]) { 40 | XCUIApplication().launch() 41 | } 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /kampkit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/touchlab/KaMPKit/4e430ed74e4ecf38ea036dbccaccc3442c13df47/kampkit.png -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") 2 | 3 | pluginManagement { 4 | repositories { 5 | google { 6 | content { 7 | includeGroupByRegex("com\\.android.*") 8 | includeGroupByRegex("com\\.google.*") 9 | includeGroupByRegex("androidx.*") 10 | } 11 | } 12 | mavenCentral() 13 | gradlePluginPortal() 14 | } 15 | } 16 | 17 | dependencyResolutionManagement { 18 | @Suppress("UnstableApiUsage") 19 | repositories { 20 | google { 21 | content { 22 | includeGroupByRegex("com\\.android.*") 23 | includeGroupByRegex("com\\.google.*") 24 | includeGroupByRegex("androidx.*") 25 | } 26 | } 27 | mavenCentral() 28 | } 29 | } 30 | 31 | plugins { 32 | id("org.gradle.toolchains.foojay-resolver-convention") version("0.8.0") 33 | } 34 | 35 | include(":app", ":shared") 36 | rootProject.name = "KaMPKit" 37 | -------------------------------------------------------------------------------- /shared/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /shared/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi 2 | import org.jetbrains.kotlin.gradle.plugin.KotlinSourceSetTree 3 | 4 | plugins { 5 | alias(libs.plugins.kotlin.multiplatform) 6 | alias(libs.plugins.kotlin.serialization) 7 | alias(libs.plugins.android.library) 8 | alias(libs.plugins.sqlDelight) 9 | alias(libs.plugins.skie) 10 | } 11 | 12 | android { 13 | namespace = "co.touchlab.kampkit" 14 | compileSdk = libs.versions.compileSdk.get().toInt() 15 | defaultConfig { 16 | minSdk = libs.versions.minSdk.get().toInt() 17 | } 18 | @Suppress("UnstableApiUsage") 19 | testOptions { 20 | unitTests { 21 | isIncludeAndroidResources = true 22 | } 23 | } 24 | 25 | lint { 26 | warningsAsErrors = true 27 | abortOnError = true 28 | } 29 | } 30 | 31 | version = "1.2" 32 | 33 | kotlin { 34 | jvmToolchain(11) 35 | // https://kotlinlang.org/docs/multiplatform-expect-actual.html#expected-and-actual-classes 36 | // To suppress this warning about usage of expected and actual classes 37 | @OptIn(ExperimentalKotlinGradlePluginApi::class) 38 | compilerOptions { 39 | freeCompilerArgs.add("-Xexpect-actual-classes") 40 | } 41 | androidTarget { 42 | @Suppress("OPT_IN_USAGE") 43 | unitTestVariant.sourceSetTree.set(KotlinSourceSetTree.test) 44 | } 45 | listOf( 46 | iosX64(), 47 | iosArm64(), 48 | iosSimulatorArm64() 49 | ).forEach { 50 | it.binaries.framework { 51 | isStatic = false 52 | linkerOpts("-lsqlite3") 53 | export(libs.touchlab.kermit.simple) 54 | } 55 | } 56 | 57 | sourceSets { 58 | all { 59 | languageSettings.apply { 60 | optIn("kotlin.RequiresOptIn") 61 | optIn("kotlinx.coroutines.ExperimentalCoroutinesApi") 62 | optIn("kotlin.time.ExperimentalTime") 63 | } 64 | } 65 | 66 | commonMain.dependencies { 67 | implementation(libs.koin.core) 68 | implementation(libs.koin.view.model) 69 | implementation(libs.coroutines.core) 70 | implementation(libs.sqlDelight.coroutinesExt) 71 | implementation(libs.bundles.ktor.common) 72 | implementation(libs.multiplatformSettings.common) 73 | implementation(libs.kotlinx.dateTime) 74 | implementation(libs.touchlab.skie.annotations) 75 | api(libs.touchlab.kermit) 76 | } 77 | commonTest.dependencies { 78 | implementation(libs.bundles.shared.commonTest) 79 | } 80 | androidMain.dependencies { 81 | implementation(libs.androidx.lifecycle.viewmodel) 82 | implementation(libs.sqlDelight.android) 83 | implementation(libs.ktor.client.okHttp) 84 | } 85 | getByName("androidUnitTest").dependencies { 86 | implementation(libs.bundles.shared.androidTest) 87 | } 88 | iosMain.dependencies { 89 | implementation(libs.sqlDelight.native) 90 | implementation(libs.ktor.client.ios) 91 | api(libs.touchlab.kermit.simple) 92 | } 93 | } 94 | } 95 | 96 | sqldelight { 97 | databases.create("KaMPKitDb") { 98 | packageName.set("co.touchlab.kampkit.db") 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /shared/consumer-rules.pro: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/touchlab/KaMPKit/4e430ed74e4ecf38ea036dbccaccc3442c13df47/shared/consumer-rules.pro -------------------------------------------------------------------------------- /shared/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile 22 | -------------------------------------------------------------------------------- /shared/src/androidMain/kotlin/co/touchlab/kampkit/KoinAndroid.kt: -------------------------------------------------------------------------------- 1 | package co.touchlab.kampkit 2 | 3 | import app.cash.sqldelight.db.SqlDriver 4 | import app.cash.sqldelight.driver.android.AndroidSqliteDriver 5 | import co.touchlab.kampkit.db.KaMPKitDb 6 | import com.russhwolf.settings.Settings 7 | import com.russhwolf.settings.SharedPreferencesSettings 8 | import io.ktor.client.engine.okhttp.OkHttp 9 | import org.koin.core.module.Module 10 | import org.koin.dsl.module 11 | 12 | actual val platformModule: Module = module { 13 | single { 14 | AndroidSqliteDriver( 15 | KaMPKitDb.Schema, 16 | get(), 17 | "KampkitDb" 18 | ) 19 | } 20 | 21 | single { 22 | SharedPreferencesSettings(get()) 23 | } 24 | 25 | single { 26 | OkHttp.create() 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /shared/src/androidMain/kotlin/co/touchlab/kampkit/models/ViewModel.kt: -------------------------------------------------------------------------------- 1 | package co.touchlab.kampkit.models 2 | 3 | import androidx.lifecycle.ViewModel as AndroidXViewModel 4 | 5 | actual abstract class ViewModel actual constructor() : AndroidXViewModel() { 6 | actual override fun onCleared() { 7 | super.onCleared() 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /shared/src/androidUnitTest/kotlin/co/touchlab/kampkit/KoinTest.kt: -------------------------------------------------------------------------------- 1 | package co.touchlab.kampkit 2 | 3 | import android.app.Application 4 | import android.content.Context 5 | import androidx.test.core.app.ApplicationProvider.getApplicationContext 6 | import androidx.test.ext.junit.runners.AndroidJUnit4 7 | import co.touchlab.kermit.Logger 8 | import kotlin.test.AfterTest 9 | import kotlin.test.Test 10 | import org.junit.runner.RunWith 11 | import org.koin.core.context.stopKoin 12 | import org.koin.core.parameter.parametersOf 13 | import org.koin.dsl.module 14 | import org.koin.test.check.checkModules 15 | import org.robolectric.annotation.Config 16 | 17 | @RunWith(AndroidJUnit4::class) 18 | @Config(sdk = [32]) 19 | class KoinTest { 20 | 21 | @Test 22 | fun checkAllModules() { 23 | initKoin( 24 | module { 25 | single { getApplicationContext() } 26 | single { get().getSharedPreferences("TEST", Context.MODE_PRIVATE) } 27 | single { TestAppInfo } 28 | single { {} } 29 | } 30 | ).checkModules { 31 | withParameters { parametersOf("TestTag") } 32 | } 33 | } 34 | 35 | @AfterTest 36 | fun breakdown() { 37 | stopKoin() 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /shared/src/androidUnitTest/kotlin/co/touchlab/kampkit/TestUtilAndroid.kt: -------------------------------------------------------------------------------- 1 | package co.touchlab.kampkit 2 | 3 | import app.cash.sqldelight.db.SqlDriver 4 | import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver 5 | import co.touchlab.kampkit.db.KaMPKitDb 6 | 7 | internal actual fun testDbConnection(): SqlDriver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY) 8 | .also { KaMPKitDb.Schema.create(it) } 9 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/co/touchlab/kampkit/AppInfo.kt: -------------------------------------------------------------------------------- 1 | package co.touchlab.kampkit 2 | 3 | interface AppInfo { 4 | val appId: String 5 | } 6 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/co/touchlab/kampkit/DatabaseHelper.kt: -------------------------------------------------------------------------------- 1 | package co.touchlab.kampkit 2 | 3 | import app.cash.sqldelight.coroutines.asFlow 4 | import app.cash.sqldelight.coroutines.mapToList 5 | import app.cash.sqldelight.db.SqlDriver 6 | import co.touchlab.kampkit.db.Breed 7 | import co.touchlab.kampkit.db.KaMPKitDb 8 | import co.touchlab.kampkit.sqldelight.transactionWithContext 9 | import co.touchlab.kermit.Logger 10 | import kotlinx.coroutines.CoroutineDispatcher 11 | import kotlinx.coroutines.Dispatchers 12 | import kotlinx.coroutines.flow.Flow 13 | import kotlinx.coroutines.flow.flowOn 14 | 15 | class DatabaseHelper( 16 | sqlDriver: SqlDriver, 17 | private val log: Logger, 18 | private val backgroundDispatcher: CoroutineDispatcher 19 | ) { 20 | private val dbRef: KaMPKitDb = KaMPKitDb(sqlDriver) 21 | 22 | fun selectAllItems(): Flow> = dbRef.tableQueries 23 | .selectAll() 24 | .asFlow() 25 | .mapToList(Dispatchers.Default) 26 | .flowOn(backgroundDispatcher) 27 | 28 | suspend fun insertBreeds(breeds: List) { 29 | log.d { "Inserting ${breeds.size} breeds into database" } 30 | dbRef.transactionWithContext(backgroundDispatcher) { 31 | breeds.forEach { breed -> 32 | dbRef.tableQueries.insertBreed(breed) 33 | } 34 | } 35 | } 36 | 37 | fun selectById(id: Long): Flow> = dbRef.tableQueries 38 | .selectById(id) 39 | .asFlow() 40 | .mapToList(Dispatchers.Default) 41 | .flowOn(backgroundDispatcher) 42 | 43 | suspend fun deleteAll() { 44 | log.i { "Database Cleared" } 45 | dbRef.transactionWithContext(backgroundDispatcher) { 46 | dbRef.tableQueries.deleteAll() 47 | } 48 | } 49 | 50 | suspend fun updateFavorite(breedId: Long, favorite: Boolean) { 51 | log.i { "Breed $breedId: Favorited $favorite" } 52 | dbRef.transactionWithContext(backgroundDispatcher) { 53 | dbRef.tableQueries.updateFavorite(favorite, breedId) 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/co/touchlab/kampkit/Koin.kt: -------------------------------------------------------------------------------- 1 | package co.touchlab.kampkit 2 | 3 | import co.touchlab.kampkit.ktor.DogApi 4 | import co.touchlab.kampkit.ktor.DogApiImpl 5 | import co.touchlab.kampkit.models.BreedRepository 6 | import co.touchlab.kermit.Logger 7 | import co.touchlab.kermit.StaticConfig 8 | import co.touchlab.kermit.platformLogWriter 9 | import kotlinx.coroutines.Dispatchers 10 | import kotlinx.datetime.Clock 11 | import org.koin.core.KoinApplication 12 | import org.koin.core.component.KoinComponent 13 | import org.koin.core.component.inject 14 | import org.koin.core.context.startKoin 15 | import org.koin.core.module.Module 16 | import org.koin.core.parameter.parametersOf 17 | import org.koin.core.scope.Scope 18 | import org.koin.dsl.module 19 | 20 | fun initKoin(appModule: Module): KoinApplication { 21 | val koinApplication = startKoin { 22 | modules( 23 | appModule, 24 | platformModule, 25 | coreModule 26 | ) 27 | } 28 | 29 | // Dummy initialization logic, making use of appModule declarations for demonstration purposes. 30 | val koin = koinApplication.koin 31 | // doOnStartup is a lambda which is implemented in Swift on iOS side 32 | val doOnStartup = koin.get<() -> Unit>() 33 | doOnStartup.invoke() 34 | 35 | val kermit = koin.get { parametersOf(null) } 36 | // AppInfo is a Kotlin interface with separate Android and iOS implementations 37 | val appInfo = koin.get() 38 | kermit.v { "App Id ${appInfo.appId}" } 39 | 40 | return koinApplication 41 | } 42 | 43 | private val coreModule = module { 44 | single { 45 | DatabaseHelper( 46 | get(), 47 | getWith("DatabaseHelper"), 48 | Dispatchers.Default 49 | ) 50 | } 51 | single { 52 | DogApiImpl( 53 | getWith("DogApiImpl"), 54 | get() 55 | ) 56 | } 57 | single { 58 | Clock.System 59 | } 60 | 61 | // platformLogWriter() is a relatively simple config option, useful for local debugging. For production 62 | // uses you *may* want to have a more robust configuration from the native platform. In KaMP Kit, 63 | // that would likely go into platformModule expect/actual. 64 | // See https://github.com/touchlab/Kermit 65 | val baseLogger = 66 | Logger(config = StaticConfig(logWriterList = listOf(platformLogWriter())), "KampKit") 67 | factory { (tag: String?) -> if (tag != null) baseLogger.withTag(tag) else baseLogger } 68 | 69 | single { 70 | BreedRepository( 71 | get(), 72 | get(), 73 | get(), 74 | getWith("BreedRepository"), 75 | get() 76 | ) 77 | } 78 | } 79 | 80 | internal inline fun Scope.getWith(vararg params: Any?): T { 81 | return get(parameters = { parametersOf(*params) }) 82 | } 83 | 84 | // Simple function to clean up the syntax a bit 85 | fun KoinComponent.injectLogger(tag: String): Lazy = inject { parametersOf(tag) } 86 | 87 | expect val platformModule: Module 88 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/co/touchlab/kampkit/ktor/DogApi.kt: -------------------------------------------------------------------------------- 1 | package co.touchlab.kampkit.ktor 2 | 3 | import co.touchlab.kampkit.response.BreedResult 4 | 5 | interface DogApi { 6 | suspend fun getJsonFromApi(): BreedResult 7 | } 8 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/co/touchlab/kampkit/ktor/DogApiImpl.kt: -------------------------------------------------------------------------------- 1 | package co.touchlab.kampkit.ktor 2 | 3 | import co.touchlab.kampkit.response.BreedResult 4 | import co.touchlab.kermit.Logger as KermitLogger 5 | import io.ktor.client.HttpClient 6 | import io.ktor.client.call.body 7 | import io.ktor.client.engine.HttpClientEngine 8 | import io.ktor.client.plugins.HttpTimeout 9 | import io.ktor.client.plugins.contentnegotiation.ContentNegotiation 10 | import io.ktor.client.plugins.logging.LogLevel 11 | import io.ktor.client.plugins.logging.Logger as KtorLogger 12 | import io.ktor.client.plugins.logging.Logging 13 | import io.ktor.client.request.HttpRequestBuilder 14 | import io.ktor.client.request.get 15 | import io.ktor.http.encodedPath 16 | import io.ktor.http.takeFrom 17 | import io.ktor.serialization.kotlinx.json.json 18 | 19 | class DogApiImpl(private val log: KermitLogger, engine: HttpClientEngine) : DogApi { 20 | 21 | private val client = HttpClient(engine) { 22 | expectSuccess = true 23 | install(ContentNegotiation) { 24 | json() 25 | } 26 | install(Logging) { 27 | logger = object : KtorLogger { 28 | override fun log(message: String) { 29 | log.v { message } 30 | } 31 | } 32 | 33 | level = LogLevel.INFO 34 | } 35 | install(HttpTimeout) { 36 | val timeout = 30000L 37 | connectTimeoutMillis = timeout 38 | requestTimeoutMillis = timeout 39 | socketTimeoutMillis = timeout 40 | } 41 | } 42 | 43 | override suspend fun getJsonFromApi(): BreedResult { 44 | log.d { "Fetching Breeds from network" } 45 | return client.get { 46 | dogs("api/breeds/list/all") 47 | }.body() 48 | } 49 | 50 | private fun HttpRequestBuilder.dogs(path: String) { 51 | url { 52 | takeFrom("https://dog.ceo/") 53 | encodedPath = path 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/co/touchlab/kampkit/models/BreedRepository.kt: -------------------------------------------------------------------------------- 1 | package co.touchlab.kampkit.models 2 | 3 | import co.touchlab.kampkit.DatabaseHelper 4 | import co.touchlab.kampkit.db.Breed 5 | import co.touchlab.kampkit.ktor.DogApi 6 | import co.touchlab.kermit.Logger 7 | import com.russhwolf.settings.Settings 8 | import kotlinx.coroutines.flow.Flow 9 | import kotlinx.datetime.Clock 10 | 11 | class BreedRepository( 12 | private val dbHelper: DatabaseHelper, 13 | private val settings: Settings, 14 | private val dogApi: DogApi, 15 | log: Logger, 16 | private val clock: Clock 17 | ) { 18 | 19 | private val log = log.withTag("BreedModel") 20 | 21 | companion object { 22 | internal const val DB_TIMESTAMP_KEY = "DbTimestampKey" 23 | } 24 | 25 | fun getBreeds(): Flow> = dbHelper.selectAllItems() 26 | 27 | suspend fun refreshBreedsIfStale() { 28 | if (isBreedListStale()) { 29 | refreshBreeds() 30 | } 31 | } 32 | 33 | suspend fun refreshBreeds() { 34 | val breedResult = dogApi.getJsonFromApi() 35 | log.v { "Breed network result: ${breedResult.status}" } 36 | val breedList = breedResult.message.keys.sorted().toList() 37 | log.v { "Fetched ${breedList.size} breeds from network" } 38 | settings.putLong(DB_TIMESTAMP_KEY, clock.now().toEpochMilliseconds()) 39 | 40 | if (breedList.isNotEmpty()) { 41 | dbHelper.insertBreeds(breedList) 42 | } 43 | } 44 | 45 | suspend fun updateBreedFavorite(breed: Breed) { 46 | dbHelper.updateFavorite(breed.id, !breed.favorite) 47 | } 48 | 49 | private fun isBreedListStale(): Boolean { 50 | val lastDownloadTimeMS = settings.getLong(DB_TIMESTAMP_KEY, 0) 51 | val oneHourMS = 60 * 60 * 1000 52 | val stale = lastDownloadTimeMS + oneHourMS < clock.now().toEpochMilliseconds() 53 | if (!stale) { 54 | log.i { "Breeds not fetched from network. Recently updated" } 55 | } 56 | return stale 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/co/touchlab/kampkit/models/BreedViewModel.kt: -------------------------------------------------------------------------------- 1 | package co.touchlab.kampkit.models 2 | 3 | import co.touchlab.kampkit.db.Breed 4 | import co.touchlab.kermit.Logger 5 | import co.touchlab.skie.configuration.annotations.DefaultArgumentInterop 6 | import kotlinx.coroutines.flow.MutableStateFlow 7 | import kotlinx.coroutines.flow.StateFlow 8 | import kotlinx.coroutines.flow.asStateFlow 9 | import kotlinx.coroutines.flow.combine 10 | import kotlinx.coroutines.flow.flow 11 | import kotlinx.coroutines.flow.update 12 | 13 | class BreedViewModel( 14 | private val breedRepository: BreedRepository, 15 | private val log: Logger 16 | ) : ViewModel() { 17 | 18 | private val mutableBreedState: MutableStateFlow = 19 | MutableStateFlow(BreedViewState.Initial) 20 | 21 | val breedState: StateFlow = mutableBreedState.asStateFlow() 22 | 23 | /** 24 | * Activates this viewModel so that `breedState` returns the current breed state. Suspends until cancelled, at 25 | * which point `breedState` will no longer update. 26 | */ 27 | suspend fun activate() { 28 | observeBreeds() 29 | } 30 | 31 | override fun onCleared() { 32 | log.v("Clearing BreedViewModel") 33 | } 34 | 35 | private suspend fun observeBreeds() { 36 | // Refresh breeds, and emit any exception that was thrown so we can handle it downstream 37 | val refreshFlow = flow { 38 | try { 39 | breedRepository.refreshBreedsIfStale() 40 | emit(null) 41 | } catch (exception: Exception) { 42 | emit(exception) 43 | } 44 | } 45 | 46 | combine( 47 | refreshFlow, 48 | breedRepository.getBreeds() 49 | ) { throwable, breeds -> throwable to breeds } 50 | .collect { (error, breeds) -> 51 | mutableBreedState.update { previousState -> 52 | val errorMessage = if (error != null) { 53 | "Unable to download breed list" 54 | } else if (previousState is BreedViewState.Error) { 55 | previousState.error 56 | } else { 57 | null 58 | } 59 | 60 | if (breeds.isNotEmpty()) { 61 | BreedViewState.Content(breeds) 62 | } else if (errorMessage != null) { 63 | BreedViewState.Error(errorMessage) 64 | } else { 65 | BreedViewState.Empty() 66 | } 67 | } 68 | } 69 | } 70 | 71 | suspend fun refreshBreeds() { 72 | // Set loading state, which will be cleared when the repository re-emits 73 | mutableBreedState.update { 74 | when (it) { 75 | is BreedViewState.Initial -> it 76 | is BreedViewState.Content -> it.copy(isLoading = true) 77 | is BreedViewState.Empty -> it.copy(isLoading = true) 78 | is BreedViewState.Error -> it.copy(isLoading = true) 79 | } 80 | } 81 | 82 | log.v { "refreshBreeds" } 83 | try { 84 | breedRepository.refreshBreeds() 85 | } catch (exception: Exception) { 86 | handleBreedError(exception) 87 | } 88 | } 89 | 90 | suspend fun updateBreedFavorite(breed: Breed) { 91 | breedRepository.updateBreedFavorite(breed) 92 | } 93 | 94 | private fun handleBreedError(throwable: Throwable) { 95 | log.e(throwable) { "Error downloading breed list" } 96 | mutableBreedState.update { 97 | when (it) { 98 | is BreedViewState.Content -> it.copy( 99 | isLoading = false 100 | ) // Just let it fail silently if we have a cache 101 | is BreedViewState.Empty, 102 | is BreedViewState.Error, 103 | is BreedViewState.Initial -> BreedViewState.Error( 104 | error = "Unable to refresh breed list" 105 | ) 106 | } 107 | } 108 | } 109 | } 110 | 111 | sealed class BreedViewState { 112 | abstract val isLoading: Boolean 113 | 114 | data object Initial : BreedViewState() { 115 | override val isLoading: Boolean = true 116 | } 117 | 118 | data class Empty @DefaultArgumentInterop.Enabled constructor( 119 | override val isLoading: Boolean = false 120 | ) : BreedViewState() 121 | 122 | data class Content @DefaultArgumentInterop.Enabled constructor( 123 | val breeds: List, 124 | override val isLoading: Boolean = false 125 | ) : BreedViewState() 126 | 127 | data class Error @DefaultArgumentInterop.Enabled constructor( 128 | val error: String, 129 | override val isLoading: Boolean = false 130 | ) : BreedViewState() 131 | } 132 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/co/touchlab/kampkit/models/ViewModel.kt: -------------------------------------------------------------------------------- 1 | package co.touchlab.kampkit.models 2 | 3 | expect abstract class ViewModel() { 4 | protected open fun onCleared() 5 | } 6 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/co/touchlab/kampkit/response/BreedResult.kt: -------------------------------------------------------------------------------- 1 | package co.touchlab.kampkit.response 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class BreedResult( 7 | val message: Map>, 8 | var status: String 9 | ) 10 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/co/touchlab/kampkit/sqldelight/CoroutinesExtensions.kt: -------------------------------------------------------------------------------- 1 | package co.touchlab.kampkit.sqldelight 2 | 3 | import app.cash.sqldelight.Transacter 4 | import app.cash.sqldelight.TransactionWithoutReturn 5 | import kotlin.coroutines.CoroutineContext 6 | import kotlinx.coroutines.withContext 7 | 8 | suspend fun Transacter.transactionWithContext( 9 | coroutineContext: CoroutineContext, 10 | noEnclosing: Boolean = false, 11 | body: TransactionWithoutReturn.() -> Unit 12 | ) { 13 | withContext(coroutineContext) { 14 | this@transactionWithContext.transaction(noEnclosing) { 15 | body() 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /shared/src/commonMain/sqldelight/co/touchlab/kampkit/db/Table.sq: -------------------------------------------------------------------------------- 1 | import kotlin.Boolean; 2 | 3 | CREATE TABLE Breed ( 4 | id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 5 | name TEXT NOT NULL UNIQUE, 6 | favorite INTEGER AS Boolean NOT NULL DEFAULT 0 7 | ); 8 | 9 | selectAll: 10 | SELECT * FROM Breed; 11 | 12 | selectById: 13 | SELECT * FROM Breed WHERE id = ?; 14 | 15 | selectByName: 16 | SELECT * FROM Breed WHERE name = ?; 17 | 18 | insertBreed: 19 | INSERT OR IGNORE INTO Breed(name) 20 | VALUES (?); 21 | 22 | deleteAll: 23 | DELETE FROM Breed; 24 | 25 | updateFavorite: 26 | UPDATE Breed SET favorite = ? WHERE id = ?; 27 | -------------------------------------------------------------------------------- /shared/src/commonTest/kotlin/co/touchlab/kampkit/BreedRepositoryTest.kt: -------------------------------------------------------------------------------- 1 | package co.touchlab.kampkit 2 | 3 | import app.cash.turbine.test 4 | import co.touchlab.kampkit.db.Breed 5 | import co.touchlab.kampkit.mock.ClockMock 6 | import co.touchlab.kampkit.mock.DogApiMock 7 | import co.touchlab.kampkit.models.BreedRepository 8 | import co.touchlab.kermit.Logger 9 | import co.touchlab.kermit.StaticConfig 10 | import com.russhwolf.settings.MapSettings 11 | import kotlin.test.AfterTest 12 | import kotlin.test.Test 13 | import kotlin.test.assertEquals 14 | import kotlin.test.assertFails 15 | import kotlin.time.Duration.Companion.hours 16 | import kotlinx.coroutines.Dispatchers 17 | import kotlinx.coroutines.test.runTest 18 | import kotlinx.datetime.Clock 19 | 20 | class BreedRepositoryTest { 21 | 22 | private var kermit = Logger(StaticConfig()) 23 | private var testDbConnection = testDbConnection() 24 | private var dbHelper = DatabaseHelper( 25 | testDbConnection, 26 | kermit, 27 | Dispatchers.Default 28 | ) 29 | private val settings = MapSettings() 30 | private val ktorApi = DogApiMock() 31 | 32 | // Need to start at non-zero time because the default value for db timestamp is 0 33 | private val clock = ClockMock(Clock.System.now()) 34 | 35 | private val repository: BreedRepository = 36 | BreedRepository(dbHelper, settings, ktorApi, kermit, clock) 37 | 38 | companion object { 39 | private val appenzeller = Breed(1, "appenzeller", false) 40 | private val australianNoLike = Breed(2, "australian", false) 41 | private val australianLike = Breed(2, "australian", true) 42 | private val breedsNoFavorite = listOf(appenzeller, australianNoLike) 43 | private val breedsFavorite = listOf(appenzeller, australianLike) 44 | private val breedNames = breedsFavorite.map { it.name } 45 | } 46 | 47 | @AfterTest 48 | fun tearDown() = runTest { 49 | testDbConnection.close() 50 | } 51 | 52 | @Test 53 | fun `Get breeds without cache`() = runTest { 54 | ktorApi.prepareResult(ktorApi.successResult()) 55 | repository.refreshBreedsIfStale() 56 | repository.getBreeds().test { 57 | assertEquals(breedsNoFavorite, awaitItem()) 58 | } 59 | } 60 | 61 | @Test 62 | fun `Get updated breeds with cache and preserve favorites`() = runTest { 63 | val successResult = ktorApi.successResult() 64 | val resultWithExtraBreed = successResult.copy( 65 | message = successResult.message + ("extra" to emptyList()) 66 | ) 67 | ktorApi.prepareResult(resultWithExtraBreed) 68 | 69 | dbHelper.insertBreeds(breedNames) 70 | dbHelper.updateFavorite(australianLike.id, true) 71 | 72 | repository.getBreeds().test { 73 | assertEquals(breedsFavorite, awaitItem()) 74 | expectNoEvents() 75 | 76 | repository.refreshBreeds() 77 | // id is 5 here because it incremented twice when trying to insert duplicate breeds 78 | assertEquals(breedsFavorite + Breed(5, "extra", false), awaitItem()) 79 | } 80 | } 81 | 82 | @Test 83 | fun `Get updated breeds when stale and preserve favorites`() = runTest { 84 | settings.putLong( 85 | BreedRepository.DB_TIMESTAMP_KEY, 86 | (clock.currentInstant - 2.hours).toEpochMilliseconds() 87 | ) 88 | 89 | val successResult = ktorApi.successResult() 90 | val resultWithExtraBreed = successResult.copy( 91 | message = successResult.message + ("extra" to emptyList()) 92 | ) 93 | ktorApi.prepareResult(resultWithExtraBreed) 94 | 95 | dbHelper.insertBreeds(breedNames) 96 | dbHelper.updateFavorite(australianLike.id, true) 97 | 98 | repository.refreshBreedsIfStale() 99 | repository.getBreeds().test { 100 | // id is 5 here because it incremented twice when trying to insert duplicate breeds 101 | assertEquals(breedsFavorite + Breed(5, "extra", false), awaitItem()) 102 | } 103 | } 104 | 105 | @Test 106 | fun `Toggle favorite cached breed`() = runTest { 107 | dbHelper.insertBreeds(breedNames) 108 | dbHelper.updateFavorite(australianLike.id, true) 109 | 110 | repository.getBreeds().test { 111 | assertEquals(breedsFavorite, awaitItem()) 112 | expectNoEvents() 113 | 114 | repository.updateBreedFavorite(australianLike) 115 | assertEquals(breedsNoFavorite, awaitItem()) 116 | } 117 | } 118 | 119 | @Test 120 | fun `No web call if data is not stale`() = runTest { 121 | settings.putLong( 122 | BreedRepository.DB_TIMESTAMP_KEY, 123 | clock.currentInstant.toEpochMilliseconds() 124 | ) 125 | ktorApi.prepareResult(ktorApi.successResult()) 126 | 127 | repository.refreshBreedsIfStale() 128 | assertEquals(0, ktorApi.calledCount) 129 | 130 | repository.refreshBreeds() 131 | assertEquals(1, ktorApi.calledCount) 132 | } 133 | 134 | @Test 135 | fun `Rethrow on API error`() = runTest { 136 | ktorApi.throwOnCall(RuntimeException("Test error")) 137 | 138 | val throwable = assertFails { 139 | repository.refreshBreeds() 140 | } 141 | assertEquals("Test error", throwable.message) 142 | } 143 | 144 | @Test 145 | fun `Rethrow on API error when stale`() = runTest { 146 | settings.putLong( 147 | BreedRepository.DB_TIMESTAMP_KEY, 148 | (clock.currentInstant - 2.hours).toEpochMilliseconds() 149 | ) 150 | ktorApi.throwOnCall(RuntimeException("Test error")) 151 | 152 | val throwable = assertFails { 153 | repository.refreshBreedsIfStale() 154 | } 155 | assertEquals("Test error", throwable.message) 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /shared/src/commonTest/kotlin/co/touchlab/kampkit/BreedViewModelTest.kt: -------------------------------------------------------------------------------- 1 | package co.touchlab.kampkit 2 | 3 | import app.cash.turbine.ReceiveTurbine 4 | import app.cash.turbine.test 5 | import co.touchlab.kampkit.db.Breed 6 | import co.touchlab.kampkit.mock.ClockMock 7 | import co.touchlab.kampkit.mock.DogApiMock 8 | import co.touchlab.kampkit.models.BreedRepository 9 | import co.touchlab.kampkit.models.BreedViewModel 10 | import co.touchlab.kampkit.models.BreedViewState 11 | import co.touchlab.kampkit.response.BreedResult 12 | import co.touchlab.kermit.Logger 13 | import co.touchlab.kermit.StaticConfig 14 | import com.russhwolf.settings.MapSettings 15 | import kotlin.test.AfterTest 16 | import kotlin.test.BeforeTest 17 | import kotlin.test.Test 18 | import kotlin.test.assertEquals 19 | import kotlin.time.Duration.Companion.hours 20 | import kotlinx.coroutines.DelicateCoroutinesApi 21 | import kotlinx.coroutines.Dispatchers 22 | import kotlinx.coroutines.GlobalScope 23 | import kotlinx.coroutines.launch 24 | import kotlinx.coroutines.test.resetMain 25 | import kotlinx.coroutines.test.runTest 26 | import kotlinx.coroutines.test.setMain 27 | import kotlinx.datetime.Clock 28 | 29 | class BreedViewModelTest { 30 | private var kermit = Logger(StaticConfig()) 31 | private var testDbConnection = testDbConnection() 32 | private var dbHelper = DatabaseHelper( 33 | testDbConnection, 34 | kermit, 35 | Dispatchers.Default 36 | ) 37 | private val settings = MapSettings() 38 | private val ktorApi = DogApiMock() 39 | 40 | // Need to start at non-zero time because the default value for db timestamp is 0 41 | private val clock = ClockMock(Clock.System.now()) 42 | 43 | private val repository: BreedRepository = 44 | BreedRepository(dbHelper, settings, ktorApi, kermit, clock) 45 | 46 | @OptIn(DelicateCoroutinesApi::class) 47 | private val viewModel by lazy { 48 | BreedViewModel(repository, kermit) 49 | .also { GlobalScope.launch { it.activate() } } 50 | } 51 | 52 | companion object { 53 | private val appenzeller = Breed(1, "appenzeller", false) 54 | private val australianNoLike = Breed(2, "australian", false) 55 | private val australianLike = Breed(2, "australian", true) 56 | private val breedViewStateSuccessNoFavorite = BreedViewState.Content( 57 | breeds = listOf(appenzeller, australianNoLike) 58 | ) 59 | private val breedViewStateSuccessFavorite = BreedViewState.Content( 60 | breeds = listOf(appenzeller, australianLike) 61 | ) 62 | private val breedNames = breedViewStateSuccessNoFavorite.breeds.map { it.name } 63 | } 64 | 65 | @BeforeTest 66 | fun setup() { 67 | Dispatchers.setMain(Dispatchers.Unconfined) 68 | } 69 | 70 | @AfterTest 71 | fun tearDown() { 72 | Dispatchers.resetMain() 73 | testDbConnection.close() 74 | } 75 | 76 | @Test 77 | fun `Get breeds without cache`() = runTest { 78 | ktorApi.prepareResult(ktorApi.successResult()) 79 | 80 | viewModel.breedState.test { 81 | assertEquals( 82 | breedViewStateSuccessNoFavorite, 83 | awaitItemPrecededBy(BreedViewState.Initial, BreedViewState.Empty()) 84 | ) 85 | } 86 | } 87 | 88 | @Test 89 | fun `Get breeds empty`() = runTest { 90 | ktorApi.prepareResult(BreedResult(emptyMap(), "success")) 91 | 92 | viewModel.breedState.test { 93 | assertEquals( 94 | BreedViewState.Empty(), 95 | awaitItemPrecededBy(BreedViewState.Initial) 96 | ) 97 | } 98 | } 99 | 100 | @Test 101 | fun `Get updated breeds with cache and preserve favorites`() = runTest { 102 | settings.putLong( 103 | BreedRepository.DB_TIMESTAMP_KEY, 104 | clock.currentInstant.toEpochMilliseconds() 105 | ) 106 | 107 | val successResult = ktorApi.successResult() 108 | val resultWithExtraBreed = successResult.copy( 109 | message = successResult.message + ("extra" to emptyList()) 110 | ) 111 | ktorApi.prepareResult(resultWithExtraBreed) 112 | 113 | dbHelper.insertBreeds(breedNames) 114 | dbHelper.updateFavorite(australianLike.id, true) 115 | 116 | viewModel.breedState.test { 117 | assertEquals(breedViewStateSuccessFavorite, awaitItemPrecededBy(BreedViewState.Initial)) 118 | expectNoEvents() 119 | 120 | viewModel.refreshBreeds() 121 | // id is 5 here because it incremented twice when trying to insert duplicate breeds 122 | assertEquals( 123 | BreedViewState.Content( 124 | breedViewStateSuccessFavorite.breeds + Breed(5, "extra", false) 125 | ), 126 | awaitItemPrecededBy(breedViewStateSuccessFavorite.copy(isLoading = true)) 127 | ) 128 | } 129 | } 130 | 131 | @Test 132 | fun `Get updated breeds when stale and preserve favorites`() = runTest { 133 | settings.putLong( 134 | BreedRepository.DB_TIMESTAMP_KEY, 135 | (clock.currentInstant - 2.hours).toEpochMilliseconds() 136 | ) 137 | 138 | val successResult = ktorApi.successResult() 139 | val resultWithExtraBreed = successResult.copy( 140 | message = successResult.message + ("extra" to emptyList()) 141 | ) 142 | ktorApi.prepareResult(resultWithExtraBreed) 143 | 144 | dbHelper.insertBreeds(breedNames) 145 | dbHelper.updateFavorite(australianLike.id, true) 146 | 147 | viewModel.breedState.test { 148 | // id is 5 here because it incremented twice when trying to insert duplicate breeds 149 | assertEquals( 150 | BreedViewState.Content( 151 | breedViewStateSuccessFavorite.breeds + Breed(5, "extra", false) 152 | ), 153 | awaitItemPrecededBy(BreedViewState.Initial, breedViewStateSuccessFavorite) 154 | ) 155 | } 156 | } 157 | 158 | @Test 159 | fun `Toggle favorite cached breed`() = runTest { 160 | settings.putLong( 161 | BreedRepository.DB_TIMESTAMP_KEY, 162 | clock.currentInstant.toEpochMilliseconds() 163 | ) 164 | 165 | dbHelper.insertBreeds(breedNames) 166 | dbHelper.updateFavorite(australianLike.id, true) 167 | 168 | viewModel.breedState.test { 169 | assertEquals(breedViewStateSuccessFavorite, awaitItemPrecededBy(BreedViewState.Initial)) 170 | expectNoEvents() 171 | 172 | viewModel.updateBreedFavorite(australianLike) 173 | assertEquals( 174 | breedViewStateSuccessNoFavorite, 175 | awaitItemPrecededBy(breedViewStateSuccessFavorite.copy(isLoading = true)) 176 | ) 177 | } 178 | } 179 | 180 | @Test 181 | fun `No web call if data is not stale`() = runTest { 182 | settings.putLong( 183 | BreedRepository.DB_TIMESTAMP_KEY, 184 | clock.currentInstant.toEpochMilliseconds() 185 | ) 186 | ktorApi.prepareResult(ktorApi.successResult()) 187 | dbHelper.insertBreeds(breedNames) 188 | 189 | viewModel.breedState.test { 190 | assertEquals( 191 | breedViewStateSuccessNoFavorite, 192 | awaitItemPrecededBy(BreedViewState.Initial) 193 | ) 194 | assertEquals(0, ktorApi.calledCount) 195 | expectNoEvents() 196 | 197 | viewModel.refreshBreeds() 198 | assertEquals( 199 | breedViewStateSuccessNoFavorite, 200 | awaitItemPrecededBy(breedViewStateSuccessNoFavorite.copy(isLoading = true)) 201 | ) 202 | assertEquals(1, ktorApi.calledCount) 203 | } 204 | } 205 | 206 | @Test 207 | fun `Display API error on first run`() = runTest { 208 | ktorApi.throwOnCall(RuntimeException("Test error")) 209 | 210 | viewModel.breedState.test { 211 | assertEquals( 212 | BreedViewState.Error(error = "Unable to download breed list"), 213 | awaitItemPrecededBy(BreedViewState.Initial, BreedViewState.Empty()) 214 | ) 215 | } 216 | } 217 | 218 | @Test 219 | fun `Ignore API error with cache`() = runTest { 220 | dbHelper.insertBreeds(breedNames) 221 | settings.putLong( 222 | BreedRepository.DB_TIMESTAMP_KEY, 223 | (clock.currentInstant - 2.hours).toEpochMilliseconds() 224 | ) 225 | ktorApi.throwOnCall(RuntimeException("Test error")) 226 | 227 | viewModel.breedState.test { 228 | assertEquals( 229 | breedViewStateSuccessNoFavorite, 230 | awaitItemPrecededBy(BreedViewState.Initial) 231 | ) 232 | expectNoEvents() 233 | 234 | ktorApi.prepareResult(ktorApi.successResult()) 235 | viewModel.refreshBreeds() 236 | 237 | assertEquals( 238 | breedViewStateSuccessNoFavorite, 239 | awaitItemPrecededBy(breedViewStateSuccessNoFavorite.copy(isLoading = true)) 240 | ) 241 | } 242 | } 243 | 244 | @Test 245 | fun `Ignore API error on refresh with cache`() = runTest { 246 | ktorApi.prepareResult(ktorApi.successResult()) 247 | 248 | viewModel.breedState.test { 249 | assertEquals( 250 | breedViewStateSuccessNoFavorite, 251 | awaitItemPrecededBy(BreedViewState.Initial, BreedViewState.Empty()) 252 | ) 253 | expectNoEvents() 254 | 255 | ktorApi.throwOnCall(RuntimeException("Test error")) 256 | viewModel.refreshBreeds() 257 | 258 | assertEquals( 259 | breedViewStateSuccessNoFavorite, 260 | awaitItemPrecededBy(breedViewStateSuccessNoFavorite.copy(isLoading = true)) 261 | ) 262 | } 263 | } 264 | 265 | @Test 266 | fun `Show API error on refresh without cache`() = runTest { 267 | settings.putLong( 268 | BreedRepository.DB_TIMESTAMP_KEY, 269 | clock.currentInstant.toEpochMilliseconds() 270 | ) 271 | ktorApi.throwOnCall(RuntimeException("Test error")) 272 | 273 | viewModel.breedState.test { 274 | assertEquals(BreedViewState.Empty(), awaitItemPrecededBy(BreedViewState.Initial)) 275 | expectNoEvents() 276 | 277 | viewModel.refreshBreeds() 278 | assertEquals( 279 | BreedViewState.Error(error = "Unable to refresh breed list"), 280 | awaitItemPrecededBy(BreedViewState.Empty(isLoading = true)) 281 | ) 282 | } 283 | } 284 | } 285 | 286 | // There's a race condition where intermediate states can get missed if the next state comes too fast. 287 | // This function addresses that by awaiting an item that may or may not be preceded by the specified other items 288 | private suspend fun ReceiveTurbine.awaitItemPrecededBy( 289 | vararg items: BreedViewState 290 | ): BreedViewState { 291 | var nextItem = awaitItem() 292 | for (item in items) { 293 | if (item == nextItem) { 294 | nextItem = awaitItem() 295 | } 296 | } 297 | return nextItem 298 | } 299 | -------------------------------------------------------------------------------- /shared/src/commonTest/kotlin/co/touchlab/kampkit/DogApiTest.kt: -------------------------------------------------------------------------------- 1 | package co.touchlab.kampkit 2 | 3 | import co.touchlab.kampkit.ktor.DogApiImpl 4 | import co.touchlab.kampkit.response.BreedResult 5 | import co.touchlab.kermit.LogWriter 6 | import co.touchlab.kermit.Logger 7 | import co.touchlab.kermit.LoggerConfig 8 | import co.touchlab.kermit.Severity 9 | import io.ktor.client.engine.mock.MockEngine 10 | import io.ktor.client.engine.mock.respond 11 | import io.ktor.client.plugins.ClientRequestException 12 | import io.ktor.http.ContentType 13 | import io.ktor.http.HttpHeaders 14 | import io.ktor.http.HttpStatusCode 15 | import io.ktor.http.headersOf 16 | import kotlin.test.Test 17 | import kotlin.test.assertEquals 18 | import kotlin.test.assertFailsWith 19 | import kotlinx.coroutines.test.runTest 20 | 21 | class DogApiTest { 22 | private val emptyLogger = Logger( 23 | config = object : LoggerConfig { 24 | override val logWriterList: List = emptyList() 25 | override val minSeverity: Severity = Severity.Assert 26 | }, 27 | tag = "" 28 | ) 29 | 30 | @Test 31 | fun success() = runTest { 32 | val engine = MockEngine { 33 | assertEquals("https://dog.ceo/api/breeds/list/all", it.url.toString()) 34 | respond( 35 | content = """ 36 | {"message":{"affenpinscher":[],"african":["shepherd"]},"status":"success"} 37 | """.trimIndent(), 38 | headers = headersOf( 39 | HttpHeaders.ContentType, 40 | ContentType.Application.Json.toString() 41 | ) 42 | ) 43 | } 44 | val dogApi = DogApiImpl(emptyLogger, engine) 45 | 46 | val result = dogApi.getJsonFromApi() 47 | assertEquals( 48 | BreedResult( 49 | mapOf( 50 | "affenpinscher" to emptyList(), 51 | "african" to listOf("shepherd") 52 | ), 53 | "success" 54 | ), 55 | result 56 | ) 57 | } 58 | 59 | @Test 60 | fun failure() = runTest { 61 | val engine = MockEngine { 62 | respond( 63 | content = "", 64 | status = HttpStatusCode.NotFound 65 | ) 66 | } 67 | val dogApi = DogApiImpl(emptyLogger, engine) 68 | 69 | assertFailsWith { 70 | dogApi.getJsonFromApi() 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /shared/src/commonTest/kotlin/co/touchlab/kampkit/SqlDelightTest.kt: -------------------------------------------------------------------------------- 1 | package co.touchlab.kampkit 2 | 3 | import co.touchlab.kermit.Logger 4 | import co.touchlab.kermit.StaticConfig 5 | import kotlin.test.BeforeTest 6 | import kotlin.test.Test 7 | import kotlin.test.assertNotNull 8 | import kotlin.test.assertTrue 9 | import kotlinx.coroutines.Dispatchers 10 | import kotlinx.coroutines.flow.first 11 | import kotlinx.coroutines.test.runTest 12 | 13 | class SqlDelightTest { 14 | 15 | private lateinit var dbHelper: DatabaseHelper 16 | 17 | private suspend fun DatabaseHelper.insertBreed(name: String) { 18 | insertBreeds(listOf(name)) 19 | } 20 | 21 | @BeforeTest 22 | fun setup() = runTest { 23 | dbHelper = DatabaseHelper( 24 | testDbConnection(), 25 | Logger(StaticConfig()), 26 | Dispatchers.Default 27 | ) 28 | dbHelper.deleteAll() 29 | dbHelper.insertBreed("Beagle") 30 | } 31 | 32 | @Test 33 | fun `Select All Items Success`() = runTest { 34 | val breeds = dbHelper.selectAllItems().first() 35 | assertNotNull( 36 | breeds.find { it.name == "Beagle" }, 37 | "Could not retrieve Breed" 38 | ) 39 | } 40 | 41 | @Test 42 | fun `Select Item by Id Success`() = runTest { 43 | val breeds = dbHelper.selectAllItems().first() 44 | val firstBreed = breeds.first() 45 | assertNotNull( 46 | dbHelper.selectById(firstBreed.id), 47 | "Could not retrieve Breed by Id" 48 | ) 49 | } 50 | 51 | @Test 52 | fun `Update Favorite Success`() = runTest { 53 | val breeds = dbHelper.selectAllItems().first() 54 | val firstBreed = breeds.first() 55 | dbHelper.updateFavorite(firstBreed.id, true) 56 | val newBreed = dbHelper.selectById(firstBreed.id).first().first() 57 | assertNotNull( 58 | newBreed, 59 | "Could not retrieve Breed by Id" 60 | ) 61 | assertTrue( 62 | newBreed.favorite, 63 | "Favorite Did Not Save" 64 | ) 65 | } 66 | 67 | @Test 68 | fun `Delete All Success`() = runTest { 69 | dbHelper.insertBreed("Poodle") 70 | dbHelper.insertBreed("Schnauzer") 71 | assertTrue(dbHelper.selectAllItems().first().isNotEmpty()) 72 | dbHelper.deleteAll() 73 | 74 | assertTrue( 75 | dbHelper.selectAllItems().first().isEmpty(), 76 | "Delete All did not work" 77 | ) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /shared/src/commonTest/kotlin/co/touchlab/kampkit/TestAppInfo.kt: -------------------------------------------------------------------------------- 1 | package co.touchlab.kampkit 2 | 3 | object TestAppInfo : AppInfo { 4 | override val appId: String = "Test" 5 | } 6 | -------------------------------------------------------------------------------- /shared/src/commonTest/kotlin/co/touchlab/kampkit/TestUtil.kt: -------------------------------------------------------------------------------- 1 | package co.touchlab.kampkit 2 | 3 | import app.cash.sqldelight.db.SqlDriver 4 | 5 | internal expect fun testDbConnection(): SqlDriver 6 | -------------------------------------------------------------------------------- /shared/src/commonTest/kotlin/co/touchlab/kampkit/mock/ClockMock.kt: -------------------------------------------------------------------------------- 1 | package co.touchlab.kampkit.mock 2 | 3 | import kotlinx.datetime.Clock 4 | import kotlinx.datetime.Instant 5 | 6 | class ClockMock(var currentInstant: Instant) : Clock { 7 | override fun now(): Instant = currentInstant 8 | } 9 | -------------------------------------------------------------------------------- /shared/src/commonTest/kotlin/co/touchlab/kampkit/mock/DogApiMock.kt: -------------------------------------------------------------------------------- 1 | package co.touchlab.kampkit.mock 2 | 3 | import co.touchlab.kampkit.ktor.DogApi 4 | import co.touchlab.kampkit.response.BreedResult 5 | 6 | // TODO convert this to use Ktor's MockEngine 7 | class DogApiMock : DogApi { 8 | private var nextResult: () -> BreedResult = { error("Uninitialized!") } 9 | var calledCount = 0 10 | private set 11 | 12 | override suspend fun getJsonFromApi(): BreedResult { 13 | val result = nextResult() 14 | calledCount++ 15 | return result 16 | } 17 | 18 | fun successResult(): BreedResult { 19 | val map = HashMap>().apply { 20 | put("appenzeller", emptyList()) 21 | put("australian", listOf("shepherd")) 22 | } 23 | return BreedResult(map, "success") 24 | } 25 | 26 | fun prepareResult(breedResult: BreedResult) { 27 | nextResult = { breedResult } 28 | } 29 | 30 | fun throwOnCall(throwable: Throwable) { 31 | nextResult = { throw throwable } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /shared/src/iosMain/kotlin/co/touchlab/kampkit/KermitExceptionHandler.kt: -------------------------------------------------------------------------------- 1 | package co.touchlab.kampkit 2 | 3 | import co.touchlab.kermit.Logger 4 | import kotlinx.coroutines.CoroutineExceptionHandler 5 | 6 | fun kermitExceptionHandler(log: Logger) = CoroutineExceptionHandler { _, throwable -> 7 | throwable.printStackTrace() 8 | log.e(throwable = throwable) { "Error in MainScope" } 9 | } 10 | -------------------------------------------------------------------------------- /shared/src/iosMain/kotlin/co/touchlab/kampkit/KoinIOS.kt: -------------------------------------------------------------------------------- 1 | package co.touchlab.kampkit 2 | 3 | import app.cash.sqldelight.db.SqlDriver 4 | import app.cash.sqldelight.driver.native.NativeSqliteDriver 5 | import co.touchlab.kampkit.db.KaMPKitDb 6 | import co.touchlab.kampkit.models.BreedViewModel 7 | import co.touchlab.kermit.Logger 8 | import com.russhwolf.settings.NSUserDefaultsSettings 9 | import com.russhwolf.settings.Settings 10 | import io.ktor.client.engine.darwin.Darwin 11 | import org.koin.core.Koin 12 | import org.koin.core.KoinApplication 13 | import org.koin.core.component.KoinComponent 14 | import org.koin.core.parameter.parametersOf 15 | import org.koin.dsl.module 16 | import platform.Foundation.NSUserDefaults 17 | 18 | fun initKoinIos( 19 | userDefaults: NSUserDefaults, 20 | appInfo: AppInfo, 21 | doOnStartup: () -> Unit 22 | ): KoinApplication = initKoin( 23 | module { 24 | single { NSUserDefaultsSettings(userDefaults) } 25 | single { appInfo } 26 | single { doOnStartup } 27 | } 28 | ) 29 | 30 | actual val platformModule = module { 31 | single { NativeSqliteDriver(KaMPKitDb.Schema, "KampkitDb") } 32 | 33 | single { Darwin.create() } 34 | 35 | single { BreedViewModel(get(), getWith("BreedViewModel")) } 36 | } 37 | 38 | // Access from Swift to create a logger 39 | @Suppress("unused") 40 | fun Koin.loggerWithTag(tag: String) = get(qualifier = null) { parametersOf(tag) } 41 | 42 | @Suppress("unused") // Called from Swift 43 | object KotlinDependencies : KoinComponent { 44 | fun getBreedViewModel() = getKoin().get() 45 | } 46 | -------------------------------------------------------------------------------- /shared/src/iosMain/kotlin/co/touchlab/kampkit/models/ViewModel.kt: -------------------------------------------------------------------------------- 1 | package co.touchlab.kampkit.models 2 | 3 | /** 4 | * Base class that provides a Kotlin/Native equivalent to the AndroidX `ViewModel`. 5 | */ 6 | actual abstract class ViewModel actual constructor() { 7 | 8 | /** 9 | * Override this to do any cleanup immediately before the internal [CoroutineScope][kotlinx.coroutines.CoroutineScope] 10 | * is cancelled. 11 | */ 12 | protected actual open fun onCleared() { 13 | } 14 | 15 | fun clear() { 16 | onCleared() 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /shared/src/iosTest/kotlin/co/touchlab/kampkit/KoinTest.kt: -------------------------------------------------------------------------------- 1 | package co.touchlab.kampkit 2 | 3 | import co.touchlab.kermit.Logger 4 | import kotlin.test.AfterTest 5 | import kotlin.test.Test 6 | import org.koin.core.context.stopKoin 7 | import org.koin.core.parameter.parametersOf 8 | import org.koin.test.check.checkModules 9 | import platform.Foundation.NSUserDefaults 10 | 11 | class KoinTest { 12 | @Test 13 | fun checkAllModules() { 14 | initKoinIos( 15 | userDefaults = NSUserDefaults.standardUserDefaults, 16 | appInfo = TestAppInfo, 17 | doOnStartup = { } 18 | ).checkModules { 19 | withParameters { parametersOf("TestTag") } 20 | } 21 | } 22 | 23 | @AfterTest 24 | fun breakdown() { 25 | stopKoin() 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /shared/src/iosTest/kotlin/co/touchlab/kampkit/TestUtilIOS.kt: -------------------------------------------------------------------------------- 1 | package co.touchlab.kampkit 2 | 3 | import app.cash.sqldelight.db.SqlDriver 4 | import app.cash.sqldelight.driver.native.inMemoryDriver 5 | import co.touchlab.kampkit.db.KaMPKitDb 6 | 7 | internal actual fun testDbConnection(): SqlDriver { 8 | return inMemoryDriver(KaMPKitDb.Schema) 9 | } 10 | -------------------------------------------------------------------------------- /tl2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/touchlab/KaMPKit/4e430ed74e4ecf38ea036dbccaccc3442c13df47/tl2.png --------------------------------------------------------------------------------