├── .gitignore ├── README.md ├── build.gradle.kts ├── composeApp ├── build.gradle.kts ├── schemas │ └── data.db.AppDatabase │ │ └── 1.json └── src │ ├── androidMain │ ├── AndroidManifest.xml │ ├── kotlin │ │ ├── com │ │ │ └── monstarlab │ │ │ │ └── kmp │ │ │ │ ├── AndroidApp.kt │ │ │ │ └── MainActivity.kt │ │ ├── core │ │ │ └── ContextProvider.kt │ │ ├── data │ │ │ └── preference │ │ │ │ └── Preference.android.kt │ │ ├── di │ │ │ └── modules │ │ │ │ └── PlatformModule.kt │ │ └── platform │ │ │ └── Platform.android.kt │ └── res │ │ ├── drawable-v24 │ │ └── ic_launcher_foreground.xml │ │ ├── drawable │ │ └── 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 │ │ └── strings.xml │ ├── commonMain │ ├── composeResources │ │ └── drawable │ │ │ └── compose-multiplatform.xml │ ├── kotlin │ │ ├── data │ │ │ ├── db │ │ │ │ ├── AppDatabase.kt │ │ │ │ ├── DataBaseConstants.kt │ │ │ │ ├── ProductDao.kt │ │ │ │ ├── ProductDataSource.kt │ │ │ │ └── ProductDatabaseDataSource.kt │ │ │ ├── network │ │ │ │ ├── ApiDefinition.kt │ │ │ │ ├── ApiService.kt │ │ │ │ ├── ApiServiceImpl.kt │ │ │ │ ├── NetworkConstants.kt │ │ │ │ ├── errorhandling │ │ │ │ │ └── ApiErrorInterceptor.kt │ │ │ │ ├── requests │ │ │ │ │ └── LoginRequest.kt │ │ │ │ └── responses │ │ │ │ │ ├── AuthResponse.kt │ │ │ │ │ └── ProductsResponse.kt │ │ │ ├── preference │ │ │ │ ├── ListDataStorePreferences.kt │ │ │ │ ├── Preference.kt │ │ │ │ └── SingleDataStorePreferences.kt │ │ │ └── repository │ │ │ │ ├── AuthRepositoryImpl.kt │ │ │ │ ├── FavoriteRepositoryImpl.kt │ │ │ │ ├── ProductPagingSource.kt │ │ │ │ └── ProductRepositoryImpl.kt │ │ ├── di │ │ │ ├── KoinCommon.kt │ │ │ └── modules │ │ │ │ ├── DataBaseModule.kt │ │ │ │ ├── DispatcherModule.kt │ │ │ │ ├── NetworkModule.kt │ │ │ │ ├── PlatformModule.kt │ │ │ │ ├── RepositoryModule.kt │ │ │ │ ├── UseCaseModule.kt │ │ │ │ └── ViewModelsModule.kt │ │ ├── domain │ │ │ ├── model │ │ │ │ └── ProductList.kt │ │ │ ├── repository │ │ │ │ ├── AuthRepository.kt │ │ │ │ ├── FavoriteRepository.kt │ │ │ │ └── ProductRepository.kt │ │ │ └── usecase │ │ │ │ ├── GetUserFromPreferenceUseCase.kt │ │ │ │ ├── LoginUseCase.kt │ │ │ │ ├── favorite │ │ │ │ ├── AddToFavoriteUseCase.kt │ │ │ │ ├── GetFavoritesUseCase.kt │ │ │ │ └── RemoveFromFavoriteUseCase.kt │ │ │ │ └── product │ │ │ │ ├── GetPaginatedProductsUseCase.kt │ │ │ │ └── GetProductsUseCase.kt │ │ ├── error │ │ │ ├── ApiErrorException.kt │ │ │ └── ApiErrorValidator.kt │ │ ├── platform │ │ │ ├── Greeting.kt │ │ │ └── Platform.kt │ │ └── presentation │ │ │ ├── App.kt │ │ │ ├── components │ │ │ ├── appbutton │ │ │ │ ├── AppButton.kt │ │ │ │ └── AppButtonType.kt │ │ │ └── apptextfield │ │ │ │ └── AppTextField.kt │ │ │ ├── feature │ │ │ ├── home │ │ │ │ ├── HomeContract.kt │ │ │ │ ├── HomeScreen.kt │ │ │ │ ├── HomeScreenRoute.kt │ │ │ │ ├── HomeViewModel.kt │ │ │ │ ├── HomeViewModelProvider.kt │ │ │ │ └── ProductItemView.kt │ │ │ ├── login │ │ │ │ ├── LoginContract.kt │ │ │ │ ├── LoginScreen.kt │ │ │ │ ├── LoginScreenRoute.kt │ │ │ │ ├── LoginViewModel.kt │ │ │ │ └── LoginViewModelProvider.kt │ │ │ ├── pagination │ │ │ │ ├── PaginatedHomeScreen.kt │ │ │ │ └── PaginatedHomeViewModel.kt │ │ │ └── splash │ │ │ │ ├── SplashContract.kt │ │ │ │ ├── SplashScreen.kt │ │ │ │ ├── SplashScreenRoute.kt │ │ │ │ ├── SplashViewModel.kt │ │ │ │ └── SplashViewModelProvider.kt │ │ │ ├── model │ │ │ └── ErrorModel.kt │ │ │ └── theme │ │ │ ├── AppTheme.kt │ │ │ ├── Colors.kt │ │ │ ├── Shapes.kt │ │ │ ├── dimensions │ │ │ ├── Dimensions.kt │ │ │ └── DimensionsProvider.kt │ │ │ └── typography │ │ │ ├── AppTypography.kt │ │ │ ├── AppTypographyProvider.kt │ │ │ ├── FontFamily.kt │ │ │ └── Typography.kt │ └── sqldelight │ │ └── com │ │ └── monstarlab │ │ └── kmp │ │ └── Product.sq │ ├── commonTest │ └── kotlin │ │ ├── domain │ │ └── usecase │ │ │ ├── GetProductsUseCaseTest.kt │ │ │ ├── LoginUseCaseTest.kt │ │ │ └── favorite │ │ │ ├── AddToFavoriteUseCaseTest.kt │ │ │ ├── GetFavoritesUseCaseTest.kt │ │ │ └── RemoveFromFavoriteUseCaseTest.kt │ │ └── fakes │ │ ├── FakeDataSource.kt │ │ ├── FakeFavoriteRepository.kt │ │ └── FakeProductRepository.kt │ └── iosMain │ └── kotlin │ ├── MainViewController.kt │ ├── data │ └── preference │ │ └── Preference.ios.kt │ ├── di │ └── modules │ │ └── PlatformModule.kt │ └── platform │ ├── Koin.kt │ └── Platform.ios.kt ├── gradle.properties ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── iosApp ├── Configuration │ └── Config.xcconfig ├── Podfile ├── Podfile.lock ├── iosApp.xcodeproj │ └── project.pbxproj └── iosApp │ ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ ├── Contents.json │ │ └── app-icon-1024.png │ └── Contents.json │ ├── Info.plist │ ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json │ ├── presentation │ ├── ContentView.swift │ ├── feature │ │ ├── HomeScreen.swift │ │ ├── LoginScreen.swift │ │ └── SplashScreen.swift │ └── iOSApp.swift │ └── system │ └── Koin.swift └── settings.gradle.kts /.gitignore: -------------------------------------------------------------------------------- 1 | .gradle 2 | **/build/ 3 | !src/**/build/ 4 | local.properties 5 | .idea 6 | .fleet 7 | .DS_Store 8 | captures 9 | .externalNativeBuild 10 | .cxx 11 | *.xcodeproj/* 12 | !*.xcodeproj/project.pbxproj 13 | !*.xcodeproj/xcshareddata/ 14 | !*.xcodeproj/project.xcworkspace/ 15 | !*.xcworkspace/contents.xcworkspacedata 16 | **/xcshareddata/WorkspaceSettings.xcsettings 17 | .fleet/settings.json 18 | 19 | # Built application files 20 | *.apk 21 | *.aar 22 | *.app 23 | *.ipa 24 | 25 | # Files for the ART/Dalvik VM 26 | *.dex 27 | 28 | # Java class files 29 | *.class 30 | 31 | # Generated files 32 | bin/ 33 | gen/ 34 | out/ 35 | build/ 36 | /*/build/ 37 | 38 | # Gradle files 39 | .gradle/ 40 | 41 | # Local configuration files 42 | /local.properties 43 | 44 | # IntelliJ/Android Studio 45 | .idea/ 46 | *.iml 47 | *.iws 48 | *.ipr 49 | *.ids 50 | *.idb 51 | *.icls 52 | *.idea_modules 53 | .idea/workspace.xml 54 | .idea/tasks.xml 55 | .idea/gradle.xml 56 | .idea/assetWizardSettings.xml 57 | .idea/dictionaries 58 | .idea/libraries 59 | .idea/caches 60 | 61 | # Keystore files 62 | *.jks 63 | *.keystore 64 | 65 | # OS generated files 66 | ehthumbs.db 67 | Icon? 68 | Thumbs.db 69 | 70 | # Kotlin Native 71 | *.klib 72 | .kotlin_metadata/ 73 | .klibs/ 74 | 75 | # Xcode 76 | .DS_Store* 77 | /.AppleDouble 78 | *.lproj/ 79 | *.pbxuser 80 | !default.pbxuser 81 | *.mode1v3 82 | !default.mode1v3 83 | *.mode2v3 84 | !default.mode2v3 85 | *.perspectivev3 86 | !default.perspectivev3 87 | xcuserdata 88 | *.xccheckout 89 | *.moved-aside 90 | DerivedData 91 | *.hmap 92 | *.xcworkspace 93 | !default.xcworkspace 94 | xcuserstate 95 | *.xcscheme 96 | 97 | # Swift Package Manager 98 | .build/ 99 | 100 | # CocoaPods 101 | iosApp/Pods/ 102 | iosApp/Podfile.lock 103 | iosApp/Podfile.private 104 | iosApp/Podfile.shared 105 | iosApp/Podfile.pods 106 | iosApp/Podfile.workspace 107 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Kotlin Multiplatform Project Setup 2 | 3 | ![kotlin-version](https://img.shields.io/badge/kotlin-1.9.23-blue) 4 | 5 | This Kotlin Multiplatform project targets Android and iOS using Compose Multiplatform. 6 | 7 |

8 | 9 | 10 |

11 | 12 | 13 | 💻 **Techstack** 14 | - [Kotlin Multiplatform](https://kotlinlang.org/docs/multiplatform.html) 15 | - [Compose Multiplatform](https://github.com/JetBrains/compose-multiplatform) for sharing UIs across multiple platforms with Kotlin 16 | - [Swift UI](https://developer.apple.com/xcode/swiftui/) (Optional) 17 | - 🗡️ [koin](https://github.com/InsertKoinIO/koin) for Dependency Injection 18 | - ☁️ [ktor](https://github.com/ktorio/ktor) for networking 19 | - 🔗 [Kotlin Serialization](https://kotlinlang.org/docs/serialization.html) as primary JSON (de)serialization tool 20 | - 🔄 [Kotlin Coroutines](https://kotlinlang.org/docs/coroutines-overview.html) for async operations 21 | - 📦 [Room (Kotlin Multiplatform)](https://developer.android.com/kotlin/multiplatform/room) for persistent SQL storage 22 | - 🗂 [DataStore (Kotlin Multiplatform)](https://developer.android.com/kotlin/multiplatform/datastore) for persistent NoSQL storage 23 | - 📱 Shared [ViewModel](https://developer.android.com/reference/androidx/lifecycle/ViewModel) by AndroidX 24 | - 🧭 [Voyager](https://voyager.adriel.cafe/navigation) for navigation 25 | - 📲 [SKIE](https://skie.touchlab.co/) - Swift Kotlin Interface Enhancer by Touchlab 26 | - 🖼 Coil for loading images 27 | - ✨ [Spotless](https://github.com/diffplug/spotless) for code-format control 28 | - 🔍 [Detekt](https://github.com/detekt/detekt) for static code analysis 29 | 30 | 📒 **Todo** 31 | - Jetpack Navigation 32 | - Deeplink Support 33 | - Build Logic 34 | 35 | ## Project Setup 36 | 37 | ### Android: 38 | 39 | 1. Open the project in Android Studio. 40 | 2. Wait for the build to finish. 41 | 3. Select `composeApp` from the `Run/Debug` Configurations. 42 | 4. Apply and run. 43 | 44 | ### iOS: 45 | 46 | 1. Navigate to the `KMP-Template/iosApp` directory. 47 | 2. For first-time setup: 48 | - Open terminal in that directory and run the command `pod install`. 49 | - It’ll generate the `iosApp.xcworkspace` file. 50 | 3. Open the `iosApp.xcworkspace` file in Xcode. 51 | 4. Choose the correct target for your device or simulator in the scheme chooser at the top of the window. 52 | 5. Build and run. 53 | 54 | ### iOS with Swift UI: 55 | 56 | If you want to run the iOS app with swift UI (not compose multiplatform) 57 | 1. Open the `iosApp.xcworkspace` file in Xcode. 58 | 2. Navigate to the `iosApp/presentation/ContentView` file in Xcode. 59 | 3. Inside the ContentView body, uncomment the line `SplashScreen()` and comment out the `ComposeView().ignoresSafeArea(.keyboard)`. 60 | 4. Build and run. 61 | 62 | ## Structure 63 | 64 | - `/composeApp`: Contains code shared across the Compose Multiplatform applications. 65 | - `commonMain`: Holds code that's common for all targets. 66 | - Platform-specific directories (e.g., `androidMain`, `iosMain`): Contain Kotlin code that will 67 | compile only for the designated platform. For iOS-specific Kotlin code, such as integration with 68 | Apple's CoreCrypto, uses `iosMain`. 69 | 70 | - `/iosApp`: Contains the iOS application. This is the necessary entry point for the iOS app, 71 | even when sharing UI with Compose Multiplatform. This is also where to add any SwiftUI code for 72 | your project. 73 | 74 | ## Architecture 75 | Template implements [Clean Architecture](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html) and follows [best practices](https://developer.android.com/topic/architecture) provided by Google with some tweaks here and there 76 | 77 | ### Presentation layer 78 | The Presentation layer or UI Layer is our Jetpack Compose UI screens and components, and ViewModels. The Presentation layer interacts with the Domain Layer where our business logic resides. 79 | 80 | ### Domain layer 81 | The domain layer contains the application's business logic. This layer only works with abstractions and as such it never knows about how different layers look like. It doesn't know about any Databases, APIs, or even any Frameworks. 82 | 83 | ### Data layer 84 | The data layer is where the actual interactions happen between different data sources. This layer “implements” parts of the Domain layer and communicates with the APIs, Databases, and other services and SDKs. 85 | 86 | 87 | ## Tools 88 | These are the tools used in the template, to improve the development, that you should be aware of: 89 | 90 | ### [Spotless](https://github.com/diffplug/spotless) 91 | Spotless is a Gradle plugin used for consistent code formatting and style conventions across the project. You can use it running the following Gradle commands: 92 | 93 | `./gradlew spotlessCheck` - Checks the style formatting and displays any issues 94 | 95 | `./gradlew spotlessApply` - Same as above but automatically tries to fix most of the issues. If for any reason it can't, then a list of problems is displayed. 96 | 97 | ## Additional Resources 98 | 99 | For more information on Kotlin Multiplatform, visit 100 | the [official documentation](https://www.jetbrains.com/help/kotlin-multiplatform-dev/get-started.html). 101 | -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | import io.gitlab.arturbosch.detekt.extensions.DetektExtension 2 | 3 | plugins { 4 | // this is necessary to avoid the plugins to be loaded multiple times 5 | // in each subproject's classloader 6 | alias(libs.plugins.androidApplication) apply false 7 | alias(libs.plugins.androidLibrary) apply false 8 | alias(libs.plugins.jetbrainsCompose) apply false 9 | alias(libs.plugins.kotlinMultiplatform) apply false 10 | alias(libs.plugins.ksp) apply false 11 | alias(libs.plugins.detekt) apply false 12 | alias(libs.plugins.spotless) 13 | alias(libs.plugins.kotlin.dokka) 14 | alias(libs.plugins.skie) apply false 15 | } 16 | subprojects { 17 | apply(plugin = rootProject.libs.plugins.kotlin.dokka.get().pluginId) 18 | apply(plugin = rootProject.libs.plugins.detekt.get().pluginId) 19 | apply(plugin = rootProject.libs.plugins.spotless.get().pluginId) 20 | configure { 21 | buildUponDefaultConfig = true 22 | allRules = false 23 | config.setFrom("$rootDir/detekt.yml") 24 | } 25 | } 26 | 27 | spotless { 28 | kotlin { 29 | target("**/*.kt") 30 | ktlint() 31 | .editorConfigOverride( 32 | mapOf( 33 | "indent_size" to 4, 34 | "indent_style" to "space", 35 | "ij_kotlin_imports_layout" to "*,java.**,javax.**,kotlin.**,kotlinx.**,^", 36 | "ij_kotlin_allow_trailing_comma_on_call_site" to "true", 37 | "ij_kotlin_allow_trailing_comma" to "true", 38 | "ktlint_function_naming_ignore_when_annotated_with" to "Composable", 39 | "ktlint_code_style" to "android_studio", 40 | ) 41 | ) 42 | trimTrailingWhitespace() 43 | endWithNewline() 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /composeApp/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.kotlinMultiplatform) 3 | alias(libs.plugins.androidApplication) 4 | alias(libs.plugins.jetbrainsCompose) 5 | alias(libs.plugins.kotlinxSerialization) 6 | alias(libs.plugins.ksp) 7 | alias(libs.plugins.skie) 8 | alias(libs.plugins.room) 9 | } 10 | 11 | // Exclude viewmodel dep from other sources 12 | configurations.all { 13 | resolutionStrategy.force(libs.kmp.viewmodel) 14 | } 15 | 16 | kotlin { 17 | androidTarget { 18 | compilations.all { 19 | kotlinOptions { 20 | jvmTarget = "11" 21 | } 22 | } 23 | } 24 | 25 | listOf( 26 | iosX64(), 27 | iosArm64(), 28 | iosSimulatorArm64() 29 | ).forEach { iosTarget -> 30 | iosTarget.binaries.framework { 31 | baseName = "ComposeApp" 32 | isStatic = true 33 | } 34 | } 35 | 36 | sourceSets { 37 | androidMain.dependencies { 38 | implementation(libs.compose.ui.tooling.preview) 39 | implementation(libs.androidx.activity.compose) 40 | implementation(libs.ktor.client.okhttp) 41 | implementation(libs.bundles.paging.android) 42 | api(libs.androidx.startup) 43 | } 44 | iosMain.dependencies { 45 | implementation(libs.ktor.client.darwin) 46 | implementation(libs.paging.runtime.uikit) 47 | } 48 | commonMain.dependencies { 49 | implementation(compose.components.resources) 50 | implementation(compose.runtime) 51 | implementation(compose.foundation) 52 | implementation(compose.material) 53 | implementation(compose.ui) 54 | implementation(compose.materialIconsExtended) 55 | implementation(libs.bundles.voyager) 56 | implementation(libs.koin.core) 57 | implementation(libs.koin.compose) 58 | implementation(libs.bundles.ktor) 59 | implementation(libs.bundles.coil) 60 | implementation(libs.bundles.paging) 61 | implementation(libs.androidx.room.runtime) 62 | implementation(libs.sqlite.bundled) 63 | implementation(libs.androidx.lifecycle.viewmodel) 64 | implementation(libs.androidx.datastore.preferences.core) 65 | // It's a temporary fix for the issue https://github.com/cashapp/sqldelight/issues/4357 66 | // No need to move the dependency to version catalog 67 | implementation("co.touchlab:stately-common:2.0.5") 68 | } 69 | commonTest.dependencies { 70 | implementation(kotlin("test-junit")) 71 | implementation(kotlin("test-common")) 72 | implementation(kotlin("test-annotations-common")) 73 | implementation(libs.koin.test) 74 | implementation(libs.kotlinx.coroutines.test) 75 | implementation(libs.paging.common.test) 76 | } 77 | } 78 | 79 | task("testClasses") 80 | } 81 | 82 | android { 83 | namespace = "com.monstarlab.kmp" 84 | compileSdk = libs.versions.android.compileSdk.get().toInt() 85 | 86 | sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml") 87 | sourceSets["main"].res.srcDirs("src/androidMain/res") 88 | sourceSets["main"].resources.srcDirs("src/commonMain/resources") 89 | 90 | defaultConfig { 91 | applicationId = "com.monstarlab.kmp" 92 | minSdk = libs.versions.android.minSdk.get().toInt() 93 | targetSdk = libs.versions.android.targetSdk.get().toInt() 94 | versionCode = 1 95 | versionName = "1.0" 96 | } 97 | packaging { 98 | resources { 99 | excludes += "/META-INF/{AL2.0,LGPL2.1}" 100 | } 101 | } 102 | buildTypes { 103 | getByName("release") { 104 | isMinifyEnabled = false 105 | } 106 | } 107 | compileOptions { 108 | sourceCompatibility = JavaVersion.VERSION_11 109 | targetCompatibility = JavaVersion.VERSION_11 110 | } 111 | dependencies { 112 | debugImplementation(libs.compose.ui.tooling) 113 | } 114 | } 115 | 116 | kotlin.sourceSets.all { 117 | languageSettings { 118 | optIn("kotlin.experimental.ExperimentalObjCName") 119 | optIn("kotlinx.cinterop.ExperimentalForeignApi") 120 | } 121 | } 122 | 123 | dependencies { 124 | add("kspAndroid", libs.androidx.room.compiler) 125 | add("kspIosSimulatorArm64", libs.androidx.room.compiler) 126 | add("kspIosX64", libs.androidx.room.compiler) 127 | add("kspIosArm64", libs.androidx.room.compiler) 128 | } 129 | 130 | room { 131 | schemaDirectory("$projectDir/schemas") 132 | } 133 | -------------------------------------------------------------------------------- /composeApp/schemas/data.db.AppDatabase/1.json: -------------------------------------------------------------------------------- 1 | { 2 | "formatVersion": 1, 3 | "database": { 4 | "version": 1, 5 | "identityHash": "78ad7fd608406aacb12ae68cc4570894", 6 | "entities": [ 7 | { 8 | "tableName": "Product", 9 | "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `brand` TEXT NOT NULL, `category` TEXT NOT NULL, `description` TEXT NOT NULL, `discountPercentage` REAL NOT NULL, `price` INTEGER NOT NULL, `rating` REAL NOT NULL, `stock` INTEGER NOT NULL, `thumbnail` TEXT NOT NULL, `title` TEXT NOT NULL, PRIMARY KEY(`id`))", 10 | "fields": [ 11 | { 12 | "fieldPath": "id", 13 | "columnName": "id", 14 | "affinity": "INTEGER", 15 | "notNull": true 16 | }, 17 | { 18 | "fieldPath": "brand", 19 | "columnName": "brand", 20 | "affinity": "TEXT", 21 | "notNull": true 22 | }, 23 | { 24 | "fieldPath": "category", 25 | "columnName": "category", 26 | "affinity": "TEXT", 27 | "notNull": true 28 | }, 29 | { 30 | "fieldPath": "description", 31 | "columnName": "description", 32 | "affinity": "TEXT", 33 | "notNull": true 34 | }, 35 | { 36 | "fieldPath": "discountPercentage", 37 | "columnName": "discountPercentage", 38 | "affinity": "REAL", 39 | "notNull": true 40 | }, 41 | { 42 | "fieldPath": "price", 43 | "columnName": "price", 44 | "affinity": "INTEGER", 45 | "notNull": true 46 | }, 47 | { 48 | "fieldPath": "rating", 49 | "columnName": "rating", 50 | "affinity": "REAL", 51 | "notNull": true 52 | }, 53 | { 54 | "fieldPath": "stock", 55 | "columnName": "stock", 56 | "affinity": "INTEGER", 57 | "notNull": true 58 | }, 59 | { 60 | "fieldPath": "thumbnail", 61 | "columnName": "thumbnail", 62 | "affinity": "TEXT", 63 | "notNull": true 64 | }, 65 | { 66 | "fieldPath": "title", 67 | "columnName": "title", 68 | "affinity": "TEXT", 69 | "notNull": true 70 | } 71 | ], 72 | "primaryKey": { 73 | "autoGenerate": false, 74 | "columnNames": [ 75 | "id" 76 | ] 77 | } 78 | } 79 | ], 80 | "setupQueries": [ 81 | "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", 82 | "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '78ad7fd608406aacb12ae68cc4570894')" 83 | ] 84 | } 85 | } -------------------------------------------------------------------------------- /composeApp/src/androidMain/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 15 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 31 | 32 | 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/kotlin/com/monstarlab/kmp/AndroidApp.kt: -------------------------------------------------------------------------------- 1 | package com.monstarlab.kmp 2 | 3 | import android.app.Application 4 | import android.content.Context 5 | import di.initKoin 6 | import org.koin.dsl.module 7 | 8 | class AndroidApp : Application() { 9 | override fun onCreate() { 10 | super.onCreate() 11 | initKoin(appModule = module { single { this@AndroidApp } }) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/kotlin/com/monstarlab/kmp/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.monstarlab.kmp 2 | 3 | import android.os.Bundle 4 | import androidx.activity.ComponentActivity 5 | import androidx.activity.compose.setContent 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.tooling.preview.Preview 8 | import presentation.App 9 | 10 | class MainActivity : ComponentActivity() { 11 | override fun onCreate(savedInstanceState: Bundle?) { 12 | super.onCreate(savedInstanceState) 13 | 14 | setContent { 15 | App() 16 | } 17 | } 18 | } 19 | 20 | @Preview 21 | @Composable 22 | fun AppAndroidPreview() { 23 | App() 24 | } 25 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/kotlin/core/ContextProvider.kt: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import android.content.Context 4 | import androidx.startup.Initializer 5 | 6 | /** 7 | * Initializes and provides the application context to other components. 8 | */ 9 | internal lateinit var applicationContext: Context 10 | private set 11 | 12 | /** 13 | * Initializes the application context and marks completion of initialization. 14 | */ 15 | data object ContextProviderInitializer 16 | 17 | /** 18 | * Provides the application context during initialization. 19 | */ 20 | class ContextProvider : Initializer { 21 | 22 | /** 23 | * Sets the application context and completes initialization. 24 | */ 25 | override fun create(context: Context): ContextProviderInitializer { 26 | applicationContext = context.applicationContext 27 | return ContextProviderInitializer 28 | } 29 | 30 | /** 31 | * Specifies dependencies, which in this case is empty. 32 | */ 33 | override fun dependencies(): List>> = emptyList() 34 | } 35 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/kotlin/data/preference/Preference.android.kt: -------------------------------------------------------------------------------- 1 | package data.preference 2 | 3 | 4 | import androidx.datastore.core.DataStore 5 | import androidx.datastore.preferences.core.Preferences 6 | import core.applicationContext 7 | 8 | /** 9 | * Platform-specific function for creating a DataStore instance. 10 | * Creates a DataStore instance for Android platform. 11 | * 12 | * @return DataStore instance. 13 | */ 14 | 15 | internal actual fun createDataStore(): DataStore = createDataStore( 16 | producePath = { applicationContext.filesDir.resolve(DATA_STORE_FILE_NAME).absolutePath }, 17 | ) 18 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/kotlin/di/modules/PlatformModule.kt: -------------------------------------------------------------------------------- 1 | package di.modules 2 | 3 | import android.content.Context 4 | import androidx.room.Room 5 | import androidx.sqlite.driver.bundled.BundledSQLiteDriver 6 | import data.db.AppDatabase 7 | import data.db.DataBaseConstants.DATABASE_NAME 8 | import kotlinx.coroutines.Dispatchers 9 | import org.koin.dsl.module 10 | import java.io.File 11 | 12 | actual val platformModule = module { 13 | single { createRoomDatabase(get()) } 14 | } 15 | 16 | fun createRoomDatabase(ctx: Context): AppDatabase { 17 | val dbFile = ctx.getDatabasePath(DATABASE_NAME) 18 | return Room.databaseBuilder(ctx, dbFile.absolutePath) 19 | .setDriver(BundledSQLiteDriver()) 20 | .setQueryCoroutineContext(Dispatchers.IO) 21 | .build() 22 | } 23 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/kotlin/platform/Platform.android.kt: -------------------------------------------------------------------------------- 1 | package platform 2 | 3 | import android.os.Build 4 | 5 | class AndroidPlatform : Platform { 6 | override val name: String = "Android ${Build.VERSION.SDK_INT}" 7 | } 8 | 9 | actual fun getPlatform(): Platform = AndroidPlatform() 10 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 15 | 18 | 21 | 22 | 23 | 24 | 30 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/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 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ml-opensource/KMP-Template/b699e77be3ae49e37faf7b09848849939bc58d5f/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ml-opensource/KMP-Template/b699e77be3ae49e37faf7b09848849939bc58d5f/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ml-opensource/KMP-Template/b699e77be3ae49e37faf7b09848849939bc58d5f/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ml-opensource/KMP-Template/b699e77be3ae49e37faf7b09848849939bc58d5f/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ml-opensource/KMP-Template/b699e77be3ae49e37faf7b09848849939bc58d5f/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ml-opensource/KMP-Template/b699e77be3ae49e37faf7b09848849939bc58d5f/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ml-opensource/KMP-Template/b699e77be3ae49e37faf7b09848849939bc58d5f/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ml-opensource/KMP-Template/b699e77be3ae49e37faf7b09848849939bc58d5f/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ml-opensource/KMP-Template/b699e77be3ae49e37faf7b09848849939bc58d5f/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ml-opensource/KMP-Template/b699e77be3ae49e37faf7b09848849939bc58d5f/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | kmp-template 3 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/composeResources/drawable/compose-multiplatform.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 14 | 18 | 24 | 30 | 36 | 37 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/data/db/AppDatabase.kt: -------------------------------------------------------------------------------- 1 | package data.db 2 | 3 | import androidx.room.Database 4 | import androidx.room.RoomDatabase 5 | import domain.model.Product 6 | 7 | @Database(entities = [Product::class], version = 1) 8 | abstract class AppDatabase : RoomDatabase() { 9 | abstract fun productDao(): ProductDao 10 | } 11 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/data/db/DataBaseConstants.kt: -------------------------------------------------------------------------------- 1 | package data.db 2 | 3 | object DataBaseConstants { 4 | const val DATABASE_NAME = "products.db" 5 | } 6 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/data/db/ProductDao.kt: -------------------------------------------------------------------------------- 1 | package data.db 2 | 3 | import androidx.room.Dao 4 | import androidx.room.Insert 5 | import androidx.room.Query 6 | import androidx.room.Update 7 | import androidx.room.Delete 8 | import domain.model.Product 9 | 10 | @Dao 11 | interface ProductDao { 12 | 13 | @Insert 14 | suspend fun insert(product: Product) 15 | 16 | @Query("SELECT * FROM Product") 17 | suspend fun getAll(): List 18 | 19 | @Query("SELECT * FROM Product WHERE id = :id") 20 | suspend fun getProductById(id: Long): Product? 21 | 22 | @Update 23 | suspend fun update(product: Product) 24 | 25 | @Delete 26 | suspend fun delete(product: Product) 27 | } 28 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/data/db/ProductDataSource.kt: -------------------------------------------------------------------------------- 1 | package data.db 2 | 3 | import domain.model.Product 4 | 5 | interface ProductDataSource { 6 | suspend fun insertProduct(product: Product) 7 | 8 | suspend fun fetchAllProducts(): List 9 | 10 | suspend fun deleteProduct(product: Product) 11 | } 12 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/data/db/ProductDatabaseDataSource.kt: -------------------------------------------------------------------------------- 1 | package data.db 2 | 3 | 4 | import domain.model.Product 5 | import kotlinx.coroutines.CoroutineDispatcher 6 | import kotlinx.coroutines.withContext 7 | 8 | class ProductDatabaseDataSource( 9 | private val dbRef: AppDatabase, 10 | private val ioDispatcher: CoroutineDispatcher, 11 | ) : ProductDataSource { 12 | override suspend fun insertProduct(product: Product) = withContext(ioDispatcher) { 13 | dbRef.productDao().insert(product = product) 14 | } 15 | 16 | override suspend fun fetchAllProducts() = withContext(ioDispatcher) { 17 | dbRef.productDao().getAll() 18 | } 19 | 20 | override suspend fun deleteProduct(product: Product) = withContext(ioDispatcher) { 21 | dbRef.productDao().delete(product) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/data/network/ApiDefinition.kt: -------------------------------------------------------------------------------- 1 | package data.network 2 | 3 | sealed class ApiDefinition { 4 | sealed class ApiEndpoint(val path: String) { 5 | data object Login : ApiEndpoint(path = "auth/login") 6 | data object Products : ApiEndpoint(path = "products") 7 | } 8 | 9 | object ApiField { 10 | const val PARAM_LIMIT = "limit" 11 | const val PARAM_SKIP = "skip" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/data/network/ApiService.kt: -------------------------------------------------------------------------------- 1 | package data.network 2 | 3 | import data.network.requests.LoginRequest 4 | import io.ktor.client.statement.HttpResponse 5 | 6 | interface ApiService { 7 | suspend fun getProducts(limit: Int = 10, skip: Int = 0): HttpResponse 8 | 9 | suspend fun authenticate(loginRequest: LoginRequest): HttpResponse 10 | } 11 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/data/network/ApiServiceImpl.kt: -------------------------------------------------------------------------------- 1 | package data.network 2 | 3 | import data.network.ApiDefinition.ApiEndpoint.Login 4 | import data.network.ApiDefinition.ApiEndpoint.Products 5 | import data.network.ApiDefinition.ApiField.PARAM_LIMIT 6 | import data.network.ApiDefinition.ApiField.PARAM_SKIP 7 | import data.network.requests.LoginRequest 8 | import io.ktor.client.HttpClient 9 | import io.ktor.client.request.get 10 | import io.ktor.client.request.parameter 11 | import io.ktor.client.request.post 12 | import io.ktor.client.request.setBody 13 | import io.ktor.client.statement.HttpResponse 14 | import io.ktor.http.ContentType 15 | import io.ktor.http.contentType 16 | 17 | class ApiServiceImpl(private val httpClient: HttpClient) : ApiService { 18 | override suspend fun getProducts(limit: Int, skip: Int): HttpResponse = httpClient.get( 19 | Products.path, 20 | ) { 21 | parameter(PARAM_LIMIT, limit) 22 | parameter(PARAM_SKIP, skip) 23 | } 24 | 25 | override suspend fun authenticate(loginRequest: LoginRequest): HttpResponse { 26 | return httpClient.post(Login.path) { 27 | contentType(ContentType.Application.Json) 28 | setBody(loginRequest) 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/data/network/NetworkConstants.kt: -------------------------------------------------------------------------------- 1 | package data.network 2 | 3 | object NetworkConstants { 4 | const val BASE_URL = "dummyjson.com" 5 | } 6 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/data/network/errorhandling/ApiErrorInterceptor.kt: -------------------------------------------------------------------------------- 1 | package data.network.errorhandling 2 | 3 | import error.ApiErrorException 4 | import io.ktor.client.statement.HttpResponse 5 | import io.ktor.http.HttpStatusCode 6 | 7 | fun validateResponse(response: HttpResponse) { 8 | val statusCode = response.status.value 9 | val displayableMessage = response.status.toDisplayableMessage() 10 | val loggableMessage = "Error code: $statusCode, message: $displayableMessage" 11 | throw ApiErrorException(statusCode, displayableMessage, loggableMessage) 12 | } 13 | 14 | fun HttpStatusCode.toDisplayableMessage(): String { 15 | return when (this) { 16 | HttpStatusCode.Forbidden -> "Access denied" 17 | HttpStatusCode.BadRequest -> "Bad request" 18 | HttpStatusCode.Unauthorized -> "Unauthorized" 19 | HttpStatusCode.NotFound -> "Resource not found" 20 | HttpStatusCode.InternalServerError -> "Internal server error" 21 | HttpStatusCode.ServiceUnavailable -> "Service unavailable" 22 | HttpStatusCode.RequestTimeout -> "The request timed out" 23 | else -> "Unexpected status code: $value" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/data/network/requests/LoginRequest.kt: -------------------------------------------------------------------------------- 1 | package data.network.requests 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class LoginRequest(val username: String, val password: String) 7 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/data/network/responses/AuthResponse.kt: -------------------------------------------------------------------------------- 1 | package data.network.responses 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class AuthResponse( 7 | val id: Int, 8 | val token: String, 9 | ) 10 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/data/network/responses/ProductsResponse.kt: -------------------------------------------------------------------------------- 1 | package data.network.responses 2 | 3 | import domain.model.Product 4 | import domain.model.ProductList 5 | import kotlinx.serialization.Serializable 6 | 7 | @Serializable 8 | internal data class ProductsResponse( 9 | val limit: Int, 10 | val products: List, 11 | val skip: Int, 12 | val total: Int, 13 | ) 14 | 15 | @Serializable 16 | internal data class ProductDTO( 17 | val brand: String, 18 | val category: String, 19 | val description: String, 20 | val discountPercentage: Double, 21 | val id: Int, 22 | val price: Int, 23 | val rating: Double, 24 | val stock: Int, 25 | val thumbnail: String, 26 | val title: String, 27 | ) 28 | 29 | internal fun ProductDTO.toDomainModel() = Product( 30 | id, brand, category, description, discountPercentage, price, rating, stock, thumbnail, title, 31 | ) 32 | 33 | internal fun ProductsResponse.toDomainModel() = ProductList( 34 | limit, 35 | products.map { it.toDomainModel() }, 36 | skip, 37 | total, 38 | ) 39 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/data/preference/ListDataStorePreferences.kt: -------------------------------------------------------------------------------- 1 | package data.preference 2 | 3 | import androidx.datastore.core.DataStore 4 | import androidx.datastore.preferences.core.Preferences 5 | import androidx.datastore.preferences.core.edit 6 | import androidx.datastore.preferences.core.stringPreferencesKey 7 | import kotlinx.coroutines.flow.first 8 | import kotlinx.coroutines.flow.map 9 | import kotlinx.serialization.KSerializer 10 | import kotlinx.serialization.SerializationException 11 | import kotlinx.serialization.builtins.ListSerializer 12 | import kotlinx.serialization.json.Json 13 | 14 | interface ListDataSource { 15 | suspend fun getAll(): List 16 | suspend fun add(item: T) 17 | suspend fun addAll(items: List) 18 | suspend fun clear() 19 | } 20 | 21 | abstract class ListDataStorePreferences( 22 | private val dataStore: DataStore, 23 | private val serializer: KSerializer, 24 | ) : ListDataSource { 25 | 26 | private val key = stringPreferencesKey(this::class.simpleName ?: "") 27 | 28 | override suspend fun getAll(): List { 29 | return try { 30 | val json = dataStore.data.map { it[key] ?: "" }.first() 31 | Json.decodeFromString(ListSerializer(serializer), json) 32 | } catch (e: SerializationException) { 33 | emptyList() 34 | } 35 | } 36 | 37 | override suspend fun add(item: T) { 38 | val list = getAll().toMutableList() 39 | list.add(item) 40 | addAll(list) 41 | } 42 | 43 | override suspend fun addAll(items: List) { 44 | val json = Json.encodeToString(ListSerializer(serializer), items) 45 | dataStore.edit { 46 | it[key] = json 47 | } 48 | } 49 | 50 | override suspend fun clear() { 51 | dataStore.edit { 52 | it[key] = "" 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/data/preference/Preference.kt: -------------------------------------------------------------------------------- 1 | package data.preference 2 | 3 | import androidx.datastore.core.DataStore 4 | import androidx.datastore.preferences.core.PreferenceDataStoreFactory 5 | import androidx.datastore.preferences.core.Preferences 6 | import kotlinx.coroutines.CoroutineScope 7 | import kotlinx.coroutines.Dispatchers 8 | import kotlinx.coroutines.SupervisorJob 9 | import okio.Path.Companion.toPath 10 | 11 | internal const val DATA_STORE_FILE_NAME = "theme.preferences_pb" 12 | 13 | /** 14 | * Creates a DataStore instance for storing and accessing preferences. 15 | * Uses the PreferenceDataStoreFactory to create the DataStore. 16 | * 17 | * @param producePath Function that produces the path where the DataStore file will be stored. 18 | * @return DataStore instance. 19 | */ 20 | fun createDataStore(producePath: () -> String): DataStore { 21 | return PreferenceDataStoreFactory.createWithPath( 22 | corruptionHandler = null, 23 | migrations = emptyList(), 24 | // Coroutine scope for IO operations 25 | scope = CoroutineScope(Dispatchers.Default + SupervisorJob()), 26 | // Produces the file path for the DataStore 27 | produceFile = { producePath().toPath() }, 28 | ) 29 | } 30 | 31 | internal expect fun createDataStore(): DataStore 32 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/data/preference/SingleDataStorePreferences.kt: -------------------------------------------------------------------------------- 1 | package data.preference 2 | 3 | import androidx.datastore.core.DataStore 4 | import androidx.datastore.preferences.core.Preferences 5 | import androidx.datastore.preferences.core.edit 6 | import androidx.datastore.preferences.core.stringPreferencesKey 7 | import kotlinx.coroutines.flow.first 8 | import kotlinx.coroutines.flow.map 9 | import kotlinx.serialization.KSerializer 10 | import kotlinx.serialization.SerializationException 11 | import kotlinx.serialization.json.Json 12 | 13 | interface SingleDataSource { 14 | suspend fun get(): T? 15 | suspend fun add(item: T) 16 | suspend fun clear() 17 | } 18 | 19 | abstract class SingleDataStorePreferences( 20 | private val dataStore: DataStore, 21 | private val serializer: KSerializer, 22 | ) : SingleDataSource { 23 | private val key = stringPreferencesKey(this::class.simpleName ?: "") 24 | 25 | override suspend fun get(): T? { 26 | return try { 27 | val json = dataStore.data.map { it[key] ?: "" }.first() 28 | val entries = Json.decodeFromString(serializer, json) 29 | entries 30 | } catch (e: SerializationException) { 31 | null 32 | } 33 | } 34 | 35 | override suspend fun add(item: T) { 36 | try { 37 | val json = Json.encodeToString(serializer, item) 38 | dataStore.edit { 39 | it[key] = json 40 | } 41 | } catch (e: SerializationException) { 42 | print("$e") 43 | } 44 | } 45 | 46 | override suspend fun clear() { 47 | dataStore.edit { it[key] = "" } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/data/repository/AuthRepositoryImpl.kt: -------------------------------------------------------------------------------- 1 | package data.repository 2 | 3 | import data.network.ApiService 4 | import data.network.requests.LoginRequest 5 | import data.network.responses.AuthResponse 6 | import domain.repository.AuthRepository 7 | import io.ktor.client.call.body 8 | 9 | class AuthRepositoryImpl(private val apiService: ApiService) : AuthRepository { 10 | override suspend fun authenticate(loginRequest: LoginRequest): AuthResponse { 11 | return apiService.authenticate(loginRequest).body() 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/data/repository/FavoriteRepositoryImpl.kt: -------------------------------------------------------------------------------- 1 | package data.repository 2 | 3 | import data.db.ProductDataSource 4 | import domain.model.Product 5 | import domain.repository.FavoriteRepository 6 | 7 | class FavoriteRepositoryImpl(private val productDataSource: ProductDataSource) : 8 | FavoriteRepository { 9 | override suspend fun getFavorites(): List = productDataSource.fetchAllProducts() 10 | 11 | override suspend fun addToFavorite(product: Product) = productDataSource.insertProduct(product) 12 | 13 | override suspend fun removeFromFavorite(product: Product) = 14 | productDataSource.deleteProduct(product) 15 | } 16 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/data/repository/ProductPagingSource.kt: -------------------------------------------------------------------------------- 1 | package data.repository 2 | 3 | import app.cash.paging.PagingSource 4 | import app.cash.paging.PagingSourceLoadParams 5 | import app.cash.paging.PagingSourceLoadResult 6 | import app.cash.paging.PagingSourceLoadResultError 7 | import app.cash.paging.PagingSourceLoadResultPage 8 | import app.cash.paging.PagingState 9 | import data.network.ApiService 10 | import data.network.responses.ProductsResponse 11 | import data.network.responses.toDomainModel 12 | import domain.model.Product 13 | import io.ktor.client.call.body 14 | import io.ktor.http.isSuccess 15 | 16 | class ProductPagingSource(private val apiService: ApiService) : 17 | PagingSource() { 18 | 19 | override suspend fun load( 20 | params: PagingSourceLoadParams 21 | ): PagingSourceLoadResult { 22 | val page = params.key ?: FIRST_PAGE_INDEX 23 | val httpResponse = apiService.getProducts(skip = page * 10) 24 | return when { 25 | httpResponse.status.isSuccess() -> { 26 | val productList = httpResponse.body().toDomainModel() 27 | PagingSourceLoadResultPage( 28 | data = productList.products, 29 | prevKey = (page - 1).takeIf { it >= FIRST_PAGE_INDEX }, 30 | nextKey = if (productList.products.isNotEmpty()) page + 1 else null, 31 | ) 32 | } 33 | 34 | else -> { 35 | PagingSourceLoadResultError(Exception("Received a ${httpResponse.status}.")) 36 | } 37 | } 38 | } 39 | 40 | override fun getRefreshKey(state: PagingState): Int? = null 41 | 42 | companion object { 43 | const val FIRST_PAGE_INDEX = 0 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/data/repository/ProductRepositoryImpl.kt: -------------------------------------------------------------------------------- 1 | package data.repository 2 | 3 | import data.network.ApiService 4 | import data.network.responses.ProductsResponse 5 | import data.network.responses.toDomainModel 6 | import domain.repository.ProductRepository 7 | import io.ktor.client.call.body 8 | 9 | class ProductRepositoryImpl(private val apiService: ApiService) : ProductRepository { 10 | override suspend fun getProducts() = 11 | apiService.getProducts().body().toDomainModel() 12 | } 13 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/di/KoinCommon.kt: -------------------------------------------------------------------------------- 1 | package di 2 | 3 | import di.modules.dataPersistenceModule 4 | import di.modules.dispatcherModule 5 | import di.modules.networkModule 6 | import di.modules.platformModule 7 | import di.modules.repositoryModule 8 | import di.modules.useCaseModule 9 | import di.modules.viewModelsModule 10 | import org.koin.core.KoinApplication 11 | import org.koin.core.context.startKoin 12 | import org.koin.core.module.Module 13 | import org.koin.dsl.module 14 | 15 | fun initKoin(appModule: Module = module { }): KoinApplication = startKoin { 16 | modules( 17 | appModule, 18 | dispatcherModule, 19 | networkModule, 20 | dataPersistenceModule, 21 | repositoryModule, 22 | useCaseModule, 23 | platformModule, 24 | viewModelsModule, 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/di/modules/DataBaseModule.kt: -------------------------------------------------------------------------------- 1 | package di.modules 2 | 3 | import data.db.ProductDataSource 4 | import data.db.ProductDatabaseDataSource 5 | import data.preference.createDataStore 6 | import org.koin.core.qualifier.named 7 | import org.koin.dsl.module 8 | 9 | val dataPersistenceModule = module { 10 | single { ProductDatabaseDataSource(get(), get(named(Dispatcher.IO))) } 11 | single { createDataStore() } 12 | } 13 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/di/modules/DispatcherModule.kt: -------------------------------------------------------------------------------- 1 | package di.modules 2 | 3 | import kotlinx.coroutines.Dispatchers 4 | import kotlinx.coroutines.IO 5 | import org.koin.core.qualifier.named 6 | import org.koin.dsl.module 7 | 8 | val dispatcherModule = module { 9 | single(named(Dispatcher.IO)) { Dispatchers.IO } 10 | single(named(Dispatcher.MAIN)) { Dispatchers.Main } 11 | single(named(Dispatcher.DEFAULT)) { Dispatchers.Default } 12 | single(named(Dispatcher.UNCONFINED)) { Dispatchers.Unconfined } 13 | } 14 | 15 | enum class Dispatcher { 16 | IO, 17 | MAIN, 18 | DEFAULT, 19 | UNCONFINED, 20 | } 21 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/di/modules/NetworkModule.kt: -------------------------------------------------------------------------------- 1 | package di.modules 2 | 3 | import data.network.ApiService 4 | import data.network.ApiServiceImpl 5 | import data.network.NetworkConstants.BASE_URL 6 | import io.ktor.client.HttpClient 7 | import io.ktor.client.plugins.contentnegotiation.ContentNegotiation 8 | import io.ktor.client.plugins.defaultRequest 9 | import io.ktor.client.plugins.logging.LogLevel 10 | import io.ktor.client.plugins.logging.Logger 11 | import io.ktor.client.plugins.logging.Logging 12 | import io.ktor.client.plugins.logging.SIMPLE 13 | import io.ktor.client.request.header 14 | import io.ktor.http.ContentType 15 | import io.ktor.http.HttpHeaders 16 | import io.ktor.http.URLProtocol 17 | import io.ktor.serialization.kotlinx.json.json 18 | import kotlinx.serialization.json.Json 19 | import org.koin.dsl.module 20 | 21 | val networkModule = module { 22 | single { 23 | HttpClient { 24 | defaultRequest { 25 | url { 26 | protocol = URLProtocol.HTTPS 27 | host = BASE_URL 28 | } 29 | header(HttpHeaders.ContentType, ContentType.Application.Json) 30 | } 31 | install(Logging) { 32 | logger = Logger.SIMPLE 33 | level = LogLevel.ALL 34 | } 35 | install(ContentNegotiation) { 36 | json( 37 | Json { 38 | isLenient = true 39 | ignoreUnknownKeys = true 40 | }, 41 | ) 42 | } 43 | } 44 | } 45 | single { ApiServiceImpl(get()) } 46 | } 47 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/di/modules/PlatformModule.kt: -------------------------------------------------------------------------------- 1 | package di.modules 2 | 3 | import org.koin.core.module.Module 4 | 5 | expect val platformModule: Module 6 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/di/modules/RepositoryModule.kt: -------------------------------------------------------------------------------- 1 | package di.modules 2 | 3 | import data.repository.AuthRepositoryImpl 4 | import data.repository.FavoriteRepositoryImpl 5 | import data.repository.ProductPagingSource 6 | import data.repository.ProductRepositoryImpl 7 | import domain.repository.AuthRepository 8 | import domain.repository.FavoriteRepository 9 | import domain.repository.ProductRepository 10 | import org.koin.dsl.module 11 | 12 | val repositoryModule = module { 13 | factory { ProductPagingSource(get()) } 14 | factory { ProductRepositoryImpl(get()) } 15 | factory { AuthRepositoryImpl(get()) } 16 | factory { FavoriteRepositoryImpl(get()) } 17 | } 18 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/di/modules/UseCaseModule.kt: -------------------------------------------------------------------------------- 1 | package di.modules 2 | 3 | import domain.usecase.GetUserFromPreferenceUseCase 4 | import domain.usecase.LoginUseCase 5 | import domain.usecase.favorite.AddToFavoriteUseCase 6 | import domain.usecase.favorite.GetFavoritesUseCase 7 | import domain.usecase.favorite.RemoveFromFavoriteUseCase 8 | import domain.usecase.product.GetPaginatedProductsUseCase 9 | import domain.usecase.product.GetProductsUseCase 10 | import org.koin.dsl.module 11 | 12 | val useCaseModule = module { 13 | factory { GetPaginatedProductsUseCase(get()) } 14 | factory { GetProductsUseCase(get()) } 15 | factory { GetFavoritesUseCase(get()) } 16 | factory { AddToFavoriteUseCase(get()) } 17 | factory { RemoveFromFavoriteUseCase(get()) } 18 | factory { LoginUseCase(get()) } 19 | factory { GetUserFromPreferenceUseCase(get()) } 20 | } 21 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/di/modules/ViewModelsModule.kt: -------------------------------------------------------------------------------- 1 | package di.modules 2 | 3 | import org.koin.dsl.module 4 | import presentation.feature.home.HomeViewModel 5 | import presentation.feature.login.LoginViewModel 6 | import presentation.feature.pagination.PaginatedHomeViewModel 7 | import presentation.feature.splash.SplashViewModel 8 | 9 | val viewModelsModule = module { 10 | factory { SplashViewModel(get()) } 11 | factory { LoginViewModel(get(), get()) } 12 | factory { HomeViewModel(get(), get(), get(), get()) } 13 | factory { PaginatedHomeViewModel(get()) } 14 | } 15 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/domain/model/ProductList.kt: -------------------------------------------------------------------------------- 1 | package domain.model 2 | 3 | import androidx.room.Entity 4 | import androidx.room.ForeignKey 5 | import androidx.room.PrimaryKey 6 | 7 | data class ProductList( 8 | val limit: Int, 9 | val products: List, 10 | val skip: Int, 11 | val total: Int, 12 | ) 13 | 14 | @Entity(tableName = "Product") 15 | data class Product( 16 | @PrimaryKey val id: Int, 17 | val brand: String, 18 | val category: String, 19 | val description: String, 20 | val discountPercentage: Double, 21 | val price: Int, 22 | val rating: Double, 23 | val stock: Int, 24 | val thumbnail: String, 25 | val title: String, 26 | ) 27 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/domain/repository/AuthRepository.kt: -------------------------------------------------------------------------------- 1 | package domain.repository 2 | 3 | import data.network.requests.LoginRequest 4 | import data.network.responses.AuthResponse 5 | 6 | interface AuthRepository { 7 | suspend fun authenticate(loginRequest: LoginRequest): AuthResponse 8 | } 9 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/domain/repository/FavoriteRepository.kt: -------------------------------------------------------------------------------- 1 | package domain.repository 2 | 3 | import domain.model.Product 4 | 5 | interface FavoriteRepository { 6 | suspend fun getFavorites(): List 7 | 8 | suspend fun addToFavorite(product: Product) 9 | 10 | suspend fun removeFromFavorite(product: Product) 11 | } 12 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/domain/repository/ProductRepository.kt: -------------------------------------------------------------------------------- 1 | package domain.repository 2 | 3 | import domain.model.ProductList 4 | 5 | interface ProductRepository { 6 | suspend fun getProducts(): ProductList 7 | } 8 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/domain/usecase/GetUserFromPreferenceUseCase.kt: -------------------------------------------------------------------------------- 1 | package domain.usecase 2 | 3 | import androidx.datastore.core.DataStore 4 | import androidx.datastore.preferences.core.Preferences 5 | import data.preference.SingleDataStorePreferences 6 | import kotlinx.serialization.Serializable 7 | 8 | @Serializable 9 | data class User(val name: String, val email: String) 10 | class GetUserFromPreferenceUseCase(dataStore: DataStore) : 11 | SingleDataStorePreferences(dataStore, User.serializer()) 12 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/domain/usecase/LoginUseCase.kt: -------------------------------------------------------------------------------- 1 | package domain.usecase 2 | 3 | import data.network.requests.LoginRequest 4 | import domain.repository.AuthRepository 5 | 6 | class LoginUseCase( 7 | private val authRepository: AuthRepository, 8 | ) { 9 | 10 | suspend operator fun invoke(loginRequest: LoginRequest) = runCatching { 11 | authRepository.authenticate(loginRequest) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/domain/usecase/favorite/AddToFavoriteUseCase.kt: -------------------------------------------------------------------------------- 1 | package domain.usecase.favorite 2 | 3 | import domain.model.Product 4 | import domain.repository.FavoriteRepository 5 | 6 | class AddToFavoriteUseCase(private val repository: FavoriteRepository) { 7 | 8 | suspend operator fun invoke(product: Product) = 9 | runCatching { repository.addToFavorite(product) } 10 | } 11 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/domain/usecase/favorite/GetFavoritesUseCase.kt: -------------------------------------------------------------------------------- 1 | package domain.usecase.favorite 2 | 3 | import domain.repository.FavoriteRepository 4 | 5 | class GetFavoritesUseCase(private val repository: FavoriteRepository) { 6 | 7 | suspend operator fun invoke() = runCatching { repository.getFavorites() } 8 | } 9 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/domain/usecase/favorite/RemoveFromFavoriteUseCase.kt: -------------------------------------------------------------------------------- 1 | package domain.usecase.favorite 2 | 3 | import domain.model.Product 4 | import domain.repository.FavoriteRepository 5 | 6 | class RemoveFromFavoriteUseCase(private val repository: FavoriteRepository) { 7 | 8 | suspend operator fun invoke(product: Product) = 9 | runCatching { repository.removeFromFavorite(product) } 10 | } 11 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/domain/usecase/product/GetPaginatedProductsUseCase.kt: -------------------------------------------------------------------------------- 1 | package domain.usecase.product 2 | 3 | import app.cash.paging.Pager 4 | import app.cash.paging.PagingConfig 5 | import data.repository.ProductPagingSource 6 | import domain.model.Product 7 | 8 | class GetPaginatedProductsUseCase(private val pagingSource: ProductPagingSource) { 9 | private val pager: Pager = run { 10 | val pagingConfig = PagingConfig(pageSize = 10, initialLoadSize = 10) 11 | check(pagingConfig.pageSize == pagingConfig.initialLoadSize) { 12 | "An elegant PagingSource implementation requires each page to be of equal size." 13 | } 14 | Pager(pagingConfig) { pagingSource } 15 | } 16 | 17 | operator fun invoke() = pager.flow 18 | } 19 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/domain/usecase/product/GetProductsUseCase.kt: -------------------------------------------------------------------------------- 1 | package domain.usecase.product 2 | 3 | 4 | import domain.repository.ProductRepository 5 | 6 | class GetProductsUseCase(private val repository: ProductRepository) { 7 | 8 | suspend operator fun invoke() = runCatching { repository.getProducts() } 9 | } 10 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/error/ApiErrorException.kt: -------------------------------------------------------------------------------- 1 | package error 2 | 3 | import io.ktor.utils.io.errors.IOException 4 | 5 | class ApiErrorException( 6 | val code: Int, 7 | val displayableMessage: String, 8 | val loggableMessage: String, 9 | ) : IOException(displayableMessage) 10 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/error/ApiErrorValidator.kt: -------------------------------------------------------------------------------- 1 | package error 2 | 3 | import io.ktor.client.statement.HttpResponse 4 | 5 | interface ApiErrorValidator { 6 | fun validateResponse(response: HttpResponse) 7 | } 8 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/platform/Greeting.kt: -------------------------------------------------------------------------------- 1 | package platform 2 | 3 | class Greeting { 4 | private val platform = getPlatform() 5 | 6 | fun greet(): String { 7 | return "Hello, ${platform.name}!" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/platform/Platform.kt: -------------------------------------------------------------------------------- 1 | package platform 2 | 3 | interface Platform { 4 | val name: String 5 | } 6 | 7 | expect fun getPlatform(): Platform 8 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/presentation/App.kt: -------------------------------------------------------------------------------- 1 | package presentation 2 | 3 | import androidx.compose.runtime.Composable 4 | import cafe.adriel.voyager.navigator.Navigator 5 | import cafe.adriel.voyager.transitions.SlideTransition 6 | import presentation.feature.splash.SplashScreenRoute 7 | import presentation.theme.AppTheme 8 | 9 | @Composable 10 | fun App() { 11 | AppTheme { 12 | Navigator(SplashScreenRoute) { SlideTransition(it) } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/presentation/components/appbutton/AppButton.kt: -------------------------------------------------------------------------------- 1 | package presentation.components.appbutton 2 | 3 | import androidx.compose.animation.Crossfade 4 | import androidx.compose.foundation.BorderStroke 5 | import androidx.compose.foundation.layout.PaddingValues 6 | import androidx.compose.foundation.layout.RowScope 7 | import androidx.compose.foundation.layout.size 8 | import androidx.compose.material.Button 9 | import androidx.compose.material.ButtonDefaults 10 | import androidx.compose.material.CircularProgressIndicator 11 | import androidx.compose.material.OutlinedButton 12 | import androidx.compose.material.Text 13 | import androidx.compose.material.TextButton 14 | import androidx.compose.runtime.Composable 15 | import androidx.compose.ui.Modifier 16 | import androidx.compose.ui.graphics.Color 17 | import androidx.compose.ui.unit.dp 18 | import presentation.theme.Theme 19 | 20 | @Composable 21 | fun AppButton( 22 | text: String, 23 | modifier: Modifier = Modifier, 24 | enabled: Boolean = true, 25 | isLoading: Boolean = false, 26 | type: AppButtonType = AppButtonType.Filled, 27 | onClick: () -> Unit, 28 | ) { 29 | when (type) { 30 | AppButtonType.Filled -> PrimaryButton( 31 | modifier = modifier, 32 | onClick = onClick, 33 | enabled = enabled && !isLoading, 34 | content = { ButtonContent(text = text, isLoading = isLoading) }, 35 | ) 36 | 37 | AppButtonType.Outlined -> OutlinedButton( 38 | onClick = onClick, 39 | modifier = modifier, 40 | enabled = enabled && !isLoading, 41 | content = { ButtonContent(text = text, isLoading = isLoading) }, 42 | colors = ButtonDefaults.outlinedButtonColors( 43 | contentColor = Theme.colors.secondary, 44 | backgroundColor = Color.Transparent, 45 | ), 46 | border = BorderStroke(1.dp, Theme.colors.secondary), 47 | ) 48 | 49 | AppButtonType.Text -> { 50 | TextButton( 51 | onClick = onClick, 52 | modifier = modifier, 53 | enabled = enabled && !isLoading, 54 | content = { 55 | ButtonContent(text = text, isLoading = isLoading) 56 | }, 57 | ) 58 | } 59 | } 60 | } 61 | 62 | @Composable 63 | private fun PrimaryButton( 64 | modifier: Modifier = Modifier, 65 | onClick: () -> Unit = {}, 66 | enabled: Boolean = false, 67 | content: @Composable RowScope.() -> Unit, 68 | ) { 69 | val colors = ButtonDefaults.buttonColors() 70 | Button( 71 | onClick = onClick, 72 | modifier = modifier, 73 | enabled = enabled, 74 | content = content, 75 | colors = colors, 76 | contentPadding = PaddingValues(vertical = Theme.dimensions.medium2), 77 | ) 78 | } 79 | 80 | @Composable 81 | private fun ButtonContent(text: String, isLoading: Boolean) { 82 | Crossfade(targetState = isLoading) { 83 | if (it) { 84 | CircularProgressIndicator( 85 | color = Theme.colors.primary, 86 | modifier = Modifier.size(24.dp), 87 | strokeWidth = 1.dp, 88 | ) 89 | } else { 90 | Text( 91 | text = text, 92 | style = Theme.typography.button, 93 | ) 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/presentation/components/appbutton/AppButtonType.kt: -------------------------------------------------------------------------------- 1 | package presentation.components.appbutton 2 | 3 | enum class AppButtonType { 4 | Filled, 5 | Outlined, 6 | Text, 7 | } 8 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/presentation/components/apptextfield/AppTextField.kt: -------------------------------------------------------------------------------- 1 | package presentation.components.apptextfield 2 | 3 | import androidx.compose.animation.AnimatedVisibility 4 | import androidx.compose.foundation.interaction.MutableInteractionSource 5 | import androidx.compose.foundation.layout.Column 6 | import androidx.compose.foundation.layout.fillMaxWidth 7 | import androidx.compose.foundation.text.KeyboardActions 8 | import androidx.compose.foundation.text.KeyboardOptions 9 | import androidx.compose.material.MaterialTheme 10 | import androidx.compose.material.OutlinedTextField 11 | import androidx.compose.material.Text 12 | import androidx.compose.material.TextFieldColors 13 | import androidx.compose.material.TextFieldDefaults 14 | import androidx.compose.runtime.Composable 15 | import androidx.compose.runtime.remember 16 | import androidx.compose.ui.Modifier 17 | import androidx.compose.ui.graphics.Shape 18 | import androidx.compose.ui.text.TextStyle 19 | import androidx.compose.ui.text.input.VisualTransformation 20 | import presentation.theme.Theme 21 | 22 | @Composable 23 | fun AppTextField( 24 | value: String, 25 | onValueChange: (String) -> Unit, 26 | modifier: Modifier = Modifier, 27 | enabled: Boolean = true, 28 | readOnly: Boolean = false, 29 | textStyle: TextStyle = TextStyle.Default, 30 | label: @Composable (() -> Unit)? = null, 31 | placeholder: @Composable (() -> Unit)? = null, 32 | leadingIcon: @Composable (() -> Unit)? = null, 33 | trailingIcon: @Composable (() -> Unit)? = null, 34 | errorMessage: String? = null, 35 | visualTransformation: VisualTransformation = VisualTransformation.None, 36 | keyboardOptions: KeyboardOptions = KeyboardOptions.Default, 37 | keyboardActions: KeyboardActions = KeyboardActions.Default, 38 | singleLine: Boolean = false, 39 | maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, 40 | minLines: Int = 1, 41 | interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, 42 | shape: Shape = MaterialTheme.shapes.small, 43 | colors: TextFieldColors = TextFieldDefaults.outlinedTextFieldColors(), 44 | ) { 45 | Column { 46 | OutlinedTextField( 47 | value = value, 48 | onValueChange = onValueChange, 49 | modifier = modifier.fillMaxWidth(), 50 | enabled = enabled, 51 | readOnly = readOnly, 52 | textStyle = textStyle, 53 | label = label, 54 | placeholder = placeholder, 55 | leadingIcon = leadingIcon, 56 | trailingIcon = trailingIcon, 57 | isError = errorMessage != null, 58 | visualTransformation = visualTransformation, 59 | keyboardOptions = keyboardOptions, 60 | keyboardActions = keyboardActions, 61 | singleLine = singleLine, 62 | maxLines = maxLines, 63 | minLines = minLines, 64 | interactionSource = interactionSource, 65 | shape = shape, 66 | colors = colors, 67 | ) 68 | 69 | AnimatedVisibility(visible = errorMessage != null) { 70 | Text( 71 | text = errorMessage ?: "", 72 | color = Theme.colors.error, 73 | style = MaterialTheme.typography.caption, 74 | ) 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/presentation/feature/home/HomeContract.kt: -------------------------------------------------------------------------------- 1 | package presentation.feature.home 2 | 3 | import domain.model.Product 4 | 5 | data class HomeScreenState( 6 | val productList: List = emptyList(), 7 | val favoriteList: List = emptyList(), 8 | val isLoading: Boolean = false, 9 | ) { 10 | // Needed for SwiftUI 11 | constructor() : this( 12 | productList = emptyList(), 13 | favoriteList = emptyList(), 14 | isLoading = false 15 | ) 16 | } 17 | 18 | sealed class HomeScreenIntent { 19 | data object OnLaunch : HomeScreenIntent() 20 | 21 | data class OnFavoriteClick(val product: Product) : HomeScreenIntent() 22 | } 23 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/presentation/feature/home/HomeScreen.kt: -------------------------------------------------------------------------------- 1 | package presentation.feature.home 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.fillMaxSize 5 | import androidx.compose.foundation.layout.fillMaxWidth 6 | import androidx.compose.foundation.lazy.LazyColumn 7 | import androidx.compose.foundation.lazy.items 8 | import androidx.compose.material.CircularProgressIndicator 9 | import androidx.compose.material.MaterialTheme 10 | import androidx.compose.material.Scaffold 11 | import androidx.compose.material.Text 12 | import androidx.compose.material.TopAppBar 13 | import androidx.compose.runtime.Composable 14 | import androidx.compose.runtime.LaunchedEffect 15 | import androidx.compose.ui.Alignment 16 | import androidx.compose.ui.Modifier 17 | import androidx.compose.ui.text.font.FontWeight 18 | import androidx.compose.ui.text.style.TextAlign 19 | import presentation.theme.Theme 20 | 21 | @Composable 22 | fun HomeScreen(state: HomeScreenState, action: (HomeScreenIntent) -> Unit) { 23 | LaunchedEffect(Unit) { 24 | action(HomeScreenIntent.OnLaunch) 25 | } 26 | 27 | Scaffold( 28 | topBar = { 29 | TopAppBar( 30 | title = { 31 | Text( 32 | text = "Home", 33 | fontWeight = FontWeight.Black, 34 | style = MaterialTheme.typography.h5, 35 | textAlign = TextAlign.Center, 36 | modifier = Modifier.fillMaxWidth(), 37 | ) 38 | }, 39 | backgroundColor = Theme.colors.primary, 40 | contentColor = Theme.colors.onPrimary, 41 | ) 42 | }, 43 | ) { 44 | Box( 45 | modifier = Modifier.fillMaxSize(), 46 | contentAlignment = Alignment.Center, 47 | ) { 48 | LazyColumn { 49 | items(state.productList) { product -> 50 | ProductItemView( 51 | product = product, 52 | isFavorite = state.favoriteList.contains(product), 53 | onFavoriteClick = { 54 | action(HomeScreenIntent.OnFavoriteClick(product)) 55 | }, 56 | onClick = { 57 | // Handle OnClick 58 | }, 59 | ) 60 | } 61 | } 62 | 63 | if (state.isLoading) CircularProgressIndicator() 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/presentation/feature/home/HomeScreenRoute.kt: -------------------------------------------------------------------------------- 1 | package presentation.feature.home 2 | 3 | import androidx.compose.material.Surface 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.runtime.collectAsState 6 | import androidx.compose.runtime.getValue 7 | import cafe.adriel.voyager.core.screen.Screen 8 | import org.koin.compose.koinInject 9 | 10 | object HomeScreenRoute : Screen { 11 | 12 | @Composable 13 | override fun Content() { 14 | val viewModel = koinInject() 15 | val state by viewModel.state.collectAsState() 16 | 17 | Surface { 18 | HomeScreen(state, viewModel::handleIntent) 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/presentation/feature/home/HomeViewModel.kt: -------------------------------------------------------------------------------- 1 | package presentation.feature.home 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import domain.model.Product 6 | import domain.usecase.favorite.AddToFavoriteUseCase 7 | import domain.usecase.favorite.GetFavoritesUseCase 8 | import domain.usecase.favorite.RemoveFromFavoriteUseCase 9 | import domain.usecase.product.GetProductsUseCase 10 | import kotlinx.coroutines.flow.MutableStateFlow 11 | import kotlinx.coroutines.flow.asStateFlow 12 | import kotlinx.coroutines.flow.update 13 | import kotlinx.coroutines.launch 14 | 15 | class HomeViewModel( 16 | private val getProductsUseCase: GetProductsUseCase, 17 | private val getFavoritesUseCase: GetFavoritesUseCase, 18 | private val addToFavoriteUseCase: AddToFavoriteUseCase, 19 | private val removeFromFavoriteUseCase: RemoveFromFavoriteUseCase, 20 | ) : ViewModel() { 21 | private val _state = MutableStateFlow(HomeScreenState()) 22 | val state = _state.asStateFlow() 23 | 24 | fun handleIntent(intent: HomeScreenIntent) { 25 | when (intent) { 26 | is HomeScreenIntent.OnLaunch -> { 27 | viewModelScope.launch { 28 | getFavorites() 29 | getProducts() 30 | } 31 | } 32 | 33 | is HomeScreenIntent.OnFavoriteClick -> { 34 | val favoriteList = state.value.favoriteList 35 | 36 | if (favoriteList.contains(intent.product)) { 37 | removeFromFavorite(intent.product) 38 | } else { 39 | addToFavorite(intent.product) 40 | } 41 | } 42 | } 43 | } 44 | 45 | private suspend fun getFavorites() { 46 | _state.update { it.copy(isLoading = true) } 47 | getFavoritesUseCase() 48 | .onSuccess { response -> 49 | _state.update { it.copy(favoriteList = response) } 50 | } 51 | .onFailure { 52 | // handle error 53 | } 54 | _state.update { it.copy(isLoading = false) } 55 | } 56 | 57 | private suspend fun getProducts() { 58 | _state.update { it.copy(isLoading = true) } 59 | getProductsUseCase() 60 | .onSuccess { response -> 61 | _state.update { it.copy(productList = response.products) } 62 | } 63 | .onFailure { 64 | // handle error 65 | } 66 | _state.update { it.copy(isLoading = false) } 67 | } 68 | 69 | private fun addToFavorite(product: Product) { 70 | viewModelScope.launch { 71 | addToFavoriteUseCase(product) 72 | .onSuccess { getFavorites() } 73 | .onFailure { 74 | // handle error 75 | } 76 | } 77 | } 78 | 79 | private fun removeFromFavorite(product: Product) { 80 | viewModelScope.launch { 81 | removeFromFavoriteUseCase(product) 82 | .onSuccess { getFavorites() } 83 | .onFailure { 84 | // handle error 85 | } 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/presentation/feature/home/HomeViewModelProvider.kt: -------------------------------------------------------------------------------- 1 | package presentation.feature.home 2 | 3 | import org.koin.core.component.KoinComponent 4 | import org.koin.core.component.inject 5 | 6 | class HomeViewModelProvider : KoinComponent { 7 | private val viewModel by inject() 8 | fun provide() = viewModel 9 | } 10 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/presentation/feature/home/ProductItemView.kt: -------------------------------------------------------------------------------- 1 | package presentation.feature.home 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.clickable 5 | import androidx.compose.foundation.layout.Box 6 | import androidx.compose.foundation.layout.fillMaxWidth 7 | import androidx.compose.foundation.layout.padding 8 | import androidx.compose.foundation.layout.size 9 | import androidx.compose.foundation.layout.wrapContentHeight 10 | import androidx.compose.foundation.shape.RoundedCornerShape 11 | import androidx.compose.material.Card 12 | import androidx.compose.material.Icon 13 | import androidx.compose.material.IconButton 14 | import androidx.compose.material.MaterialTheme 15 | import androidx.compose.material.Text 16 | import androidx.compose.material.icons.Icons 17 | import androidx.compose.material.icons.filled.Star 18 | import androidx.compose.material.icons.outlined.StarOutline 19 | import androidx.compose.runtime.Composable 20 | import androidx.compose.ui.Alignment 21 | import androidx.compose.ui.Modifier 22 | import androidx.compose.ui.graphics.Brush 23 | import androidx.compose.ui.graphics.Color 24 | import androidx.compose.ui.layout.ContentScale 25 | import androidx.compose.ui.text.style.TextAlign 26 | import androidx.compose.ui.unit.dp 27 | import coil3.compose.AsyncImage 28 | import domain.model.Product 29 | 30 | @Composable 31 | fun ProductItemView( 32 | product: Product, 33 | isFavorite: Boolean, 34 | onFavoriteClick: () -> Unit, 35 | onClick: () -> Unit, 36 | ) { 37 | Card( 38 | modifier = Modifier 39 | .padding(5.dp) 40 | .fillMaxWidth() 41 | .wrapContentHeight() 42 | .clickable { onClick() }, 43 | shape = RoundedCornerShape(8.dp), 44 | elevation = 8.dp, 45 | backgroundColor = MaterialTheme.colors.surface, 46 | ) { 47 | AsyncImage( 48 | model = product.thumbnail, 49 | contentDescription = product.title, 50 | modifier = Modifier.size(220.dp), 51 | contentScale = ContentScale.Crop, 52 | ) 53 | 54 | Box( 55 | modifier = Modifier 56 | .size(220.dp) 57 | .background( 58 | brush = Brush.verticalGradient( 59 | colors = listOf( 60 | Color.Transparent, 61 | Color.Transparent, 62 | Color(0xFF1F2023), 63 | ), 64 | ), 65 | ), 66 | ) { 67 | IconButton( 68 | modifier = Modifier.align(Alignment.TopEnd), 69 | onClick = { onFavoriteClick() }, 70 | ) { 71 | if (isFavorite) { 72 | Icon( 73 | Icons.Filled.Star, 74 | contentDescription = "Favorite", 75 | tint = Color(0xFFFFD81A), 76 | modifier = Modifier 77 | .size(44.dp) 78 | .padding(8.dp), 79 | ) 80 | } else { 81 | Icon( 82 | Icons.Outlined.StarOutline, 83 | contentDescription = "Not Favorite", 84 | tint = Color(0xFFFFD81A), 85 | modifier = Modifier 86 | .size(44.dp) 87 | .padding(8.dp), 88 | ) 89 | } 90 | } 91 | 92 | Text( 93 | text = product.title, 94 | textAlign = TextAlign.Center, 95 | color = Color.White, 96 | style = MaterialTheme.typography.body1, 97 | modifier = Modifier 98 | .align(Alignment.BottomCenter) 99 | .padding(8.dp), 100 | ) 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/presentation/feature/login/LoginContract.kt: -------------------------------------------------------------------------------- 1 | package presentation.feature.login 2 | 3 | /** 4 | * UI State that represents LoginScreen 5 | **/ 6 | data class LoginScreenState( 7 | var email: String = "kminchelle", 8 | var password: String = "0lelplR", 9 | val isLoading: Boolean = false, 10 | val error: String? = null, 11 | val loginButtonEnabled: Boolean = false, 12 | val isLoggedIn: Boolean = false, 13 | ) { 14 | // Needed for SwiftUI 15 | constructor() : this( 16 | email = "kminchelle", 17 | password = "0lelplR", 18 | isLoading = false, 19 | error = null, 20 | loginButtonEnabled = false, 21 | isLoggedIn = false 22 | ) 23 | } 24 | 25 | /** 26 | * Login Actions emitted from the UI Layer 27 | * passed to the viewmodel to handle 28 | **/ 29 | sealed class LoginIntent { 30 | data class OnPasswordChange(val pass: String) : LoginIntent() 31 | data class OnEmailChange(val email: String) : LoginIntent() 32 | data object Login : LoginIntent() 33 | } 34 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/presentation/feature/login/LoginScreen.kt: -------------------------------------------------------------------------------- 1 | package presentation.feature.login 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.Spacer 6 | import androidx.compose.foundation.layout.fillMaxSize 7 | import androidx.compose.foundation.layout.fillMaxWidth 8 | import androidx.compose.foundation.layout.imePadding 9 | import androidx.compose.foundation.layout.padding 10 | import androidx.compose.foundation.layout.size 11 | import androidx.compose.material.MaterialTheme 12 | import androidx.compose.material.Scaffold 13 | import androidx.compose.material.Text 14 | import androidx.compose.material.TopAppBar 15 | import androidx.compose.runtime.Composable 16 | import androidx.compose.ui.Alignment 17 | import androidx.compose.ui.Modifier 18 | import androidx.compose.ui.text.font.FontWeight 19 | import androidx.compose.ui.text.input.PasswordVisualTransformation 20 | import androidx.compose.ui.text.style.TextAlign 21 | import presentation.components.appbutton.AppButton 22 | import presentation.components.apptextfield.AppTextField 23 | import presentation.theme.Theme 24 | 25 | @Composable 26 | fun LoginScreen( 27 | state: LoginScreenState, 28 | action: (LoginIntent) -> Unit, 29 | navigate: () -> Unit 30 | ) { 31 | Scaffold( 32 | topBar = { 33 | TopAppBar( 34 | title = { 35 | Text( 36 | text = "Login", 37 | fontWeight = FontWeight.Black, 38 | style = MaterialTheme.typography.h5, 39 | textAlign = TextAlign.Center, 40 | modifier = Modifier.fillMaxWidth(), 41 | ) 42 | }, 43 | backgroundColor = Theme.colors.primary, 44 | contentColor = Theme.colors.onPrimary, 45 | ) 46 | }, 47 | ) { 48 | Column( 49 | modifier = Modifier 50 | .padding(it) 51 | .fillMaxSize() 52 | .padding(Theme.dimensions.big1) 53 | .imePadding(), 54 | horizontalAlignment = Alignment.CenterHorizontally, 55 | verticalArrangement = Arrangement.Center, 56 | ) { 57 | AppTextField( 58 | value = state.email, 59 | onValueChange = { text -> 60 | action(LoginIntent.OnEmailChange(email = text)) 61 | }, 62 | modifier = Modifier.fillMaxWidth(), 63 | placeholder = { Text(text = "Username") }, 64 | ) 65 | 66 | Spacer(modifier = Modifier.size(Theme.dimensions.medium3)) 67 | 68 | AppTextField( 69 | value = state.password, 70 | onValueChange = { text -> 71 | action(LoginIntent.OnPasswordChange(text)) 72 | }, 73 | modifier = Modifier.fillMaxWidth(), 74 | visualTransformation = PasswordVisualTransformation(), 75 | placeholder = { Text(text = "Password") }, 76 | ) 77 | 78 | Spacer(modifier = Modifier.size(Theme.dimensions.medium3)) 79 | 80 | AppButton( 81 | text = "Login", 82 | onClick = { 83 | action(LoginIntent.Login) 84 | }, 85 | modifier = Modifier.fillMaxWidth(), 86 | isLoading = state.isLoading, 87 | ) 88 | 89 | if (state.isLoggedIn) navigate() 90 | } 91 | } 92 | } -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/presentation/feature/login/LoginScreenRoute.kt: -------------------------------------------------------------------------------- 1 | package presentation.feature.login 2 | 3 | import androidx.compose.material.Surface 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.runtime.collectAsState 6 | import androidx.compose.runtime.getValue 7 | import cafe.adriel.voyager.core.screen.Screen 8 | import cafe.adriel.voyager.navigator.LocalNavigator 9 | import cafe.adriel.voyager.navigator.currentOrThrow 10 | import org.koin.compose.koinInject 11 | import presentation.feature.home.HomeScreenRoute 12 | 13 | object LoginScreenRoute : Screen { 14 | @Composable 15 | override fun Content() { 16 | val viewModel = koinInject() 17 | val navigator = LocalNavigator.currentOrThrow 18 | val state by viewModel.state.collectAsState() 19 | 20 | Surface { 21 | LoginScreen(state, viewModel::handleIntent) { 22 | navigator.replaceAll(HomeScreenRoute) 23 | } 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/presentation/feature/login/LoginViewModel.kt: -------------------------------------------------------------------------------- 1 | package presentation.feature.login 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import data.network.requests.LoginRequest 6 | import domain.usecase.GetUserFromPreferenceUseCase 7 | import domain.usecase.LoginUseCase 8 | import domain.usecase.User 9 | import kotlinx.coroutines.flow.MutableStateFlow 10 | import kotlinx.coroutines.flow.asStateFlow 11 | import kotlinx.coroutines.flow.update 12 | import kotlinx.coroutines.launch 13 | import presentation.model.toError 14 | 15 | class LoginViewModel( 16 | private val loginUseCase: LoginUseCase, 17 | private val userUseCase: GetUserFromPreferenceUseCase, 18 | ) : ViewModel() { 19 | private val _state = MutableStateFlow(LoginScreenState()) 20 | val state = _state.asStateFlow() 21 | 22 | fun handleIntent(intent: LoginIntent) { 23 | when (intent) { 24 | is LoginIntent.Login -> login() 25 | is LoginIntent.OnEmailChange -> onEmailChange(intent.email) 26 | is LoginIntent.OnPasswordChange -> onPasswordChange(intent.pass) 27 | } 28 | } 29 | 30 | private fun onEmailChange(value: String) = _state.update { it.copy(email = value) } 31 | 32 | private fun onPasswordChange(value: String) = _state.update { it.copy(password = value) } 33 | 34 | private fun login() { 35 | viewModelScope.launch { 36 | _state.update { it.copy(isLoading = true) } 37 | val state = _state.value 38 | val result = loginUseCase(LoginRequest(state.email, state.password)) 39 | 40 | result.onSuccess { 41 | userUseCase.add(User(state.email.uppercase(), state.email)) 42 | } 43 | 44 | _state.update { 45 | it.copy( 46 | error = result.exceptionOrNull()?.toError()?.message, 47 | isLoading = false, 48 | isLoggedIn = result.isSuccess, 49 | ) 50 | } 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/presentation/feature/login/LoginViewModelProvider.kt: -------------------------------------------------------------------------------- 1 | package presentation.feature.login 2 | 3 | import org.koin.core.component.KoinComponent 4 | import org.koin.core.component.inject 5 | 6 | class LoginViewModelProvider : KoinComponent { 7 | private val viewModel by inject() 8 | fun provide() = viewModel 9 | } 10 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/presentation/feature/pagination/PaginatedHomeScreen.kt: -------------------------------------------------------------------------------- 1 | package presentation.feature.pagination 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.fillMaxSize 5 | import androidx.compose.foundation.layout.fillMaxWidth 6 | import androidx.compose.foundation.lazy.LazyColumn 7 | import androidx.compose.material.CircularProgressIndicator 8 | import androidx.compose.material.MaterialTheme 9 | import androidx.compose.material.Scaffold 10 | import androidx.compose.material.Text 11 | import androidx.compose.material.TopAppBar 12 | import androidx.compose.runtime.Composable 13 | import androidx.compose.runtime.LaunchedEffect 14 | import androidx.compose.ui.Alignment 15 | import androidx.compose.ui.Modifier 16 | import androidx.compose.ui.text.font.FontWeight 17 | import androidx.compose.ui.text.style.TextAlign 18 | import app.cash.paging.LoadStateError 19 | import app.cash.paging.LoadStateLoading 20 | import app.cash.paging.LoadStateNotLoading 21 | import app.cash.paging.compose.collectAsLazyPagingItems 22 | import cafe.adriel.voyager.core.screen.Screen 23 | import org.koin.compose.koinInject 24 | import presentation.feature.home.ProductItemView 25 | import presentation.theme.Theme 26 | 27 | object PaginatedHomeScreen : Screen { 28 | 29 | @Composable 30 | override fun Content() { 31 | val viewModel = koinInject() 32 | val productList = viewModel.productList.collectAsLazyPagingItems() 33 | 34 | Scaffold( 35 | topBar = { 36 | TopAppBar( 37 | title = { 38 | Text( 39 | text = "Products", 40 | fontWeight = FontWeight.Black, 41 | style = MaterialTheme.typography.h5, 42 | textAlign = TextAlign.Center, 43 | modifier = Modifier.fillMaxWidth(), 44 | ) 45 | }, 46 | backgroundColor = Theme.colors.primary, 47 | contentColor = Theme.colors.onPrimary, 48 | ) 49 | }, 50 | ) { 51 | Box( 52 | modifier = Modifier.fillMaxSize(), 53 | contentAlignment = Alignment.Center, 54 | ) { 55 | LazyColumn { 56 | when (val loadState = productList.loadState.refresh) { 57 | is LoadStateLoading -> { 58 | item { CircularProgressIndicator() } 59 | } 60 | 61 | is LoadStateNotLoading -> { 62 | items(productList.itemCount) { index -> 63 | val product = productList[index] 64 | product?.let { 65 | ProductItemView(it, false, {}, {}) 66 | } 67 | } 68 | } 69 | 70 | is LoadStateError -> { 71 | item { 72 | Text(loadState.error.message!!) 73 | } 74 | } 75 | 76 | else -> error("when should be exhaustive") 77 | } 78 | } 79 | } 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/presentation/feature/pagination/PaginatedHomeViewModel.kt: -------------------------------------------------------------------------------- 1 | package presentation.feature.pagination 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import androidx.paging.cachedIn 6 | import domain.usecase.product.GetPaginatedProductsUseCase 7 | 8 | class PaginatedHomeViewModel( 9 | getPaginatedProductsUseCase: GetPaginatedProductsUseCase, 10 | ) : ViewModel() { 11 | val productList = getPaginatedProductsUseCase().cachedIn(viewModelScope) 12 | } 13 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/presentation/feature/splash/SplashContract.kt: -------------------------------------------------------------------------------- 1 | package presentation.feature.splash 2 | 3 | import domain.usecase.User 4 | import platform.Greeting 5 | 6 | data class SplashScreenState( 7 | val user: User? = null, 8 | val showContent: Boolean = false, 9 | val greeting: String = Greeting().greet(), 10 | ) { 11 | // Needed for SwiftUI 12 | constructor() : this( 13 | user = null, 14 | showContent = false, 15 | greeting = Greeting().greet() 16 | ) 17 | } 18 | 19 | sealed class SplashScreenIntent { 20 | data class OnContentVisibilityChange(val show: Boolean) : SplashScreenIntent() 21 | } 22 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/presentation/feature/splash/SplashScreen.kt: -------------------------------------------------------------------------------- 1 | package presentation.feature.splash 2 | 3 | import androidx.compose.animation.AnimatedVisibility 4 | import androidx.compose.foundation.Image 5 | import androidx.compose.foundation.layout.Arrangement 6 | import androidx.compose.foundation.layout.Column 7 | import androidx.compose.foundation.layout.fillMaxSize 8 | import androidx.compose.foundation.layout.fillMaxWidth 9 | import androidx.compose.material.Text 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.runtime.LaunchedEffect 12 | import androidx.compose.ui.Alignment 13 | import androidx.compose.ui.Modifier 14 | import kmp_template.composeapp.generated.resources.Res 15 | import kmp_template.composeapp.generated.resources.compose_multiplatform 16 | import kotlinx.coroutines.delay 17 | import org.jetbrains.compose.resources.ExperimentalResourceApi 18 | import org.jetbrains.compose.resources.painterResource 19 | 20 | @OptIn(ExperimentalResourceApi::class) 21 | @Composable 22 | fun SplashScreen( 23 | state: SplashScreenState, 24 | action: (SplashScreenIntent) -> Unit, 25 | navigate: () -> Unit 26 | ) { 27 | LaunchedEffect(Unit) { 28 | delay(500) 29 | action(SplashScreenIntent.OnContentVisibilityChange(show = true)) 30 | delay(1000) 31 | action(SplashScreenIntent.OnContentVisibilityChange(show = false)) 32 | delay(500) 33 | navigate() 34 | } 35 | 36 | Column( 37 | Modifier.fillMaxSize(), 38 | horizontalAlignment = Alignment.CenterHorizontally, 39 | verticalArrangement = Arrangement.Center, 40 | ) { 41 | AnimatedVisibility(state.showContent) { 42 | Column( 43 | Modifier.fillMaxWidth(), 44 | horizontalAlignment = Alignment.CenterHorizontally, 45 | ) { 46 | Image(painterResource(Res.drawable.compose_multiplatform), null) 47 | Text("Compose: ${state.greeting}") 48 | } 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/presentation/feature/splash/SplashScreenRoute.kt: -------------------------------------------------------------------------------- 1 | package presentation.feature.splash 2 | 3 | import androidx.compose.material.Surface 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.runtime.collectAsState 6 | import androidx.compose.runtime.getValue 7 | import cafe.adriel.voyager.core.screen.Screen 8 | import cafe.adriel.voyager.navigator.LocalNavigator 9 | import cafe.adriel.voyager.navigator.currentOrThrow 10 | import org.koin.compose.koinInject 11 | import presentation.feature.home.HomeScreenRoute 12 | import presentation.feature.login.LoginScreenRoute 13 | 14 | object SplashScreenRoute : Screen { 15 | @Composable 16 | override fun Content() { 17 | val viewModel = koinInject() 18 | val navigator = LocalNavigator.currentOrThrow 19 | val state by viewModel.state.collectAsState() 20 | 21 | Surface { 22 | SplashScreen(state, viewModel::handleIntent) { 23 | state.user?.let { 24 | navigator.replaceAll(HomeScreenRoute) 25 | } ?: navigator.replaceAll(LoginScreenRoute) 26 | } 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/presentation/feature/splash/SplashViewModel.kt: -------------------------------------------------------------------------------- 1 | package presentation.feature.splash 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import domain.usecase.GetUserFromPreferenceUseCase 6 | import kotlinx.coroutines.flow.MutableStateFlow 7 | import kotlinx.coroutines.flow.asStateFlow 8 | import kotlinx.coroutines.flow.update 9 | import kotlinx.coroutines.launch 10 | 11 | class SplashViewModel( 12 | private val userUseCase: GetUserFromPreferenceUseCase 13 | ) : ViewModel() { 14 | private val _state = MutableStateFlow(SplashScreenState()) 15 | val state = _state.asStateFlow() 16 | 17 | fun handleIntent(intent: SplashScreenIntent) { 18 | when (intent) { 19 | is SplashScreenIntent.OnContentVisibilityChange -> _state.update { it.copy(showContent = intent.show) } 20 | } 21 | } 22 | 23 | init { 24 | getUser() 25 | } 26 | 27 | private fun getUser() = viewModelScope.launch { 28 | _state.update { it.copy(user = userUseCase.get()) } 29 | println("USER | ${state.value.user}") 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/presentation/feature/splash/SplashViewModelProvider.kt: -------------------------------------------------------------------------------- 1 | package presentation.feature.splash 2 | 3 | import org.koin.core.component.KoinComponent 4 | import org.koin.core.component.inject 5 | 6 | class SplashViewModelProvider : KoinComponent { 7 | private val viewModel by inject() 8 | fun provide() = viewModel 9 | } 10 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/presentation/model/ErrorModel.kt: -------------------------------------------------------------------------------- 1 | package presentation.model 2 | 3 | import error.ApiErrorException 4 | import io.ktor.client.network.sockets.SocketTimeoutException 5 | 6 | sealed class ErrorModel(open val message: String) { 7 | data class ApiError(val exception: ApiErrorException, override val message: String) : 8 | ErrorModel(message) 9 | 10 | sealed class Connection(override val message: String) : ErrorModel(message) { 11 | data object Timeout : Connection("Connection timed out") 12 | } 13 | 14 | data class Unknown(val throwable: Throwable, override val message: String) : ErrorModel(message) 15 | } 16 | 17 | fun Throwable.toError(): ErrorModel { 18 | return when (this) { 19 | is ApiErrorException -> { 20 | ErrorModel.ApiError(this, "API error: ${this.message}") 21 | } 22 | 23 | is SocketTimeoutException -> ErrorModel.Connection.Timeout 24 | else -> ErrorModel.Unknown(this, "Unknown error: ${this.message}") 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/presentation/theme/AppTheme.kt: -------------------------------------------------------------------------------- 1 | package presentation.theme 2 | 3 | import androidx.compose.foundation.isSystemInDarkTheme 4 | import androidx.compose.material.Colors 5 | import androidx.compose.material.MaterialTheme 6 | import androidx.compose.material.Shapes 7 | import androidx.compose.material.darkColors 8 | import androidx.compose.material.lightColors 9 | import androidx.compose.runtime.Composable 10 | import presentation.theme.dimensions.Dimensions 11 | import presentation.theme.dimensions.LocalDimensions 12 | import presentation.theme.dimensions.ProvideDimensions 13 | import presentation.theme.typography.AppTypography 14 | import presentation.theme.typography.LocalAppTypography 15 | import presentation.theme.typography.ProvideAppTypography 16 | import presentation.theme.typography.Typography 17 | 18 | /** 19 | * Main theme provider 20 | * Use [Theme.*] to access colors, typography and etc 21 | */ 22 | @Composable 23 | fun AppTheme(isDarkMode: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) { 24 | val dimensions = Dimensions.Default 25 | ProvideDimensions(dimensions = dimensions) { 26 | ProvideAppTypography { 27 | MaterialTheme( 28 | typography = Typography, 29 | shapes = Shapes, 30 | content = content, 31 | colors = if (isDarkMode) DarkColors else LightColors, 32 | ) 33 | } 34 | } 35 | } 36 | 37 | /** 38 | * Shortcut to obtain App Theme values instead of using [MaterialTheme] 39 | * - Provides custom AppTypography instead of Material one 40 | * - Provides access to dimensions, that can vary based on device size 41 | */ 42 | object Theme { 43 | val typography: AppTypography @Composable get() = LocalAppTypography.current 44 | val colors: Colors @Composable get() = MaterialTheme.colors 45 | val shapes: Shapes @Composable get() = MaterialTheme.shapes 46 | val dimensions: Dimensions @Composable get() = LocalDimensions.current 47 | } 48 | 49 | private val LightColors = lightColors( 50 | primary = MonstarlabYellow, 51 | onPrimary = Black, 52 | background = LightGrey, 53 | onBackground = DarkGrey, 54 | onSurface = Black, 55 | surface = White, 56 | error = Red, 57 | ) 58 | 59 | private val DarkColors = darkColors( 60 | primary = MonstarlabYellow, 61 | onPrimary = Black, 62 | background = DarkGrey, 63 | onBackground = White, 64 | onSurface = White, 65 | surface = Black, 66 | error = Red, 67 | ) 68 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/presentation/theme/Colors.kt: -------------------------------------------------------------------------------- 1 | package presentation.theme 2 | 3 | import androidx.compose.ui.graphics.Color 4 | 5 | val MonstarlabYellow = Color(0xFFF6FF00) 6 | val DarkGrey = Color(0xFF363636) 7 | val Black = Color.Black 8 | val White = Color.White 9 | val Red = Color(0xFFFF2600) 10 | val LightGrey = Color(0xFFE4E4E4) 11 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/presentation/theme/Shapes.kt: -------------------------------------------------------------------------------- 1 | package presentation.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(8.dp), 10 | large = RoundedCornerShape(32.dp), 11 | ) 12 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/presentation/theme/dimensions/Dimensions.kt: -------------------------------------------------------------------------------- 1 | package presentation.theme.dimensions 2 | 3 | import androidx.compose.ui.unit.Dp 4 | import androidx.compose.ui.unit.dp 5 | 6 | /** 7 | * Holder for the app dimensions that can be used to avoid hardcoded margins and paddings 8 | * Provides Default variant and [Small] that can be used for smaller devices 9 | */ 10 | class Dimensions( 11 | val medium1: Dp, 12 | val medium2: Dp, 13 | val medium3: Dp, 14 | val big1: Dp, 15 | val big2: Dp, 16 | ) { 17 | companion object { 18 | val Small = Dimensions( 19 | medium1 = 12.dp, 20 | medium2 = 9.dp, 21 | medium3 = 6.dp, 22 | big1 = 18.dp, 23 | big2 = 16.dp, 24 | ) 25 | 26 | val Default = Dimensions( 27 | medium1 = 16.dp, 28 | medium2 = 12.dp, 29 | medium3 = 8.dp, 30 | big1 = 24.dp, 31 | big2 = 32.dp, 32 | ) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/presentation/theme/dimensions/DimensionsProvider.kt: -------------------------------------------------------------------------------- 1 | package presentation.theme.dimensions 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.CompositionLocalProvider 5 | import androidx.compose.runtime.staticCompositionLocalOf 6 | 7 | val LocalDimensions = staticCompositionLocalOf { 8 | Dimensions.Default 9 | } 10 | 11 | @Composable 12 | fun ProvideDimensions(dimensions: Dimensions, content: @Composable () -> Unit) { 13 | CompositionLocalProvider(LocalDimensions provides dimensions) { 14 | content.invoke() 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/presentation/theme/typography/AppTypography.kt: -------------------------------------------------------------------------------- 1 | package presentation.theme.typography 2 | 3 | import androidx.compose.material.Typography 4 | import androidx.compose.ui.text.TextStyle 5 | 6 | /** 7 | * Custom holder for the Text styles instead of the one used in Material Design 8 | * Here You can put the styles the app needs and the one specified in you projects Figma 9 | * 10 | * secondary constructor uses Material Theme typography from [Typography] 11 | * to maintain some corelation with Material Specification 12 | */ 13 | data class AppTypography( 14 | val headline1: TextStyle, 15 | val headline2: TextStyle, 16 | val body1: TextStyle, 17 | val body2: TextStyle, 18 | val button: TextStyle, 19 | ) { 20 | 21 | constructor(typography: Typography) : this( 22 | headline1 = typography.h1, 23 | headline2 = typography.h2, 24 | body1 = typography.body1, 25 | body2 = typography.body2, 26 | button = typography.button, 27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/presentation/theme/typography/AppTypographyProvider.kt: -------------------------------------------------------------------------------- 1 | package presentation.theme.typography 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.CompositionLocalProvider 5 | import androidx.compose.runtime.staticCompositionLocalOf 6 | 7 | val LocalAppTypography = staticCompositionLocalOf { 8 | AppTypography(Typography) 9 | } 10 | 11 | @Composable 12 | fun ProvideAppTypography( 13 | typography: AppTypography = AppTypography(Typography), 14 | content: @Composable () -> Unit = {}, 15 | ) { 16 | CompositionLocalProvider(LocalAppTypography provides typography) { 17 | content.invoke() 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/presentation/theme/typography/FontFamily.kt: -------------------------------------------------------------------------------- 1 | package presentation.theme.typography 2 | 3 | import androidx.compose.ui.text.font.FontFamily 4 | 5 | val FontFamily = FontFamily.Default 6 | 7 | /** 8 | val CustomFontFamily = Font( 9 | resId = R.font.my_custom_font, 10 | weight = FontWeight.Bold, 11 | style = FontStyle.Normal 12 | ) 13 | */ 14 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/presentation/theme/typography/Typography.kt: -------------------------------------------------------------------------------- 1 | package presentation.theme.typography 2 | 3 | import androidx.compose.material.Typography 4 | import androidx.compose.ui.text.TextStyle 5 | import androidx.compose.ui.text.font.FontWeight 6 | import androidx.compose.ui.unit.sp 7 | 8 | val Typography = Typography( 9 | defaultFontFamily = FontFamily, 10 | h1 = TextStyle( 11 | fontWeight = FontWeight.Bold, 12 | fontSize = 34.sp, 13 | ), 14 | 15 | h2 = TextStyle( 16 | fontWeight = FontWeight.Bold, 17 | fontSize = 28.sp, 18 | lineHeight = 32.sp, 19 | ), 20 | 21 | h3 = TextStyle( 22 | fontWeight = FontWeight.Bold, 23 | fontSize = 24.sp, 24 | ), 25 | 26 | h5 = TextStyle( 27 | fontWeight = FontWeight.Bold, 28 | fontSize = 20.sp, 29 | ), 30 | 31 | h6 = TextStyle( 32 | fontWeight = FontWeight.Bold, 33 | fontSize = 17.sp, 34 | ), 35 | 36 | body1 = TextStyle( 37 | fontWeight = FontWeight.Normal, 38 | fontSize = 16.sp, 39 | lineHeight = 21.sp, 40 | ), 41 | 42 | body2 = TextStyle( 43 | fontWeight = FontWeight.Normal, 44 | fontSize = 14.sp, 45 | ), 46 | 47 | button = TextStyle( 48 | fontWeight = FontWeight.Normal, 49 | fontSize = 16.sp, 50 | ), 51 | caption = TextStyle( 52 | fontWeight = FontWeight.Normal, 53 | fontSize = 12.sp, 54 | ), 55 | overline = TextStyle( 56 | fontWeight = FontWeight.Normal, 57 | fontSize = 8.sp, 58 | ), 59 | ) 60 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/sqldelight/com/monstarlab/kmp/Product.sq: -------------------------------------------------------------------------------- 1 | import kotlin.String; 2 | import kotlin.collections.List; 3 | 4 | CREATE TABLE ProductDB ( 5 | id INTEGER NOT NULL PRIMARY KEY, 6 | brand TEXT NOT NULL, 7 | category TEXT NOT NULL, 8 | description TEXT NOT NULL, 9 | discountPercentage REAL NOT NULL, 10 | rating REAL NOT NULL, 11 | price INTEGER NOT NULL, 12 | stock INTEGER NOT NULL, 13 | thumbnail TEXT NOT NULL, 14 | title TEXT NOT NULL, 15 | images TEXT AS List NOT NULL 16 | ); 17 | 18 | selectAll: 19 | SELECT * FROM ProductDB; 20 | 21 | insertProduct: 22 | INSERT OR IGNORE INTO ProductDB( 23 | id, 24 | brand, 25 | category, 26 | description, 27 | discountPercentage, 28 | rating, 29 | price, 30 | stock, 31 | thumbnail, 32 | title, 33 | images 34 | ) 35 | VALUES ?; 36 | 37 | deleteProduct: 38 | DELETE FROM ProductDB WHERE id = ?; 39 | -------------------------------------------------------------------------------- /composeApp/src/commonTest/kotlin/domain/usecase/GetProductsUseCaseTest.kt: -------------------------------------------------------------------------------- 1 | package domain.usecase 2 | 3 | import domain.model.ProductList 4 | import domain.usecase.product.GetProductsUseCase 5 | import fakes.FakeDataSource 6 | import fakes.FakeProductRepository 7 | import kotlinx.coroutines.test.runTest 8 | import kotlin.test.Test 9 | import kotlin.test.assertEquals 10 | 11 | class GetProductsUseCaseTest { 12 | private lateinit var sut: GetProductsUseCase 13 | 14 | @Test 15 | fun `invoke returns Success`() = runTest { 16 | // Arrange 17 | sut = GetProductsUseCase(FakeProductRepository(true)) 18 | val assertedResponse = FakeDataSource.productList 19 | 20 | // Act 21 | lateinit var actualResponse: ProductList 22 | sut().onSuccess { actualResponse = it } 23 | 24 | // Assert 25 | assertEquals(assertedResponse, actualResponse) 26 | } 27 | 28 | @Test 29 | fun `invoke returns Failure`() = runTest { 30 | // Arrange 31 | sut = GetProductsUseCase(FakeProductRepository(false)) 32 | val assertedResponse = "No Data" 33 | 34 | // Act 35 | lateinit var actualResponse: String 36 | sut().onFailure { actualResponse = it.message.toString() } 37 | 38 | // Assert 39 | assertEquals(assertedResponse, actualResponse) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /composeApp/src/commonTest/kotlin/domain/usecase/LoginUseCaseTest.kt: -------------------------------------------------------------------------------- 1 | package domain.usecase 2 | 3 | import data.network.requests.LoginRequest 4 | import data.network.responses.AuthResponse 5 | import domain.repository.AuthRepository 6 | import kotlin.test.BeforeTest 7 | import kotlin.test.Test 8 | import kotlin.test.assertEquals 9 | import kotlinx.coroutines.test.runTest 10 | 11 | class LoginUseCaseTest { 12 | 13 | private lateinit var sut: LoginUseCase 14 | private val mockAuthRepository = object : AuthRepository { 15 | override suspend fun authenticate(loginRequest: LoginRequest): AuthResponse { 16 | return AuthResponse(id = 0, token = "token") 17 | } 18 | } 19 | 20 | @BeforeTest 21 | fun setUp() { 22 | sut = LoginUseCase(mockAuthRepository) 23 | } 24 | 25 | @Test 26 | fun loginUseCase_ValidCredentials_ReturnsAuthResponse() = runTest { 27 | // Arrange 28 | val loginRequest = LoginRequest("test", "password") 29 | val expectedResult = AuthResponse(id = 0, token = "token") 30 | 31 | // Act 32 | val result = sut(loginRequest) 33 | 34 | // Assert 35 | assertEquals(expectedResult, result.getOrNull()) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /composeApp/src/commonTest/kotlin/domain/usecase/favorite/AddToFavoriteUseCaseTest.kt: -------------------------------------------------------------------------------- 1 | package domain.usecase.favorite 2 | 3 | import fakes.FakeDataSource 4 | import fakes.FakeFavoriteRepository 5 | import kotlinx.coroutines.test.runTest 6 | import kotlin.test.Test 7 | import kotlin.test.assertEquals 8 | 9 | class AddToFavoriteUseCaseTest { 10 | private lateinit var sut: AddToFavoriteUseCase 11 | 12 | @Test 13 | fun `invoke returns Success`() = runTest { 14 | // Arrange 15 | sut = AddToFavoriteUseCase(FakeFavoriteRepository(true)) 16 | val assertedResponse = Unit 17 | 18 | // Act 19 | lateinit var actualResponse: Unit 20 | sut(FakeDataSource.product).onSuccess { actualResponse = it } 21 | 22 | // Assert 23 | assertEquals(assertedResponse, actualResponse) 24 | } 25 | 26 | @Test 27 | fun `invoke returns Failure`() = runTest { 28 | // Arrange 29 | sut = AddToFavoriteUseCase(FakeFavoriteRepository(false)) 30 | val assertedResponse = "Error" 31 | 32 | // Act 33 | lateinit var actualResponse: String 34 | sut(FakeDataSource.product).onFailure { actualResponse = it.message.toString() } 35 | 36 | // Assert 37 | assertEquals(assertedResponse, actualResponse) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /composeApp/src/commonTest/kotlin/domain/usecase/favorite/GetFavoritesUseCaseTest.kt: -------------------------------------------------------------------------------- 1 | package domain.usecase.favorite 2 | 3 | import domain.model.Product 4 | import fakes.FakeDataSource 5 | import fakes.FakeFavoriteRepository 6 | import kotlinx.coroutines.test.runTest 7 | import kotlin.test.Test 8 | import kotlin.test.assertEquals 9 | 10 | class GetFavoritesUseCaseTest { 11 | private lateinit var sut: GetFavoritesUseCase 12 | 13 | @Test 14 | fun `invoke returns Success`() = runTest { 15 | // Arrange 16 | sut = GetFavoritesUseCase(FakeFavoriteRepository(true)) 17 | val assertedResponse = listOf(FakeDataSource.product) 18 | 19 | // Act 20 | lateinit var actualResponse: List 21 | sut().onSuccess { actualResponse = it } 22 | 23 | // Assert 24 | assertEquals(assertedResponse, actualResponse) 25 | } 26 | 27 | @Test 28 | fun `invoke returns Failure`() = runTest { 29 | // Arrange 30 | sut = GetFavoritesUseCase(FakeFavoriteRepository(false)) 31 | val assertedResponse = "Error" 32 | 33 | // Act 34 | lateinit var actualResponse: String 35 | sut().onFailure { actualResponse = it.message.toString() } 36 | 37 | // Assert 38 | assertEquals(assertedResponse, actualResponse) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /composeApp/src/commonTest/kotlin/domain/usecase/favorite/RemoveFromFavoriteUseCaseTest.kt: -------------------------------------------------------------------------------- 1 | package domain.usecase.favorite 2 | 3 | import fakes.FakeDataSource 4 | import fakes.FakeFavoriteRepository 5 | import kotlinx.coroutines.test.runTest 6 | import kotlin.test.Test 7 | import kotlin.test.assertEquals 8 | 9 | class RemoveFromFavoriteUseCaseTest { 10 | private lateinit var sut: RemoveFromFavoriteUseCase 11 | 12 | @Test 13 | fun `invoke returns Success`() = runTest { 14 | // Arrange 15 | sut = RemoveFromFavoriteUseCase(FakeFavoriteRepository(true)) 16 | val assertedResponse = Unit 17 | 18 | // Act 19 | lateinit var actualResponse: Unit 20 | sut(FakeDataSource.product).onSuccess { actualResponse = it } 21 | 22 | // Assert 23 | assertEquals(assertedResponse, actualResponse) 24 | } 25 | 26 | @Test 27 | fun `invoke returns Failure`() = runTest { 28 | // Arrange 29 | sut = RemoveFromFavoriteUseCase(FakeFavoriteRepository(false)) 30 | val assertedResponse = "Error" 31 | 32 | // Act 33 | lateinit var actualResponse: String 34 | sut(FakeDataSource.product).onFailure { actualResponse = it.message.toString() } 35 | 36 | // Assert 37 | assertEquals(assertedResponse, actualResponse) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /composeApp/src/commonTest/kotlin/fakes/FakeDataSource.kt: -------------------------------------------------------------------------------- 1 | package fakes 2 | 3 | import domain.model.Product 4 | import domain.model.ProductList 5 | 6 | internal object FakeDataSource { 7 | val product = Product( 8 | id = 1, 9 | title = "iPhone 9", 10 | description = "An apple mobile which is nothing like apple", 11 | price = 549, 12 | discountPercentage = 12.96, 13 | rating = 4.69, 14 | stock = 94, 15 | brand = "Apple", 16 | category = "smartphones", 17 | thumbnail = "https://cdn.dummyjson.com/product-images/1/thumbnail.jpg", 18 | ) 19 | 20 | val productList = ProductList(products = listOf(product), limit = 1, skip = 0, total = 100) 21 | } 22 | -------------------------------------------------------------------------------- /composeApp/src/commonTest/kotlin/fakes/FakeFavoriteRepository.kt: -------------------------------------------------------------------------------- 1 | package fakes 2 | 3 | import domain.model.Product 4 | import domain.repository.FavoriteRepository 5 | 6 | internal class FakeFavoriteRepository(private val isSuccess: Boolean) : FavoriteRepository { 7 | override suspend fun getFavorites() = 8 | if (isSuccess) listOf(FakeDataSource.product) else throw Exception("Error") 9 | 10 | override suspend fun addToFavorite(product: Product) = 11 | if (isSuccess) Unit else throw Exception("Error") 12 | 13 | override suspend fun removeFromFavorite(product: Product) = 14 | if (isSuccess) Unit else throw Exception("Error") 15 | } 16 | -------------------------------------------------------------------------------- /composeApp/src/commonTest/kotlin/fakes/FakeProductRepository.kt: -------------------------------------------------------------------------------- 1 | package fakes 2 | 3 | import domain.repository.ProductRepository 4 | 5 | internal class FakeProductRepository(private val isSuccess: Boolean) : ProductRepository { 6 | override suspend fun getProducts() = 7 | if (isSuccess) FakeDataSource.productList else throw Exception("No Data") 8 | } 9 | -------------------------------------------------------------------------------- /composeApp/src/iosMain/kotlin/MainViewController.kt: -------------------------------------------------------------------------------- 1 | import androidx.compose.ui.window.ComposeUIViewController 2 | import presentation.App 3 | 4 | fun MainViewController() = ComposeUIViewController { App() } 5 | -------------------------------------------------------------------------------- /composeApp/src/iosMain/kotlin/data/preference/Preference.ios.kt: -------------------------------------------------------------------------------- 1 | package data.preference 2 | 3 | import androidx.datastore.core.DataStore 4 | import androidx.datastore.preferences.core.Preferences 5 | import platform.Foundation.NSDocumentDirectory 6 | import platform.Foundation.NSFileManager 7 | import platform.Foundation.NSURL 8 | import platform.Foundation.NSUserDomainMask 9 | 10 | /** 11 | * Platform-specific function for creating a DataStore instance. 12 | * Creates a DataStore instance for iOS platform. 13 | * 14 | * @return DataStore instance. 15 | */ 16 | 17 | internal actual fun createDataStore(): DataStore = createDataStore( 18 | producePath = { 19 | // Retrieves the document directory path for iOS 20 | val docDirectory: NSURL? = NSFileManager.defaultManager.URLForDirectory( 21 | directory = NSDocumentDirectory, 22 | inDomain = NSUserDomainMask, 23 | appropriateForURL = null, 24 | create = false, 25 | error = null, 26 | ) 27 | 28 | // Constructs the full file path for the DataStore 29 | requireNotNull(docDirectory).path + "/$DATA_STORE_FILE_NAME" 30 | }, 31 | ) 32 | -------------------------------------------------------------------------------- /composeApp/src/iosMain/kotlin/di/modules/PlatformModule.kt: -------------------------------------------------------------------------------- 1 | package di.modules 2 | 3 | import androidx.room.Room 4 | import androidx.sqlite.driver.bundled.BundledSQLiteDriver 5 | import data.db.AppDatabase 6 | import data.db.DataBaseConstants.DATABASE_NAME 7 | import data.db.instantiateImpl 8 | import kotlinx.coroutines.Dispatchers 9 | import kotlinx.coroutines.IO 10 | import org.koin.dsl.module 11 | import platform.Foundation.NSDocumentDirectory 12 | import platform.Foundation.NSFileManager 13 | import platform.Foundation.NSURL 14 | import platform.Foundation.NSUserDomainMask 15 | 16 | actual val platformModule = module { 17 | single { createRoomDatabase() } 18 | } 19 | 20 | fun createRoomDatabase(): AppDatabase { 21 | val dbFile = "${fileDirectory()}/$DATABASE_NAME" 22 | return Room.databaseBuilder( 23 | name = dbFile, 24 | factory = { AppDatabase::class.instantiateImpl() } 25 | ).setDriver(BundledSQLiteDriver()) 26 | .setQueryCoroutineContext(Dispatchers.IO) 27 | .build() 28 | } 29 | 30 | private fun fileDirectory(): String { 31 | val documentDirectory: NSURL? = NSFileManager.defaultManager.URLForDirectory( 32 | directory = NSDocumentDirectory, 33 | inDomain = NSUserDomainMask, 34 | appropriateForURL = null, 35 | create = false, 36 | error = null, 37 | ) 38 | return requireNotNull(documentDirectory).path!! 39 | } 40 | -------------------------------------------------------------------------------- /composeApp/src/iosMain/kotlin/platform/Koin.kt: -------------------------------------------------------------------------------- 1 | package platform 2 | 3 | import di.initKoin 4 | import org.koin.core.Koin 5 | import org.koin.core.KoinApplication 6 | import org.koin.core.parameter.parametersOf 7 | import org.koin.core.qualifier.Qualifier 8 | import kotlinx.cinterop.BetaInteropApi 9 | import kotlinx.cinterop.ObjCClass 10 | import kotlinx.cinterop.getOriginalKotlinClass 11 | 12 | object KoinIOS { 13 | fun initialize(): KoinApplication = initKoin() 14 | } 15 | 16 | @OptIn(BetaInteropApi::class) 17 | fun Koin.get(objCClass: ObjCClass): Any { 18 | val kClazz = getOriginalKotlinClass(objCClass)!! 19 | return get(kClazz, null, null) 20 | } 21 | 22 | @OptIn(BetaInteropApi::class) 23 | fun Koin.get(objCClass: ObjCClass, qualifier: Qualifier?, parameter: Any): Any { 24 | val kClazz = getOriginalKotlinClass(objCClass)!! 25 | return get(kClazz, qualifier) { parametersOf(parameter) } 26 | } 27 | -------------------------------------------------------------------------------- /composeApp/src/iosMain/kotlin/platform/Platform.ios.kt: -------------------------------------------------------------------------------- 1 | package platform 2 | 3 | import platform.UIKit.UIDevice 4 | 5 | class IOSPlatform : Platform { 6 | override val name: String = UIDevice.currentDevice.systemName() + " " + 7 | UIDevice.currentDevice.systemVersion 8 | } 9 | 10 | actual fun getPlatform(): Platform = IOSPlatform() 11 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | kotlin.code.style=official 2 | 3 | #Gradle 4 | org.gradle.jvmargs=-Xmx2048M -Dfile.encoding=UTF-8 -Dkotlin.daemon.jvm.options\="-Xmx2048M" 5 | 6 | 7 | #Android 8 | android.nonTransitiveRClass=true 9 | android.useAndroidX=true 10 | 11 | #MPP 12 | kotlin.mpp.androidSourceSetLayoutVersion=2 13 | kotlin.mpp.enableCInteropCommonization=true 14 | 15 | #Development 16 | development=true 17 | 18 | kotlin.native.disableCompilerDaemon = true -------------------------------------------------------------------------------- /gradle/libs.versions.toml: -------------------------------------------------------------------------------- 1 | [versions] 2 | agp = "8.2.2" 3 | android-compileSdk = "34" 4 | android-minSdk = "24" 5 | android-targetSdk = "34" 6 | androidx-activityCompose = "1.9.0" 7 | androidx-appcompat = "1.6.1" 8 | androidx-constraintlayout = "2.1.4" 9 | androidx-core-ktx = "1.13.0" 10 | androidx-espresso-core = "3.5.1" 11 | androidx-material = "1.11.0" 12 | androidx-test-junit = "1.1.5" 13 | coil = "3.0.0-alpha03" 14 | compose = "1.6.6" 15 | compose-plugin = "1.6.1" 16 | datastorePreferencesCore = "1.1.0" 17 | junit = "5.10.2" 18 | kotlin = "1.9.23" 19 | kotlin-dokka = "1.9.0" 20 | kotlinx-coroutines = "1.8.0" 21 | pagingCommon = "3.3.0-alpha02-0.4.0" 22 | pagingAndroid = "3.3.0-alpha02" 23 | voyager = "1.0.0" 24 | koin-compose-kmp = "1.2.0-alpha3" 25 | koin = "3.5.4" 26 | ktor = "2.3.9" 27 | ksp = "1.9.23-1.0.19" 28 | kmmViewmodelCore = "1.0.0-ALPHA-19" 29 | spotless = "6.25.0" 30 | detekt = "1.23.5" 31 | androidx-lifecycle = "2.8.0-alpha04" 32 | androidx-startup = "1.1.1" 33 | skie = "0.6.4" 34 | androidxRoom = "2.7.0-alpha01" 35 | sqlite = "2.5.0-SNAPSHOT" 36 | 37 | [libraries] 38 | androidx-datastore-preferences-core = { module = "androidx.datastore:datastore-preferences-core", version.ref = "datastorePreferencesCore" } 39 | androidx-startup = { module = "androidx.startup:startup-runtime", version.ref = "androidx-startup" } 40 | kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } 41 | kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } 42 | kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } 43 | junit = { group = "junit", name = "junit", version.ref = "junit" } 44 | 45 | androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidx-core-ktx" } 46 | androidx-test-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-junit" } 47 | androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "androidx-espresso-core" } 48 | androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidx-appcompat" } 49 | androidx-material = { group = "com.google.android.material", name = "material", version.ref = "androidx-material" } 50 | androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "androidx-constraintlayout" } 51 | androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" } 52 | androidx-lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel", version.ref = "androidx-lifecycle" } 53 | 54 | compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose" } 55 | compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "compose" } 56 | 57 | voyager-navigator = { module = "cafe.adriel.voyager:voyager-navigator", version.ref = "voyager" } 58 | voyager-bottomSheetNavigator = { module = "cafe.adriel.voyager:voyager-bottom-sheet-navigator", version.ref = "voyager" } 59 | voyager-tabNavigator = { module = "cafe.adriel.voyager:voyager-tab-navigator", version.ref = "voyager" } 60 | voyager-transitions = { module = "cafe.adriel.voyager:voyager-transitions", version.ref = "voyager" } 61 | voyager-koin = { module = "cafe.adriel.voyager:voyager-koin", version.ref = "voyager" } 62 | 63 | koin-core = { group = "io.insert-koin", name = "koin-core", version.ref = "koin" } 64 | koin-compose = { group = "io.insert-koin", name = "koin-compose", version.ref = "koin-compose-kmp" } 65 | koin-test = { group = "io.insert-koin", name = "koin-test", version.ref = "koin" } 66 | 67 | ktor-client-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor" } 68 | ktor-client-okhttp = { group = "io.ktor", name = "ktor-client-okhttp", version.ref = "ktor" } 69 | ktor-client-darwin = { group = "io.ktor", name = "ktor-client-darwin", version.ref = "ktor" } 70 | ktor-client-auth = { group = "io.ktor", name = "ktor-client-auth", version.ref = "ktor" } 71 | ktor-serialization-kotlinx-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" } 72 | ktor-client-content-negotiation = { group = "io.ktor", name = "ktor-client-content-negotiation", version.ref = "ktor" } 73 | ktor-client-logging = { group = "io.ktor", name = "ktor-client-logging", version.ref = "ktor" } 74 | 75 | coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } 76 | coil-network-ktor = { module = "io.coil-kt.coil3:coil-network-ktor", version.ref = "coil" } 77 | 78 | paging-common = { module = "app.cash.paging:paging-common", version.ref = "pagingCommon" } 79 | paging-compose-common = { module = "app.cash.paging:paging-compose-common", version.ref = "pagingCommon" } 80 | paging-runtime-uikit = { module = "app.cash.paging:paging-runtime-uikit", version.ref = "pagingCommon" } 81 | paging-compose-androidx = { module = "androidx.paging:paging-compose", version.ref = "pagingAndroid" } 82 | paging-runtime-androidx = { module = "androidx.paging:paging-runtime", version.ref = "pagingAndroid" } 83 | paging-common-test = { module = "app.cash.paging:paging-testing", version.ref = "pagingCommon" } 84 | 85 | androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "androidxRoom" } 86 | androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "androidxRoom" } 87 | sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.ref = "sqlite" } 88 | 89 | kmp-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "androidx-lifecycle" } 90 | 91 | [bundles] 92 | voyager = ["voyager-navigator", "voyager-bottomSheetNavigator", "voyager-tabNavigator", "voyager-transitions", "voyager-koin"] 93 | ktor = ["ktor-client-core", "ktor-client-logging", "ktor-client-auth", "ktor-serialization-kotlinx-json", "ktor-client-content-negotiation"] 94 | coil = ["coil-compose", "coil-network-ktor"] 95 | paging = ["paging-common", "paging-compose-common"] 96 | paging-android = ["paging-compose-androidx", "paging-runtime-androidx"] 97 | 98 | [plugins] 99 | androidApplication = { id = "com.android.application", version.ref = "agp" } 100 | androidLibrary = { id = "com.android.library", version.ref = "agp" } 101 | jetbrainsCompose = { id = "org.jetbrains.compose", version.ref = "compose-plugin" } 102 | kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } 103 | kotlinxSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } 104 | ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } 105 | spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } 106 | detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } 107 | kotlin-dokka = { id = "org.jetbrains.dokka", version.ref = "kotlin-dokka" } 108 | skie = { id = "co.touchlab.skie", version.ref = "skie" } 109 | room = { id = "androidx.room", version.ref = "androidxRoom" } 110 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ml-opensource/KMP-Template/b699e77be3ae49e37faf7b09848849939bc58d5f/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.4-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. 47 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 48 | echo. 49 | echo Please set the JAVA_HOME variable in your environment to match the 50 | echo location of your Java installation. 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. 61 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 62 | echo. 63 | echo Please set the JAVA_HOME variable in your environment to match the 64 | echo location of your Java installation. 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 | -------------------------------------------------------------------------------- /iosApp/Configuration/Config.xcconfig: -------------------------------------------------------------------------------- 1 | TEAM_ID= 2 | BUNDLE_ID=com.monstarlab.kmp.kmp-template 3 | APP_NAME=kmp-template -------------------------------------------------------------------------------- /iosApp/Podfile: -------------------------------------------------------------------------------- 1 | # Uncomment the next line to define a global platform for your project 2 | # platform :ios, '9.0' 3 | 4 | target 'iosApp' do 5 | # Comment the next line if you don't want to use dynamic frameworks 6 | use_frameworks! 7 | 8 | # Pods for iosApp 9 | 10 | end 11 | -------------------------------------------------------------------------------- /iosApp/Podfile.lock: -------------------------------------------------------------------------------- 1 | PODFILE CHECKSUM: c8b607a42d26f21541b1abb39b8ff3ab6670f169 2 | 3 | COCOAPODS: 1.13.0 4 | -------------------------------------------------------------------------------- /iosApp/iosApp.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 54; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 058557BB273AAA24004C7B11 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 058557BA273AAA24004C7B11 /* Assets.xcassets */; }; 11 | 058557D9273AAEEB004C7B11 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 058557D8273AAEEB004C7B11 /* Preview Assets.xcassets */; }; 12 | 2152FB042600AC8F00CF470E /* iOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2152FB032600AC8F00CF470E /* iOSApp.swift */; }; 13 | 7555FF83242A565900829871 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7555FF82242A565900829871 /* ContentView.swift */; }; 14 | C1ACDA472B8DE0CB000C228D /* Koin.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1ACDA462B8DE0CB000C228D /* Koin.swift */; }; 15 | C1ACDA4A2B8DE119000C228D /* SplashScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1ACDA492B8DE119000C228D /* SplashScreen.swift */; }; 16 | C1ACDA4C2B8DE133000C228D /* HomeScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1ACDA4B2B8DE133000C228D /* HomeScreen.swift */; }; 17 | C1C2D0D62BEA31DF00BFB6AD /* LoginScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1C2D0D52BEA31DE00BFB6AD /* LoginScreen.swift */; }; 18 | E402536C8CAC213632F9EA4E /* Pods_iosApp.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BBC182C52559F275B41F2FDC /* Pods_iosApp.framework */; }; 19 | /* End PBXBuildFile section */ 20 | 21 | /* Begin PBXFileReference section */ 22 | 058557BA273AAA24004C7B11 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 23 | 058557D8273AAEEB004C7B11 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 24 | 2152FB032600AC8F00CF470E /* iOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSApp.swift; sourceTree = ""; }; 25 | 7555FF7B242A565900829871 /* kmp-template.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "kmp-template.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 26 | 7555FF82242A565900829871 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 27 | 7555FF8C242A565B00829871 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 28 | AB3632DC29227652001CCB65 /* Config.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Config.xcconfig; sourceTree = ""; }; 29 | BBC182C52559F275B41F2FDC /* Pods_iosApp.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iosApp.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 30 | C0D9217A7F97513F9651E216 /* Pods-iosApp.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iosApp.debug.xcconfig"; path = "Target Support Files/Pods-iosApp/Pods-iosApp.debug.xcconfig"; sourceTree = ""; }; 31 | C1ACDA462B8DE0CB000C228D /* Koin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Koin.swift; sourceTree = ""; }; 32 | C1ACDA492B8DE119000C228D /* SplashScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplashScreen.swift; sourceTree = ""; }; 33 | C1ACDA4B2B8DE133000C228D /* HomeScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreen.swift; sourceTree = ""; }; 34 | C1C2D0D52BEA31DE00BFB6AD /* LoginScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginScreen.swift; sourceTree = ""; }; 35 | C1F49C30569FCD4DF157F9E1 /* Pods-iosApp.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iosApp.release.xcconfig"; path = "Target Support Files/Pods-iosApp/Pods-iosApp.release.xcconfig"; sourceTree = ""; }; 36 | /* End PBXFileReference section */ 37 | 38 | /* Begin PBXFrameworksBuildPhase section */ 39 | B92378962B6B1156000C7307 /* Frameworks */ = { 40 | isa = PBXFrameworksBuildPhase; 41 | buildActionMask = 2147483647; 42 | files = ( 43 | E402536C8CAC213632F9EA4E /* Pods_iosApp.framework in Frameworks */, 44 | ); 45 | runOnlyForDeploymentPostprocessing = 0; 46 | }; 47 | /* End PBXFrameworksBuildPhase section */ 48 | 49 | /* Begin PBXGroup section */ 50 | 058557D7273AAEEB004C7B11 /* Preview Content */ = { 51 | isa = PBXGroup; 52 | children = ( 53 | 058557D8273AAEEB004C7B11 /* Preview Assets.xcassets */, 54 | ); 55 | path = "Preview Content"; 56 | sourceTree = ""; 57 | }; 58 | 2BEDAC8B03DCC2908730247E /* Pods */ = { 59 | isa = PBXGroup; 60 | children = ( 61 | C0D9217A7F97513F9651E216 /* Pods-iosApp.debug.xcconfig */, 62 | C1F49C30569FCD4DF157F9E1 /* Pods-iosApp.release.xcconfig */, 63 | ); 64 | path = Pods; 65 | sourceTree = ""; 66 | }; 67 | 42799AB246E5F90AF97AA0EF /* Frameworks */ = { 68 | isa = PBXGroup; 69 | children = ( 70 | BBC182C52559F275B41F2FDC /* Pods_iosApp.framework */, 71 | ); 72 | name = Frameworks; 73 | sourceTree = ""; 74 | }; 75 | 7555FF72242A565900829871 = { 76 | isa = PBXGroup; 77 | children = ( 78 | AB1DB47929225F7C00F7AF9C /* Configuration */, 79 | 7555FF7D242A565900829871 /* iosApp */, 80 | 7555FF7C242A565900829871 /* Products */, 81 | 42799AB246E5F90AF97AA0EF /* Frameworks */, 82 | 2BEDAC8B03DCC2908730247E /* Pods */, 83 | ); 84 | sourceTree = ""; 85 | }; 86 | 7555FF7C242A565900829871 /* Products */ = { 87 | isa = PBXGroup; 88 | children = ( 89 | 7555FF7B242A565900829871 /* kmp-template.app */, 90 | ); 91 | name = Products; 92 | sourceTree = ""; 93 | }; 94 | 7555FF7D242A565900829871 /* iosApp */ = { 95 | isa = PBXGroup; 96 | children = ( 97 | C1ACDA432B8DE087000C228D /* system */, 98 | C1ACDA422B8DE078000C228D /* presentation */, 99 | 058557BA273AAA24004C7B11 /* Assets.xcassets */, 100 | 7555FF8C242A565B00829871 /* Info.plist */, 101 | 058557D7273AAEEB004C7B11 /* Preview Content */, 102 | ); 103 | path = iosApp; 104 | sourceTree = ""; 105 | }; 106 | AB1DB47929225F7C00F7AF9C /* Configuration */ = { 107 | isa = PBXGroup; 108 | children = ( 109 | AB3632DC29227652001CCB65 /* Config.xcconfig */, 110 | ); 111 | path = Configuration; 112 | sourceTree = ""; 113 | }; 114 | C1ACDA422B8DE078000C228D /* presentation */ = { 115 | isa = PBXGroup; 116 | children = ( 117 | 2152FB032600AC8F00CF470E /* iOSApp.swift */, 118 | 7555FF82242A565900829871 /* ContentView.swift */, 119 | C1ACDA482B8DE0FE000C228D /* feature */, 120 | ); 121 | path = presentation; 122 | sourceTree = ""; 123 | }; 124 | C1ACDA432B8DE087000C228D /* system */ = { 125 | isa = PBXGroup; 126 | children = ( 127 | C1ACDA462B8DE0CB000C228D /* Koin.swift */, 128 | ); 129 | path = system; 130 | sourceTree = ""; 131 | }; 132 | C1ACDA482B8DE0FE000C228D /* feature */ = { 133 | isa = PBXGroup; 134 | children = ( 135 | C1ACDA492B8DE119000C228D /* SplashScreen.swift */, 136 | C1ACDA4B2B8DE133000C228D /* HomeScreen.swift */, 137 | C1C2D0D52BEA31DE00BFB6AD /* LoginScreen.swift */, 138 | ); 139 | path = feature; 140 | sourceTree = ""; 141 | }; 142 | /* End PBXGroup section */ 143 | 144 | /* Begin PBXNativeTarget section */ 145 | 7555FF7A242A565900829871 /* iosApp */ = { 146 | isa = PBXNativeTarget; 147 | buildConfigurationList = 7555FFA5242A565B00829871 /* Build configuration list for PBXNativeTarget "iosApp" */; 148 | buildPhases = ( 149 | 3B2AA6F83D81A49150BE1815 /* [CP] Check Pods Manifest.lock */, 150 | F36B1CEB2AD83DDC00CB74D5 /* Compile Kotlin Framework */, 151 | 7555FF77242A565900829871 /* Sources */, 152 | B92378962B6B1156000C7307 /* Frameworks */, 153 | 7555FF79242A565900829871 /* Resources */, 154 | ); 155 | buildRules = ( 156 | ); 157 | dependencies = ( 158 | ); 159 | name = iosApp; 160 | packageProductDependencies = ( 161 | ); 162 | productName = iosApp; 163 | productReference = 7555FF7B242A565900829871 /* kmp-template.app */; 164 | productType = "com.apple.product-type.application"; 165 | }; 166 | /* End PBXNativeTarget section */ 167 | 168 | /* Begin PBXProject section */ 169 | 7555FF73242A565900829871 /* Project object */ = { 170 | isa = PBXProject; 171 | attributes = { 172 | LastSwiftUpdateCheck = 1130; 173 | LastUpgradeCheck = 1130; 174 | ORGANIZATIONNAME = orgName; 175 | TargetAttributes = { 176 | 7555FF7A242A565900829871 = { 177 | CreatedOnToolsVersion = 11.3.1; 178 | }; 179 | }; 180 | }; 181 | buildConfigurationList = 7555FF76242A565900829871 /* Build configuration list for PBXProject "iosApp" */; 182 | compatibilityVersion = "Xcode 12.0"; 183 | developmentRegion = en; 184 | hasScannedForEncodings = 0; 185 | knownRegions = ( 186 | en, 187 | Base, 188 | ); 189 | mainGroup = 7555FF72242A565900829871; 190 | packageReferences = ( 191 | ); 192 | productRefGroup = 7555FF7C242A565900829871 /* Products */; 193 | projectDirPath = ""; 194 | projectRoot = ""; 195 | targets = ( 196 | 7555FF7A242A565900829871 /* iosApp */, 197 | ); 198 | }; 199 | /* End PBXProject section */ 200 | 201 | /* Begin PBXResourcesBuildPhase section */ 202 | 7555FF79242A565900829871 /* Resources */ = { 203 | isa = PBXResourcesBuildPhase; 204 | buildActionMask = 2147483647; 205 | files = ( 206 | 058557D9273AAEEB004C7B11 /* Preview Assets.xcassets in Resources */, 207 | 058557BB273AAA24004C7B11 /* Assets.xcassets in Resources */, 208 | ); 209 | runOnlyForDeploymentPostprocessing = 0; 210 | }; 211 | /* End PBXResourcesBuildPhase section */ 212 | 213 | /* Begin PBXShellScriptBuildPhase section */ 214 | 3B2AA6F83D81A49150BE1815 /* [CP] Check Pods Manifest.lock */ = { 215 | isa = PBXShellScriptBuildPhase; 216 | buildActionMask = 2147483647; 217 | files = ( 218 | ); 219 | inputFileListPaths = ( 220 | ); 221 | inputPaths = ( 222 | "${PODS_PODFILE_DIR_PATH}/Podfile.lock", 223 | "${PODS_ROOT}/Manifest.lock", 224 | ); 225 | name = "[CP] Check Pods Manifest.lock"; 226 | outputFileListPaths = ( 227 | ); 228 | outputPaths = ( 229 | "$(DERIVED_FILE_DIR)/Pods-iosApp-checkManifestLockResult.txt", 230 | ); 231 | runOnlyForDeploymentPostprocessing = 0; 232 | shellPath = /bin/sh; 233 | shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; 234 | showEnvVarsInLog = 0; 235 | }; 236 | F36B1CEB2AD83DDC00CB74D5 /* Compile Kotlin Framework */ = { 237 | isa = PBXShellScriptBuildPhase; 238 | buildActionMask = 2147483647; 239 | files = ( 240 | ); 241 | inputFileListPaths = ( 242 | ); 243 | inputPaths = ( 244 | ); 245 | name = "Compile Kotlin Framework"; 246 | outputFileListPaths = ( 247 | ); 248 | outputPaths = ( 249 | ); 250 | runOnlyForDeploymentPostprocessing = 0; 251 | shellPath = /bin/sh; 252 | shellScript = "if [ \"YES\" = \"$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED\" ]; then\n echo \"Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \\\"YES\\\"\"\n exit 0\nfi\ncd \"$SRCROOT/..\"\n./gradlew :composeApp:embedAndSignAppleFrameworkForXcode\n"; 253 | }; 254 | /* End PBXShellScriptBuildPhase section */ 255 | 256 | /* Begin PBXSourcesBuildPhase section */ 257 | 7555FF77242A565900829871 /* Sources */ = { 258 | isa = PBXSourcesBuildPhase; 259 | buildActionMask = 2147483647; 260 | files = ( 261 | C1ACDA4A2B8DE119000C228D /* SplashScreen.swift in Sources */, 262 | C1ACDA4C2B8DE133000C228D /* HomeScreen.swift in Sources */, 263 | 2152FB042600AC8F00CF470E /* iOSApp.swift in Sources */, 264 | C1ACDA472B8DE0CB000C228D /* Koin.swift in Sources */, 265 | 7555FF83242A565900829871 /* ContentView.swift in Sources */, 266 | C1C2D0D62BEA31DF00BFB6AD /* LoginScreen.swift in Sources */, 267 | ); 268 | runOnlyForDeploymentPostprocessing = 0; 269 | }; 270 | /* End PBXSourcesBuildPhase section */ 271 | 272 | /* Begin XCBuildConfiguration section */ 273 | 7555FFA3242A565B00829871 /* Debug */ = { 274 | isa = XCBuildConfiguration; 275 | baseConfigurationReference = AB3632DC29227652001CCB65 /* Config.xcconfig */; 276 | buildSettings = { 277 | ALWAYS_SEARCH_USER_PATHS = NO; 278 | CLANG_ANALYZER_NONNULL = YES; 279 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 280 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 281 | CLANG_CXX_LIBRARY = "libc++"; 282 | CLANG_ENABLE_MODULES = YES; 283 | CLANG_ENABLE_OBJC_ARC = YES; 284 | CLANG_ENABLE_OBJC_WEAK = YES; 285 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 286 | CLANG_WARN_BOOL_CONVERSION = YES; 287 | CLANG_WARN_COMMA = YES; 288 | CLANG_WARN_CONSTANT_CONVERSION = YES; 289 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 290 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 291 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 292 | CLANG_WARN_EMPTY_BODY = YES; 293 | CLANG_WARN_ENUM_CONVERSION = YES; 294 | CLANG_WARN_INFINITE_RECURSION = YES; 295 | CLANG_WARN_INT_CONVERSION = YES; 296 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 297 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 298 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 299 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 300 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 301 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 302 | CLANG_WARN_STRICT_PROTOTYPES = YES; 303 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 304 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 305 | CLANG_WARN_UNREACHABLE_CODE = YES; 306 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 307 | COPY_PHASE_STRIP = NO; 308 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 309 | ENABLE_STRICT_OBJC_MSGSEND = YES; 310 | ENABLE_TESTABILITY = YES; 311 | GCC_C_LANGUAGE_STANDARD = gnu11; 312 | GCC_DYNAMIC_NO_PIC = NO; 313 | GCC_NO_COMMON_BLOCKS = YES; 314 | GCC_OPTIMIZATION_LEVEL = 0; 315 | GCC_PREPROCESSOR_DEFINITIONS = ( 316 | "DEBUG=1", 317 | "$(inherited)", 318 | ); 319 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 320 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 321 | GCC_WARN_UNDECLARED_SELECTOR = YES; 322 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 323 | GCC_WARN_UNUSED_FUNCTION = YES; 324 | GCC_WARN_UNUSED_VARIABLE = YES; 325 | IPHONEOS_DEPLOYMENT_TARGET = 15.2; 326 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 327 | MTL_FAST_MATH = YES; 328 | ONLY_ACTIVE_ARCH = YES; 329 | SDKROOT = iphoneos; 330 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 331 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 332 | }; 333 | name = Debug; 334 | }; 335 | 7555FFA4242A565B00829871 /* Release */ = { 336 | isa = XCBuildConfiguration; 337 | baseConfigurationReference = AB3632DC29227652001CCB65 /* Config.xcconfig */; 338 | buildSettings = { 339 | ALWAYS_SEARCH_USER_PATHS = NO; 340 | CLANG_ANALYZER_NONNULL = YES; 341 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 342 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 343 | CLANG_CXX_LIBRARY = "libc++"; 344 | CLANG_ENABLE_MODULES = YES; 345 | CLANG_ENABLE_OBJC_ARC = YES; 346 | CLANG_ENABLE_OBJC_WEAK = YES; 347 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 348 | CLANG_WARN_BOOL_CONVERSION = YES; 349 | CLANG_WARN_COMMA = YES; 350 | CLANG_WARN_CONSTANT_CONVERSION = YES; 351 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 352 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 353 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 354 | CLANG_WARN_EMPTY_BODY = YES; 355 | CLANG_WARN_ENUM_CONVERSION = YES; 356 | CLANG_WARN_INFINITE_RECURSION = YES; 357 | CLANG_WARN_INT_CONVERSION = YES; 358 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 359 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 360 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 361 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 362 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 363 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 364 | CLANG_WARN_STRICT_PROTOTYPES = YES; 365 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 366 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 367 | CLANG_WARN_UNREACHABLE_CODE = YES; 368 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 369 | COPY_PHASE_STRIP = NO; 370 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 371 | ENABLE_NS_ASSERTIONS = NO; 372 | ENABLE_STRICT_OBJC_MSGSEND = YES; 373 | GCC_C_LANGUAGE_STANDARD = gnu11; 374 | GCC_NO_COMMON_BLOCKS = YES; 375 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 376 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 377 | GCC_WARN_UNDECLARED_SELECTOR = YES; 378 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 379 | GCC_WARN_UNUSED_FUNCTION = YES; 380 | GCC_WARN_UNUSED_VARIABLE = YES; 381 | IPHONEOS_DEPLOYMENT_TARGET = 15.2; 382 | MTL_ENABLE_DEBUG_INFO = NO; 383 | MTL_FAST_MATH = YES; 384 | SDKROOT = iphoneos; 385 | SWIFT_COMPILATION_MODE = wholemodule; 386 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 387 | VALIDATE_PRODUCT = YES; 388 | }; 389 | name = Release; 390 | }; 391 | 7555FFA6242A565B00829871 /* Debug */ = { 392 | isa = XCBuildConfiguration; 393 | baseConfigurationReference = C0D9217A7F97513F9651E216 /* Pods-iosApp.debug.xcconfig */; 394 | buildSettings = { 395 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 396 | CODE_SIGN_IDENTITY = "Apple Development"; 397 | CODE_SIGN_STYLE = Automatic; 398 | DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; 399 | DEVELOPMENT_TEAM = G227YKW72F; 400 | ENABLE_PREVIEWS = YES; 401 | FRAMEWORK_SEARCH_PATHS = ( 402 | "$(SRCROOT)/../shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)\n$(SRCROOT)/../composeApp/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)", 403 | ); 404 | INFOPLIST_FILE = iosApp/Info.plist; 405 | IPHONEOS_DEPLOYMENT_TARGET = 15.2; 406 | LD_RUNPATH_SEARCH_PATHS = "$(inherited)"; 407 | OTHER_LDFLAGS = ( 408 | "$(inherited)", 409 | "-framework", 410 | composeApp, 411 | "-lsqlite3", 412 | ); 413 | PRODUCT_BUNDLE_IDENTIFIER = "${BUNDLE_ID}${TEAM_ID}"; 414 | PRODUCT_NAME = "${APP_NAME}"; 415 | PROVISIONING_PROFILE_SPECIFIER = ""; 416 | SWIFT_VERSION = 5.0; 417 | TARGETED_DEVICE_FAMILY = "1,2"; 418 | }; 419 | name = Debug; 420 | }; 421 | 7555FFA7242A565B00829871 /* Release */ = { 422 | isa = XCBuildConfiguration; 423 | baseConfigurationReference = C1F49C30569FCD4DF157F9E1 /* Pods-iosApp.release.xcconfig */; 424 | buildSettings = { 425 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 426 | CODE_SIGN_IDENTITY = "Apple Development"; 427 | CODE_SIGN_STYLE = Automatic; 428 | DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; 429 | DEVELOPMENT_TEAM = G227YKW72F; 430 | ENABLE_PREVIEWS = YES; 431 | FRAMEWORK_SEARCH_PATHS = ( 432 | "$(SRCROOT)/../shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)\n$(SRCROOT)/../composeApp/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)", 433 | ); 434 | INFOPLIST_FILE = iosApp/Info.plist; 435 | IPHONEOS_DEPLOYMENT_TARGET = 15.2; 436 | LD_RUNPATH_SEARCH_PATHS = "$(inherited)"; 437 | OTHER_LDFLAGS = ( 438 | "$(inherited)", 439 | "-framework", 440 | composeApp, 441 | "-lsqlite3", 442 | ); 443 | PRODUCT_BUNDLE_IDENTIFIER = "${BUNDLE_ID}${TEAM_ID}"; 444 | PRODUCT_NAME = "${APP_NAME}"; 445 | PROVISIONING_PROFILE_SPECIFIER = ""; 446 | SWIFT_VERSION = 5.0; 447 | TARGETED_DEVICE_FAMILY = "1,2"; 448 | }; 449 | name = Release; 450 | }; 451 | /* End XCBuildConfiguration section */ 452 | 453 | /* Begin XCConfigurationList section */ 454 | 7555FF76242A565900829871 /* Build configuration list for PBXProject "iosApp" */ = { 455 | isa = XCConfigurationList; 456 | buildConfigurations = ( 457 | 7555FFA3242A565B00829871 /* Debug */, 458 | 7555FFA4242A565B00829871 /* Release */, 459 | ); 460 | defaultConfigurationIsVisible = 0; 461 | defaultConfigurationName = Release; 462 | }; 463 | 7555FFA5242A565B00829871 /* Build configuration list for PBXNativeTarget "iosApp" */ = { 464 | isa = XCConfigurationList; 465 | buildConfigurations = ( 466 | 7555FFA6242A565B00829871 /* Debug */, 467 | 7555FFA7242A565B00829871 /* Release */, 468 | ); 469 | defaultConfigurationIsVisible = 0; 470 | defaultConfigurationName = Release; 471 | }; 472 | /* End XCConfigurationList section */ 473 | }; 474 | rootObject = 7555FF73242A565900829871 /* Project object */; 475 | } 476 | -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "app-icon-1024.png", 5 | "idiom" : "universal", 6 | "platform" : "ios", 7 | "size" : "1024x1024" 8 | } 9 | ], 10 | "info" : { 11 | "author" : "xcode", 12 | "version" : 1 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ml-opensource/KMP-Template/b699e77be3ae49e37faf7b09848849939bc58d5f/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024.png -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } -------------------------------------------------------------------------------- /iosApp/iosApp/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 | CADisableMinimumFrameDurationOnPhone 24 | 25 | UIApplicationSceneManifest 26 | 27 | UIApplicationSupportsMultipleScenes 28 | 29 | 30 | UILaunchScreen 31 | 32 | UIRequiredDeviceCapabilities 33 | 34 | armv7 35 | 36 | UISupportedInterfaceOrientations 37 | 38 | UIInterfaceOrientationPortrait 39 | UIInterfaceOrientationLandscapeLeft 40 | UIInterfaceOrientationLandscapeRight 41 | 42 | UISupportedInterfaceOrientations~ipad 43 | 44 | UIInterfaceOrientationPortrait 45 | UIInterfaceOrientationPortraitUpsideDown 46 | UIInterfaceOrientationLandscapeLeft 47 | UIInterfaceOrientationLandscapeRight 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /iosApp/iosApp/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } -------------------------------------------------------------------------------- /iosApp/iosApp/presentation/ContentView.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import SwiftUI 3 | import ComposeApp 4 | 5 | struct ComposeView: UIViewControllerRepresentable { 6 | func makeUIViewController(context: Context) -> UIViewController { 7 | MainViewControllerKt.MainViewController() 8 | } 9 | 10 | func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} 11 | } 12 | 13 | /** 14 | Entry point for the iOS App. 15 | 16 | Uncomment the line 17 | ``` 18 | SplashScreen() 19 | ``` 20 | and comment out the line 21 | ``` 22 | ComposeView().ignoresSafeArea(.keyboard) 23 | ``` 24 | to run the app in Swift UI. 25 | */ 26 | struct ContentView: View { 27 | var body: some View { 28 | // SplashScreen() 29 | ComposeView().ignoresSafeArea(.keyboard) // Compose has own keyboard handler 30 | } 31 | } 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /iosApp/iosApp/presentation/feature/HomeScreen.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HomeScreen.swift 3 | // iosApp 4 | // 5 | // Created by Golam Shakib Khan on 27/2/24. 6 | // Copyright © 2024 orgName. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import ComposeApp 11 | 12 | struct HomeScreen: View { 13 | @State var viewModel = HomeViewModelProvider().provide() 14 | @State var screenState: HomeScreenState = HomeScreenState() 15 | 16 | var body: some View { 17 | NavigationView { 18 | ZStack { 19 | ScrollView(showsIndicators: false) { 20 | VStack(alignment: .leading) { 21 | ForEach(screenState.productList, id: \.id) { product in 22 | ProductItemView(product: product) 23 | } 24 | } 25 | } 26 | 27 | if screenState.isLoading { 28 | ProgressView() 29 | } 30 | } 31 | .navigationBarTitleDisplayMode(.inline) 32 | .navigationTitle("Products") 33 | .onAppear { 34 | viewModel.handleIntent(intent: HomeScreenIntent.OnLaunch()) 35 | } 36 | .task { 37 | for await state in viewModel.state { 38 | screenState = state 39 | } 40 | } 41 | } 42 | } 43 | } 44 | 45 | struct ProductItemView: View { 46 | let product: Product 47 | 48 | var body: some View { 49 | ZStack { 50 | AsyncImage(url: URL(string: product.thumbnail)) { image in 51 | image.image? 52 | .resizable() 53 | .scaledToFill() 54 | } 55 | .cornerRadius(8.0) 56 | 57 | VStack { 58 | Spacer() 59 | 60 | Text(product.title) 61 | .foregroundColor(.white) 62 | .frame(maxWidth: .infinity) 63 | .padding(8.0) 64 | } 65 | .background(LinearGradient(gradient: Gradient(colors: [Color.black, Color.gray.opacity(0.2)]), startPoint: .bottom, endPoint: .center)) 66 | .cornerRadius(8.0) 67 | } 68 | .padding(.horizontal, 8) 69 | .padding(.vertical, 2) 70 | } 71 | } 72 | 73 | #Preview { 74 | HomeScreen() 75 | } 76 | -------------------------------------------------------------------------------- /iosApp/iosApp/presentation/feature/LoginScreen.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoginScreen.swift 3 | // iosApp 4 | // 5 | // Created by Golam Shakib Khan on 7/5/24. 6 | // Copyright © 2024 orgName. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import ComposeApp 11 | 12 | struct LoginScreen: View { 13 | @State var viewModel = LoginViewModelProvider().provide() 14 | @State var screenState: LoginScreenState = LoginScreenState() 15 | 16 | var body: some View { 17 | ZStack { 18 | if screenState.isLoggedIn { 19 | HomeScreen() 20 | } else { 21 | VStack { 22 | TextField("Email", text: $screenState.email) 23 | .padding() 24 | .overlay( 25 | RoundedRectangle(cornerRadius: 4) 26 | .stroke(.gray, lineWidth: 1) 27 | ) 28 | .padding(.horizontal) 29 | 30 | SecureField("Password", text: $screenState.password) 31 | .padding() 32 | .overlay( 33 | RoundedRectangle(cornerRadius: 4) 34 | .stroke(.gray, lineWidth: 1) 35 | ) 36 | .padding(.horizontal) 37 | 38 | Button(action: { 39 | viewModel.handleIntent(intent: LoginIntent.Login()) 40 | }, label: { 41 | if screenState.isLoading { 42 | ProgressView() 43 | } else { 44 | Text("Login") 45 | .font(.headline) 46 | .foregroundColor(.black) 47 | } 48 | }) 49 | .frame(maxWidth: .infinity, maxHeight: 48) 50 | .background(.yellow) 51 | .cornerRadius(8) 52 | .padding(.horizontal) 53 | .padding(.vertical) 54 | } 55 | } 56 | } 57 | .task { 58 | for await state in viewModel.state { 59 | screenState = state 60 | } 61 | } 62 | } 63 | } 64 | 65 | #Preview { 66 | LoginScreen() 67 | } 68 | -------------------------------------------------------------------------------- /iosApp/iosApp/presentation/feature/SplashScreen.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SplashScreen.swift 3 | // iosApp 4 | // 5 | // Created by Golam Shakib Khan on 27/2/24. 6 | // Copyright © 2024 orgName. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import ComposeApp 11 | 12 | struct SplashScreen: View { 13 | @State var viewModel = SplashViewModelProvider().provide() 14 | @State var screenState: SplashScreenState = SplashScreenState() 15 | @State var navigate = false 16 | 17 | var body: some View { 18 | ZStack { 19 | if navigate && screenState.user != nil { 20 | HomeScreen() 21 | } else if navigate && screenState.user == nil { 22 | LoginScreen() 23 | } else { 24 | VStack { 25 | Image(systemName: "swift") 26 | .resizable() 27 | .aspectRatio(contentMode: .fill) 28 | .frame(width: 200, height: 200) 29 | .foregroundColor(.blue) 30 | 31 | Text(screenState.greeting) 32 | .padding(.top, 24) 33 | } 34 | .transition(AnyTransition.opacity.combined(with: .move(edge: .top))) 35 | .animation(.easeInOut(duration: 0.35), value: screenState.showContent) 36 | } 37 | } 38 | .onAppear { 39 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { 40 | withAnimation { 41 | viewModel.handleIntent(intent: SplashScreenIntent.OnContentVisibilityChange(show: true)) 42 | } 43 | } 44 | 45 | DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { 46 | withAnimation { 47 | viewModel.handleIntent(intent: SplashScreenIntent.OnContentVisibilityChange(show: false)) 48 | } 49 | } 50 | 51 | DispatchQueue.main.asyncAfter(deadline: .now() + 2) { 52 | withAnimation { 53 | navigate.toggle() 54 | } 55 | } 56 | } 57 | .task { 58 | for await state in viewModel.state { 59 | screenState = state 60 | } 61 | } 62 | } 63 | } 64 | 65 | #Preview { 66 | SplashScreen() 67 | } 68 | -------------------------------------------------------------------------------- /iosApp/iosApp/presentation/iOSApp.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | @main 4 | struct iOSApp: App { 5 | init() { Koin.start() } 6 | 7 | var body: some Scene { 8 | WindowGroup { 9 | ContentView() 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /iosApp/iosApp/system/Koin.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Koin.swift 3 | // iosApp 4 | // 5 | // Created by Golam Shakib Khan on 27/2/24. 6 | // Copyright © 2024 orgName. All rights reserved. 7 | // 8 | 9 | import ComposeApp 10 | 11 | final class Koin { 12 | private var core: Koin_coreKoin? 13 | 14 | static let instance = Koin() 15 | 16 | static func start() { 17 | if instance.core == nil { 18 | let app = KoinIOS.shared.initialize() 19 | instance.core = app.koin 20 | } 21 | if instance.core == nil { 22 | fatalError("Can't initialize Koin.") 23 | } 24 | } 25 | 26 | private init() { 27 | 28 | } 29 | 30 | func get() -> T { 31 | guard let core = core else { 32 | fatalError("You should call `start()` before using \(#function)") 33 | } 34 | 35 | guard let result = core.get(objCClass: T.self) as? T else { 36 | fatalError("Koin can't provide an instance of type: \(T.self)") 37 | } 38 | 39 | return result 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | rootProject.name = "KMP-Template" 2 | enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") 3 | 4 | pluginManagement { 5 | repositories { 6 | maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") 7 | google() 8 | gradlePluginPortal() 9 | mavenCentral() 10 | } 11 | } 12 | 13 | dependencyResolutionManagement { 14 | repositories { 15 | google() 16 | mavenCentral() 17 | maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") 18 | } 19 | } 20 | 21 | include(":composeApp") --------------------------------------------------------------------------------