15
examples/todoapp/.gitignore
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
*.iml
|
||||
.gradle
|
||||
/local.properties
|
||||
/.idea
|
||||
/.idea/caches
|
||||
/.idea/libraries
|
||||
/.idea/modules.xml
|
||||
/.idea/workspace.xml
|
||||
/.idea/navEditor.xml
|
||||
/.idea/assetWizardSettings.xml
|
||||
.DS_Store
|
||||
/build
|
||||
/captures
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
28
examples/todoapp/README.md
Executable file
@@ -0,0 +1,28 @@
|
||||
An example of Kotlin Multiplatform todo app with shared Jetpack Compose UI.
|
||||
|
||||
Supported targets: `Android` and `JVM`.
|
||||
|
||||
Libraries used:
|
||||
- Jetpack Compose - shared UI
|
||||
- [Decompose](https://github.com/arkivanov/Decompose) - navigation and lifecycle
|
||||
- [MVIKotlin](https://github.com/arkivanov/MVIKotlin) - presentation and business logic
|
||||
- [Reaktive](https://github.com/badoo/Reaktive) - background processing and data transformation
|
||||
- [SQLDelight](https://github.com/cashapp/sqldelight) - data storage
|
||||
|
||||
There are multiple common modules:
|
||||
- `utils` - just some useful helpers
|
||||
- `database` - SQLDelight database definition
|
||||
- `main` - displays a list of todo items and a text field
|
||||
- `edit` - accepts an item id and allows editing
|
||||
- `root` - navigates between `main` and `edit` screens
|
||||
|
||||
The `root` module is integrated into both Android and Desktop apps.
|
||||
|
||||
Features:
|
||||
- 99% of the code is shared: data, business logic, presentation, navigation and UI
|
||||
- View state is preserved when navigating between screens, Android configuration change, etc.
|
||||
- Model-View-Intent (aka MVI) architectural pattern
|
||||
|
||||
To run the desktop application execute the following command: `./gradlew desktop:run`.
|
||||
|
||||
To run the Android application you will need to open the project in Intellij IDEA or Android Studio and run "android" configuration.
|
||||
1
examples/todoapp/android/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/build
|
||||
39
examples/todoapp/android/build.gradle.kts
Executable file
@@ -0,0 +1,39 @@
|
||||
import org.jetbrains.compose.compose
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
kotlin("android")
|
||||
id("org.jetbrains.compose")
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdkVersion(30)
|
||||
|
||||
defaultConfig {
|
||||
minSdkVersion(23)
|
||||
targetSdkVersion(30)
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
}
|
||||
|
||||
packagingOptions {
|
||||
exclude("META-INF/*")
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":common:database"))
|
||||
implementation(project(":common:utils"))
|
||||
implementation(project(":common:root"))
|
||||
implementation(compose.material)
|
||||
implementation(Deps.ArkIvanov.MVIKotlin.mvikotlin)
|
||||
implementation(Deps.ArkIvanov.MVIKotlin.mvikotlinMain)
|
||||
implementation(Deps.ArkIvanov.MVIKotlin.mvikotlinLogging)
|
||||
implementation(Deps.ArkIvanov.MVIKotlin.mvikotlinTimeTravel)
|
||||
implementation(Deps.ArkIvanov.Decompose.decompose)
|
||||
}
|
||||
27
examples/todoapp/android/src/main/AndroidManifest.xml
Executable file
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="example.todo.android">
|
||||
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:name=".App"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.AppCompat.Light.NoActionBar">
|
||||
<activity
|
||||
android:name="example.todo.android.MainActivity"
|
||||
android:label="@string/app_name">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,13 @@
|
||||
package example.todo.android
|
||||
|
||||
import android.app.Application
|
||||
import com.arkivanov.mvikotlin.timetravel.server.TimeTravelServer
|
||||
|
||||
class App : Application() {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
TimeTravelServer().start()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package example.todo.android
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
val purple200 = Color(0xFFBB86FC)
|
||||
val purple500 = Color(0xFF6200EE)
|
||||
val purple700 = Color(0xFF3700B3)
|
||||
val teal200 = Color(0xFF03DAC5)
|
||||
48
examples/todoapp/android/src/main/java/example/todo/android/MainActivity.kt
Executable file
@@ -0,0 +1,48 @@
|
||||
package example.todo.android
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Surface
|
||||
import androidx.compose.ui.platform.setContent
|
||||
import com.arkivanov.decompose.DefaultComponentContext
|
||||
import com.arkivanov.decompose.backpressed.toBackPressedDispatched
|
||||
import com.arkivanov.decompose.instancekeeper.toInstanceKeeper
|
||||
import com.arkivanov.decompose.lifecycle.asDecomposeLifecycle
|
||||
import com.arkivanov.decompose.statekeeper.toStateKeeper
|
||||
import com.arkivanov.mvikotlin.core.store.StoreFactory
|
||||
import com.arkivanov.mvikotlin.logging.store.LoggingStoreFactory
|
||||
import com.arkivanov.mvikotlin.main.store.DefaultStoreFactory
|
||||
import com.arkivanov.mvikotlin.timetravel.store.TimeTravelStoreFactory
|
||||
import example.todo.common.database.TodoDatabaseDriver
|
||||
import example.todo.common.root.TodoRoot
|
||||
import example.todo.database.TodoDatabase
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val todoRoot =
|
||||
TodoRoot(
|
||||
componentContext = DefaultComponentContext(
|
||||
lifecycle = lifecycle.asDecomposeLifecycle(),
|
||||
stateKeeper = savedStateRegistry.toStateKeeper(),
|
||||
instanceKeeper = viewModelStore.toInstanceKeeper(),
|
||||
backPressedDispatcher = onBackPressedDispatcher.toBackPressedDispatched(lifecycle)
|
||||
),
|
||||
dependencies = object : TodoRoot.Dependencies {
|
||||
// You can play with time travel using IDEA plugin: https://arkivanov.github.io/MVIKotlin/time_travel.html
|
||||
override val storeFactory: StoreFactory = LoggingStoreFactory(TimeTravelStoreFactory(DefaultStoreFactory))
|
||||
override val database: TodoDatabase = TodoDatabase(TodoDatabaseDriver(this@MainActivity))
|
||||
}
|
||||
)
|
||||
|
||||
setContent {
|
||||
ComposeAppTheme {
|
||||
Surface(color = MaterialTheme.colors.background) {
|
||||
todoRoot()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package example.todo.android
|
||||
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.Shapes
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
val shapes = Shapes(
|
||||
small = RoundedCornerShape(4.dp),
|
||||
medium = RoundedCornerShape(4.dp),
|
||||
large = RoundedCornerShape(0.dp)
|
||||
)
|
||||
@@ -0,0 +1,35 @@
|
||||
package example.todo.android
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.darkColors
|
||||
import androidx.compose.material.lightColors
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
private val DarkColorPalette = darkColors(
|
||||
primary = purple200,
|
||||
primaryVariant = purple700,
|
||||
secondary = teal200
|
||||
)
|
||||
|
||||
private val LightColorPalette = lightColors(
|
||||
primary = purple500,
|
||||
primaryVariant = purple700,
|
||||
secondary = teal200
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun ComposeAppTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable() () -> Unit) {
|
||||
val colors = if (darkTheme) {
|
||||
DarkColorPalette
|
||||
} else {
|
||||
LightColorPalette
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colors = colors,
|
||||
typography = typography,
|
||||
shapes = shapes,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package example.todo.android
|
||||
|
||||
import androidx.compose.material.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
// Set of Material typography styles to start with
|
||||
val typography = Typography(
|
||||
body1 = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,34 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
|
||||
android:strokeWidth="1"
|
||||
android:strokeColor="#00000000">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="78.5885"
|
||||
android:endY="90.9159"
|
||||
android:startX="48.7653"
|
||||
android:startY="61.0927"
|
||||
android:type="linear">
|
||||
<item
|
||||
android:color="#44000000"
|
||||
android:offset="0.0" />
|
||||
<item
|
||||
android:color="#00000000"
|
||||
android:offset="1.0" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
|
||||
android:strokeWidth="1"
|
||||
android:strokeColor="#00000000" />
|
||||
</vector>
|
||||
@@ -0,0 +1,170 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#008577"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M9,0L9,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,0L19,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,0L29,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,0L39,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,0L49,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,0L59,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,0L69,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,0L79,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M89,0L89,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M99,0L99,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,9L108,9"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,19L108,19"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,29L108,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,39L108,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,49L108,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,59L108,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,69L108,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,79L108,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,89L108,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,99L108,99"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,29L89,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,39L89,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,49L89,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,59L89,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,69L89,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,79L89,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,19L29,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,19L39,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,19L49,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,19L59,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,19L69,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,19L79,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
</vector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 8.9 KiB |
|
After Width: | Height: | Size: 15 KiB |
4
examples/todoapp/android/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Todo</string>
|
||||
</resources>
|
||||
14
examples/todoapp/build.gradle.kts
Executable file
@@ -0,0 +1,14 @@
|
||||
plugins {
|
||||
`kotlin-dsl`
|
||||
}
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
jcenter()
|
||||
mavenLocal()
|
||||
maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
|
||||
maven("https://dl.bintray.com/arkivanov/maven")
|
||||
maven("https://dl.bintray.com/badoo/maven")
|
||||
}
|
||||
}
|
||||
1
examples/todoapp/buildSrc/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/build
|
||||
36
examples/todoapp/buildSrc/build.gradle.kts
Normal file
@@ -0,0 +1,36 @@
|
||||
plugins {
|
||||
`kotlin-dsl`
|
||||
`kotlin-dsl-precompiled-script-plugins`
|
||||
}
|
||||
|
||||
repositories {
|
||||
google()
|
||||
jcenter()
|
||||
maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
|
||||
}
|
||||
|
||||
buildscript {
|
||||
repositories {
|
||||
google()
|
||||
jcenter()
|
||||
maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
classpath(Deps.JetBrains.Compose.gradlePlugin)
|
||||
classpath(Deps.JetBrains.Kotlin.gradlePlugin)
|
||||
classpath(Deps.Android.Tools.Build.gradlePlugin)
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(Deps.JetBrains.Compose.gradlePlugin)
|
||||
implementation(Deps.JetBrains.Kotlin.gradlePlugin)
|
||||
implementation(Deps.Android.Tools.Build.gradlePlugin)
|
||||
implementation(Deps.Squareup.SQLDelight.gradlePlugin)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
// Add Deps to compilation, so it will become available in main project
|
||||
sourceSets.getByName("main").kotlin.srcDir("buildSrc/src/main/kotlin")
|
||||
}
|
||||
1
examples/todoapp/buildSrc/buildSrc/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/build
|
||||
7
examples/todoapp/buildSrc/buildSrc/build.gradle.kts
Normal file
@@ -0,0 +1,7 @@
|
||||
plugins {
|
||||
`kotlin-dsl`
|
||||
}
|
||||
|
||||
repositories {
|
||||
jcenter()
|
||||
}
|
||||
61
examples/todoapp/buildSrc/buildSrc/src/main/kotlin/Deps.kt
Normal file
@@ -0,0 +1,61 @@
|
||||
object Deps {
|
||||
|
||||
object JetBrains {
|
||||
object Kotlin {
|
||||
private const val VERSION = "1.4.0"
|
||||
const val gradlePlugin = "org.jetbrains.kotlin:kotlin-gradle-plugin:$VERSION"
|
||||
const val testCommon = "org.jetbrains.kotlin:kotlin-test-common:$VERSION"
|
||||
const val testJunit = "org.jetbrains.kotlin:kotlin-test-junit:$VERSION"
|
||||
const val testAnnotationsCommon = "org.jetbrains.kotlin:kotlin-test-annotations-common:$VERSION"
|
||||
}
|
||||
|
||||
object Compose {
|
||||
private const val VERSION = "0.1.0-dev97"
|
||||
const val gradlePlugin = "org.jetbrains.compose:compose-gradle-plugin:$VERSION"
|
||||
}
|
||||
}
|
||||
|
||||
object Android {
|
||||
object Tools {
|
||||
object Build {
|
||||
const val gradlePlugin = "com.android.tools.build:gradle:4.0.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object ArkIvanov {
|
||||
object MVIKotlin {
|
||||
private const val VERSION = "2.0.0"
|
||||
const val mvikotlin = "com.arkivanov.mvikotlin:mvikotlin:$VERSION"
|
||||
const val mvikotlinMain = "com.arkivanov.mvikotlin:mvikotlin-main:$VERSION"
|
||||
const val mvikotlinLogging = "com.arkivanov.mvikotlin:mvikotlin-logging:$VERSION"
|
||||
const val mvikotlinTimeTravel = "com.arkivanov.mvikotlin:mvikotlin-timetravel:$VERSION"
|
||||
const val mvikotlinExtensionsReaktive = "com.arkivanov.mvikotlin:mvikotlin-extensions-reaktive:$VERSION"
|
||||
}
|
||||
|
||||
object Decompose {
|
||||
private const val VERSION = "0.0.10"
|
||||
const val decompose = "com.arkivanov.decompose:decompose:$VERSION"
|
||||
}
|
||||
}
|
||||
|
||||
object Badoo {
|
||||
object Reaktive {
|
||||
private const val VERSION = "1.1.18"
|
||||
const val reaktive = "com.badoo.reaktive:reaktive:$VERSION"
|
||||
const val reaktiveTesting = "com.badoo.reaktive:reaktive-testing:$VERSION"
|
||||
const val utils = "com.badoo.reaktive:utils:$VERSION"
|
||||
const val coroutinesInterop = "com.badoo.reaktive:coroutines-interop:$VERSION"
|
||||
}
|
||||
}
|
||||
|
||||
object Squareup {
|
||||
object SQLDelight {
|
||||
private const val VERSION = "1.4.4"
|
||||
|
||||
const val gradlePlugin = "com.squareup.sqldelight:gradle-plugin:$VERSION"
|
||||
const val androidDriver = "com.squareup.sqldelight:android-driver:$VERSION"
|
||||
const val sqliteDriver = "com.squareup.sqldelight:sqlite-driver:$VERSION"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import org.jetbrains.compose.compose
|
||||
|
||||
plugins {
|
||||
id("kotlin-multiplatform")
|
||||
id("org.jetbrains.compose")
|
||||
}
|
||||
|
||||
kotlin {
|
||||
sourceSets {
|
||||
named("commonMain") {
|
||||
dependencies {
|
||||
api(compose.runtime)
|
||||
api(compose.foundation)
|
||||
api(compose.material)
|
||||
}
|
||||
}
|
||||
named("androidMain") {
|
||||
dependencies {
|
||||
api("androidx.appcompat:appcompat:1.1.0")
|
||||
api("androidx.core:core-ktx:1.3.1")
|
||||
}
|
||||
}
|
||||
named("desktopMain") {
|
||||
dependencies {
|
||||
api(compose.desktop.common)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
plugins {
|
||||
id("com.android.library")
|
||||
id("kotlin-multiplatform")
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvm("desktop")
|
||||
android()
|
||||
|
||||
sourceSets {
|
||||
named("commonMain") {
|
||||
dependencies {
|
||||
implementation(Deps.ArkIvanov.MVIKotlin.mvikotlin)
|
||||
implementation(Deps.ArkIvanov.MVIKotlin.mvikotlinExtensionsReaktive)
|
||||
implementation(Deps.ArkIvanov.Decompose.decompose)
|
||||
implementation(Deps.Badoo.Reaktive.reaktive)
|
||||
}
|
||||
}
|
||||
|
||||
named("commonTest") {
|
||||
dependencies {
|
||||
implementation(Deps.JetBrains.Kotlin.testCommon)
|
||||
implementation(Deps.JetBrains.Kotlin.testAnnotationsCommon)
|
||||
}
|
||||
}
|
||||
|
||||
named("androidTest") {
|
||||
dependencies {
|
||||
implementation(Deps.JetBrains.Kotlin.testJunit)
|
||||
}
|
||||
}
|
||||
named("desktopTest") {
|
||||
dependencies {
|
||||
implementation(Deps.JetBrains.Kotlin.testJunit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
|
||||
kotlinOptions.jvmTarget = "1.8"
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdkVersion(30)
|
||||
|
||||
defaultConfig {
|
||||
minSdkVersion(23)
|
||||
targetSdkVersion(30)
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
named("main") {
|
||||
manifest.srcFile("src/androidMain/AndroidManifest.xml")
|
||||
res.srcDirs("src/androidMain/res")
|
||||
}
|
||||
}
|
||||
}
|
||||
1
examples/todoapp/common/database/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/build
|
||||
33
examples/todoapp/common/database/build.gradle.kts
Executable file
@@ -0,0 +1,33 @@
|
||||
plugins {
|
||||
id("multiplatform-setup")
|
||||
id("com.squareup.sqldelight")
|
||||
}
|
||||
|
||||
sqldelight {
|
||||
database("TodoDatabase") {
|
||||
packageName = "example.todo.database"
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
sourceSets {
|
||||
commonMain {
|
||||
dependencies {
|
||||
implementation(Deps.Badoo.Reaktive.reaktive)
|
||||
}
|
||||
}
|
||||
|
||||
androidMain {
|
||||
dependencies {
|
||||
implementation(Deps.Squareup.SQLDelight.androidDriver)
|
||||
implementation(Deps.Squareup.SQLDelight.sqliteDriver)
|
||||
}
|
||||
}
|
||||
|
||||
desktopMain {
|
||||
dependencies {
|
||||
implementation(Deps.Squareup.SQLDelight.sqliteDriver)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
2
examples/todoapp/common/database/src/androidMain/AndroidManifest.xml
Executable file
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest package="example.todo.common.database"/>
|
||||
@@ -0,0 +1,13 @@
|
||||
package example.todo.common.database
|
||||
|
||||
import com.squareup.sqldelight.db.SqlDriver
|
||||
import com.squareup.sqldelight.sqlite.driver.JdbcSqliteDriver
|
||||
import example.todo.database.TodoDatabase
|
||||
|
||||
@Suppress("FunctionName") // FactoryFunction
|
||||
actual fun TestDatabaseDriver(): SqlDriver {
|
||||
val driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY)
|
||||
TodoDatabase.Schema.create(driver)
|
||||
|
||||
return driver
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package example.todo.common.database
|
||||
|
||||
import android.content.Context
|
||||
import com.squareup.sqldelight.android.AndroidSqliteDriver
|
||||
import com.squareup.sqldelight.db.SqlDriver
|
||||
import example.todo.database.TodoDatabase
|
||||
|
||||
@Suppress("FunctionName") // FactoryFunction
|
||||
fun TodoDatabaseDriver(context: Context): SqlDriver =
|
||||
AndroidSqliteDriver(
|
||||
schema = TodoDatabase.Schema,
|
||||
context = context,
|
||||
name = "TodoDatabase.db"
|
||||
)
|
||||
@@ -0,0 +1,28 @@
|
||||
package example.todo.common.database
|
||||
|
||||
import com.badoo.reaktive.base.setCancellable
|
||||
import com.badoo.reaktive.observable.Observable
|
||||
import com.badoo.reaktive.observable.map
|
||||
import com.badoo.reaktive.observable.observable
|
||||
import com.badoo.reaktive.observable.observeOn
|
||||
import com.badoo.reaktive.scheduler.ioScheduler
|
||||
import com.squareup.sqldelight.Query
|
||||
|
||||
fun <T : Any, R> Query<T>.asObservable(execute: (Query<T>) -> R): Observable<R> =
|
||||
asObservable()
|
||||
.observeOn(ioScheduler)
|
||||
.map(execute)
|
||||
|
||||
fun <T : Any> Query<T>.asObservable(): Observable<Query<T>> =
|
||||
observable { emitter ->
|
||||
val listener =
|
||||
object : Query.Listener {
|
||||
override fun queryResultsChanged() {
|
||||
emitter.onNext(this@asObservable)
|
||||
}
|
||||
}
|
||||
|
||||
emitter.onNext(this@asObservable)
|
||||
addListener(listener)
|
||||
emitter.setCancellable { removeListener(listener) }
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package example.todo.common.database
|
||||
|
||||
import com.squareup.sqldelight.db.SqlDriver
|
||||
|
||||
@Suppress("FunctionName")
|
||||
expect fun TestDatabaseDriver(): SqlDriver
|
||||
@@ -0,0 +1,36 @@
|
||||
CREATE TABLE IF NOT EXISTS TodoItemEntity (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
orderNum INTEGER NOT NULL,
|
||||
text TEXT NOT NULL,
|
||||
isDone INTEGER AS Boolean NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
selectAll:
|
||||
SELECT *
|
||||
FROM TodoItemEntity;
|
||||
|
||||
select:
|
||||
SELECT *
|
||||
FROM TodoItemEntity
|
||||
WHERE id = :id;
|
||||
|
||||
add:
|
||||
INSERT INTO TodoItemEntity (orderNum, text)
|
||||
VALUES ((CASE (SELECT COUNT(*) FROM TodoItemEntity) WHEN 0 THEN 1 ELSE (SELECT MAX(orderNum)+1 FROM TodoItemEntity) END), :text);
|
||||
|
||||
setText:
|
||||
UPDATE TodoItemEntity
|
||||
SET text = :text
|
||||
WHERE id = :id;
|
||||
|
||||
setDone:
|
||||
UPDATE TodoItemEntity
|
||||
SET isDone = :isDone
|
||||
WHERE id = :id;
|
||||
|
||||
delete:
|
||||
DELETE FROM TodoItemEntity
|
||||
WHERE id = :id;
|
||||
|
||||
getLastInsertId:
|
||||
SELECT last_insert_rowid();
|
||||
@@ -0,0 +1,13 @@
|
||||
package example.todo.common.database
|
||||
|
||||
import com.squareup.sqldelight.db.SqlDriver
|
||||
import com.squareup.sqldelight.sqlite.driver.JdbcSqliteDriver
|
||||
import example.todo.database.TodoDatabase
|
||||
|
||||
@Suppress("FunctionName") // FactoryFunction
|
||||
actual fun TestDatabaseDriver(): SqlDriver {
|
||||
val driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY)
|
||||
TodoDatabase.Schema.create(driver)
|
||||
|
||||
return driver
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package example.todo.common.database
|
||||
|
||||
import com.squareup.sqldelight.db.SqlDriver
|
||||
import com.squareup.sqldelight.sqlite.driver.JdbcSqliteDriver
|
||||
import example.todo.database.TodoDatabase
|
||||
import java.io.File
|
||||
|
||||
@Suppress("FunctionName") // FactoryFunction
|
||||
fun TodoDatabaseDriver(): SqlDriver {
|
||||
val databasePath = File(System.getProperty("java.io.tmpdir"), "ComposeTodoDatabase.db")
|
||||
val driver = JdbcSqliteDriver(url = "jdbc:sqlite:${databasePath.absolutePath}")
|
||||
TodoDatabase.Schema.create(driver)
|
||||
|
||||
return driver
|
||||
}
|
||||
1
examples/todoapp/common/edit/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/build
|
||||
19
examples/todoapp/common/edit/build.gradle.kts
Executable file
@@ -0,0 +1,19 @@
|
||||
plugins {
|
||||
id("multiplatform-setup")
|
||||
id("multiplatform-compose-setup")
|
||||
}
|
||||
|
||||
kotlin {
|
||||
sourceSets {
|
||||
named("commonMain") {
|
||||
dependencies {
|
||||
implementation(project(":common:utils"))
|
||||
implementation(project(":common:database"))
|
||||
implementation(Deps.ArkIvanov.MVIKotlin.mvikotlin)
|
||||
implementation(Deps.ArkIvanov.MVIKotlin.mvikotlinExtensionsReaktive)
|
||||
implementation(Deps.ArkIvanov.Decompose.decompose)
|
||||
implementation(Deps.Badoo.Reaktive.reaktive)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
2
examples/todoapp/common/edit/src/androidMain/AndroidManifest.xml
Executable file
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest package="example.todo.common.edit"/>
|
||||
@@ -0,0 +1,27 @@
|
||||
package example.todo.common.edit
|
||||
|
||||
import com.arkivanov.decompose.ComponentContext
|
||||
import com.arkivanov.mvikotlin.core.store.StoreFactory
|
||||
import com.badoo.reaktive.base.Consumer
|
||||
import example.todo.common.edit.TodoEdit.Dependencies
|
||||
import example.todo.common.edit.integration.TodoEditImpl
|
||||
import example.todo.common.utils.Component
|
||||
import example.todo.database.TodoDatabase
|
||||
|
||||
interface TodoEdit : Component {
|
||||
|
||||
interface Dependencies {
|
||||
val storeFactory: StoreFactory
|
||||
val database: TodoDatabase
|
||||
val itemId: Long
|
||||
val editOutput: Consumer<Output>
|
||||
}
|
||||
|
||||
sealed class Output {
|
||||
object Finished : Output()
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("FunctionName") // Factory function
|
||||
fun TodoEdit(componentContext: ComponentContext, dependencies: Dependencies): TodoEdit =
|
||||
TodoEditImpl(componentContext, dependencies)
|
||||
@@ -0,0 +1,6 @@
|
||||
package example.todo.common.edit
|
||||
|
||||
data class TodoItem(
|
||||
val text: String,
|
||||
val isDone: Boolean
|
||||
)
|
||||
@@ -0,0 +1,37 @@
|
||||
package example.todo.common.edit.integration
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import com.arkivanov.decompose.ComponentContext
|
||||
import example.todo.common.edit.TodoEdit
|
||||
import example.todo.common.edit.TodoEdit.Dependencies
|
||||
import example.todo.common.edit.store.TodoEditStoreProvider
|
||||
import example.todo.common.edit.ui.TodoEditUi
|
||||
import example.todo.common.utils.composeState
|
||||
import example.todo.common.utils.getStore
|
||||
|
||||
internal class TodoEditImpl(
|
||||
componentContext: ComponentContext,
|
||||
dependencies: Dependencies
|
||||
) : TodoEdit, ComponentContext by componentContext, Dependencies by dependencies {
|
||||
|
||||
private val store =
|
||||
instanceKeeper.getStore {
|
||||
TodoEditStoreProvider(
|
||||
storeFactory = storeFactory,
|
||||
database = TodoEditStoreDatabase(queries = database.todoDatabaseQueries),
|
||||
id = itemId
|
||||
).provide()
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun invoke() {
|
||||
val state by store.composeState
|
||||
|
||||
TodoEditUi(
|
||||
state = state,
|
||||
output = editOutput,
|
||||
intents = store::accept
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package example.todo.common.edit.integration
|
||||
|
||||
import com.badoo.reaktive.completable.Completable
|
||||
import com.badoo.reaktive.completable.completableFromFunction
|
||||
import com.badoo.reaktive.completable.subscribeOn
|
||||
import com.badoo.reaktive.maybe.Maybe
|
||||
import com.badoo.reaktive.maybe.map
|
||||
import com.badoo.reaktive.maybe.maybeFromFunction
|
||||
import com.badoo.reaktive.maybe.notNull
|
||||
import com.badoo.reaktive.maybe.subscribeOn
|
||||
import com.badoo.reaktive.scheduler.ioScheduler
|
||||
import com.squareup.sqldelight.Query
|
||||
import example.todo.common.database.TodoDatabaseQueries
|
||||
import example.todo.common.database.TodoItemEntity
|
||||
import example.todo.common.edit.TodoItem
|
||||
import example.todo.common.edit.store.TodoEditStoreProvider.Database
|
||||
|
||||
internal class TodoEditStoreDatabase(
|
||||
private val queries: TodoDatabaseQueries
|
||||
) : Database {
|
||||
|
||||
override fun load(id: Long): Maybe<TodoItem> =
|
||||
maybeFromFunction { queries.select(id = id) }
|
||||
.subscribeOn(ioScheduler)
|
||||
.map(Query<TodoItemEntity>::executeAsOne)
|
||||
.notNull()
|
||||
.map { it.toItem() }
|
||||
|
||||
private fun TodoItemEntity.toItem(): TodoItem =
|
||||
TodoItem(
|
||||
text = text,
|
||||
isDone = isDone
|
||||
)
|
||||
|
||||
override fun setText(id: Long, text: String): Completable =
|
||||
completableFromFunction { queries.setText(id = id, text = text) }
|
||||
.subscribeOn(ioScheduler)
|
||||
|
||||
override fun setDone(id: Long, isDone: Boolean): Completable =
|
||||
completableFromFunction { queries.setDone(id = id, isDone = isDone) }
|
||||
.subscribeOn(ioScheduler)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package example.todo.common.edit.store
|
||||
|
||||
import com.arkivanov.mvikotlin.core.store.Store
|
||||
import example.todo.common.edit.TodoItem
|
||||
import example.todo.common.edit.store.TodoEditStore.Intent
|
||||
import example.todo.common.edit.store.TodoEditStore.Label
|
||||
import example.todo.common.edit.store.TodoEditStore.State
|
||||
|
||||
internal interface TodoEditStore : Store<Intent, State, Label> {
|
||||
|
||||
sealed class Intent {
|
||||
data class SetText(val text: String) : Intent()
|
||||
data class SetDone(val isDone: Boolean) : Intent()
|
||||
}
|
||||
|
||||
data class State(
|
||||
val text: String = "",
|
||||
val isDone: Boolean = false
|
||||
)
|
||||
|
||||
sealed class Label {
|
||||
data class Changed(val item: TodoItem) : Label()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package example.todo.common.edit.store
|
||||
|
||||
import com.arkivanov.mvikotlin.core.store.Reducer
|
||||
import com.arkivanov.mvikotlin.core.store.SimpleBootstrapper
|
||||
import com.arkivanov.mvikotlin.core.store.Store
|
||||
import com.arkivanov.mvikotlin.core.store.StoreFactory
|
||||
import com.arkivanov.mvikotlin.extensions.reaktive.ReaktiveExecutor
|
||||
import com.badoo.reaktive.completable.Completable
|
||||
import com.badoo.reaktive.maybe.Maybe
|
||||
import com.badoo.reaktive.maybe.map
|
||||
import com.badoo.reaktive.maybe.observeOn
|
||||
import com.badoo.reaktive.scheduler.mainScheduler
|
||||
import example.todo.common.edit.TodoItem
|
||||
import example.todo.common.edit.store.TodoEditStore.Intent
|
||||
import example.todo.common.edit.store.TodoEditStore.Label
|
||||
import example.todo.common.edit.store.TodoEditStore.State
|
||||
|
||||
internal class TodoEditStoreProvider(
|
||||
private val storeFactory: StoreFactory,
|
||||
private val database: Database,
|
||||
private val id: Long
|
||||
) {
|
||||
|
||||
fun provide(): TodoEditStore =
|
||||
object : TodoEditStore, Store<Intent, State, Label> by storeFactory.create(
|
||||
name = "EditStore",
|
||||
initialState = State(),
|
||||
bootstrapper = SimpleBootstrapper(Unit),
|
||||
executorFactory = ::ExecutorImpl,
|
||||
reducer = ReducerImpl
|
||||
) {}
|
||||
|
||||
private sealed class Result {
|
||||
data class Loaded(val item: TodoItem) : Result()
|
||||
data class TextChanged(val text: String) : Result()
|
||||
data class DoneChanged(val isDone: Boolean) : Result()
|
||||
}
|
||||
|
||||
private inner class ExecutorImpl : ReaktiveExecutor<Intent, Unit, State, Result, Label>() {
|
||||
override fun executeAction(action: Unit, getState: () -> State) {
|
||||
database
|
||||
.load(id = id)
|
||||
.map(Result::Loaded)
|
||||
.observeOn(mainScheduler)
|
||||
.subscribeScoped(onSuccess = ::dispatch)
|
||||
}
|
||||
|
||||
override fun executeIntent(intent: Intent, getState: () -> State) =
|
||||
when (intent) {
|
||||
is Intent.SetText -> setText(text = intent.text, state = getState())
|
||||
is Intent.SetDone -> setDone(isDone = intent.isDone, state = getState())
|
||||
}
|
||||
|
||||
private fun setText(text: String, state: State) {
|
||||
dispatch(Result.TextChanged(text = text))
|
||||
publish(Label.Changed(TodoItem(text = text, isDone = state.isDone)))
|
||||
database.setText(id = id, text = text).subscribeScoped()
|
||||
}
|
||||
|
||||
private fun setDone(isDone: Boolean, state: State) {
|
||||
dispatch(Result.DoneChanged(isDone = isDone))
|
||||
publish(Label.Changed(TodoItem(text = state.text, isDone = isDone)))
|
||||
database.setDone(id = id, isDone = isDone).subscribeScoped()
|
||||
}
|
||||
}
|
||||
|
||||
private object ReducerImpl : Reducer<State, Result> {
|
||||
override fun State.reduce(result: Result): State =
|
||||
when (result) {
|
||||
is Result.Loaded -> copy(text = result.item.text, isDone = result.item.isDone)
|
||||
is Result.TextChanged -> copy(text = result.text)
|
||||
is Result.DoneChanged -> copy(isDone = result.isDone)
|
||||
}
|
||||
}
|
||||
|
||||
interface Database {
|
||||
fun load(id: Long): Maybe<TodoItem>
|
||||
|
||||
fun setText(id: Long, text: String): Completable
|
||||
|
||||
fun setDone(id: Long, isDone: Boolean): Completable
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package example.todo.common.edit.ui
|
||||
|
||||
import androidx.compose.foundation.Icon
|
||||
import androidx.compose.foundation.Text
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.Checkbox
|
||||
import androidx.compose.material.IconButton
|
||||
import androidx.compose.material.TextField
|
||||
import androidx.compose.material.TopAppBar
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.badoo.reaktive.base.Consumer
|
||||
import example.todo.common.edit.TodoEdit.Output
|
||||
import example.todo.common.edit.store.TodoEditStore.Intent
|
||||
import example.todo.common.edit.store.TodoEditStore.State
|
||||
|
||||
@Composable
|
||||
internal fun TodoEditUi(
|
||||
state: State,
|
||||
output: Consumer<Output>,
|
||||
intents: (Intent) -> Unit
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
TopAppBar(
|
||||
title = { Text("Edit todo") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { output.onNext(Output.Finished) }) {
|
||||
Icon(Icons.Default.ArrowBack)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
TextField(
|
||||
value = state.text,
|
||||
modifier = Modifier.weight(1F).fillMaxWidth().padding(8.dp),
|
||||
label = { Text("Todo text") },
|
||||
onValueChange = { intents(Intent.SetText(text = it)) }
|
||||
)
|
||||
|
||||
Row(modifier = Modifier.padding(8.dp)) {
|
||||
Text(text = "Completed")
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
Checkbox(
|
||||
checked = state.isDone,
|
||||
onCheckedChange = { intents(Intent.SetDone(isDone = it)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
1
examples/todoapp/common/main/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/build
|
||||
27
examples/todoapp/common/main/build.gradle.kts
Executable file
@@ -0,0 +1,27 @@
|
||||
plugins {
|
||||
id("multiplatform-setup")
|
||||
id("multiplatform-compose-setup")
|
||||
}
|
||||
|
||||
kotlin {
|
||||
sourceSets {
|
||||
named("commonMain") {
|
||||
dependencies {
|
||||
implementation(project(":common:utils"))
|
||||
implementation(project(":common:database"))
|
||||
implementation(Deps.ArkIvanov.Decompose.decompose)
|
||||
implementation(Deps.ArkIvanov.MVIKotlin.mvikotlin)
|
||||
implementation(Deps.ArkIvanov.MVIKotlin.mvikotlinExtensionsReaktive)
|
||||
implementation(Deps.Badoo.Reaktive.reaktive)
|
||||
}
|
||||
}
|
||||
|
||||
named("commonTest") {
|
||||
dependencies {
|
||||
implementation(Deps.ArkIvanov.MVIKotlin.mvikotlinMain)
|
||||
implementation(Deps.Badoo.Reaktive.reaktiveTesting)
|
||||
implementation(Deps.Badoo.Reaktive.utils)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
2
examples/todoapp/common/main/src/androidMain/AndroidManifest.xml
Executable file
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest package="example.todo.common.main"/>
|
||||
@@ -0,0 +1,26 @@
|
||||
package example.todo.common.main
|
||||
|
||||
import com.arkivanov.decompose.ComponentContext
|
||||
import com.arkivanov.mvikotlin.core.store.StoreFactory
|
||||
import com.badoo.reaktive.base.Consumer
|
||||
import example.todo.common.main.TodoMain.Dependencies
|
||||
import example.todo.common.main.integration.TodoMainImpl
|
||||
import example.todo.common.utils.Component
|
||||
import example.todo.database.TodoDatabase
|
||||
|
||||
interface TodoMain : Component {
|
||||
|
||||
interface Dependencies {
|
||||
val storeFactory: StoreFactory
|
||||
val database: TodoDatabase
|
||||
val mainOutput: Consumer<Output>
|
||||
}
|
||||
|
||||
sealed class Output {
|
||||
data class Selected(val id: Long) : Output()
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("FunctionName") // Factory function
|
||||
fun TodoMain(componentContext: ComponentContext, dependencies: Dependencies): TodoMain =
|
||||
TodoMainImpl(componentContext, dependencies)
|
||||
@@ -0,0 +1,49 @@
|
||||
package example.todo.common.main.integration
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import com.arkivanov.decompose.ComponentContext
|
||||
import example.todo.common.main.TodoMain
|
||||
import example.todo.common.main.TodoMain.Dependencies
|
||||
import example.todo.common.main.TodoMain.Output
|
||||
import example.todo.common.main.store.TodoMainStore.Intent
|
||||
import example.todo.common.main.store.TodoMainStore.State
|
||||
import example.todo.common.main.store.TodoMainStoreProvider
|
||||
import example.todo.common.main.ui.TodoMainUi
|
||||
import example.todo.common.utils.composeState
|
||||
import example.todo.common.utils.getStore
|
||||
|
||||
internal class TodoMainImpl(
|
||||
componentContext: ComponentContext,
|
||||
dependencies: Dependencies
|
||||
) : TodoMain, ComponentContext by componentContext, Dependencies by dependencies {
|
||||
|
||||
private val store =
|
||||
instanceKeeper.getStore {
|
||||
TodoMainStoreProvider(
|
||||
storeFactory = storeFactory,
|
||||
database = TodoMainStoreDatabase(queries = database.todoDatabaseQueries)
|
||||
).provide()
|
||||
}
|
||||
|
||||
internal val state: State get() = store.state
|
||||
|
||||
@Composable
|
||||
override fun invoke() {
|
||||
val state by store.composeState
|
||||
|
||||
TodoMainUi(
|
||||
state = state,
|
||||
output = mainOutput,
|
||||
intents = store::accept
|
||||
)
|
||||
}
|
||||
|
||||
internal fun onIntent(intent: Intent) {
|
||||
store.accept(intent)
|
||||
}
|
||||
|
||||
internal fun onOutput(output: Output) {
|
||||
mainOutput.onNext(output)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package example.todo.common.main.integration
|
||||
|
||||
import com.badoo.reaktive.completable.Completable
|
||||
import com.badoo.reaktive.completable.completableFromFunction
|
||||
import com.badoo.reaktive.completable.subscribeOn
|
||||
import com.badoo.reaktive.observable.Observable
|
||||
import com.badoo.reaktive.observable.mapIterable
|
||||
import com.badoo.reaktive.scheduler.ioScheduler
|
||||
import com.squareup.sqldelight.Query
|
||||
import example.todo.common.database.TodoDatabaseQueries
|
||||
import example.todo.common.database.TodoItemEntity
|
||||
import example.todo.common.database.asObservable
|
||||
import example.todo.common.main.store.TodoItem
|
||||
import example.todo.common.main.store.TodoMainStoreProvider
|
||||
|
||||
internal class TodoMainStoreDatabase(
|
||||
private val queries: TodoDatabaseQueries
|
||||
) : TodoMainStoreProvider.Database {
|
||||
|
||||
override val updates: Observable<List<TodoItem>> =
|
||||
queries
|
||||
.selectAll()
|
||||
.asObservable(Query<TodoItemEntity>::executeAsList)
|
||||
.mapIterable { it.toItem() }
|
||||
|
||||
private fun TodoItemEntity.toItem(): TodoItem =
|
||||
TodoItem(
|
||||
id = id,
|
||||
order = orderNum,
|
||||
text = text,
|
||||
isDone = isDone
|
||||
)
|
||||
|
||||
override fun setDone(id: Long, isDone: Boolean): Completable =
|
||||
completableFromFunction { queries.setDone(id = id, isDone = isDone) }
|
||||
.subscribeOn(ioScheduler)
|
||||
|
||||
override fun delete(id: Long): Completable =
|
||||
completableFromFunction { queries.delete(id = id) }
|
||||
.subscribeOn(ioScheduler)
|
||||
|
||||
override fun add(text: String): Completable =
|
||||
completableFromFunction {
|
||||
queries.transactionWithResult {
|
||||
queries.add(text = text)
|
||||
val lastId = queries.getLastInsertId().executeAsOne()
|
||||
queries.select(id = lastId).executeAsOne()
|
||||
}
|
||||
}.subscribeOn(ioScheduler)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package example.todo.common.main.store
|
||||
|
||||
internal data class TodoItem(
|
||||
val id: Long = 0L,
|
||||
val order: Long = 0L,
|
||||
val text: String = "",
|
||||
val isDone: Boolean = false
|
||||
)
|
||||
@@ -0,0 +1,20 @@
|
||||
package example.todo.common.main.store
|
||||
|
||||
import com.arkivanov.mvikotlin.core.store.Store
|
||||
import example.todo.common.main.store.TodoMainStore.Intent
|
||||
import example.todo.common.main.store.TodoMainStore.State
|
||||
|
||||
internal interface TodoMainStore : Store<Intent, State, Nothing> {
|
||||
|
||||
sealed class Intent {
|
||||
data class SetItemDone(val id: Long, val isDone: Boolean) : Intent()
|
||||
data class DeleteItem(val id: Long) : Intent()
|
||||
data class SetText(val text: String) : Intent()
|
||||
object AddItem : Intent()
|
||||
}
|
||||
|
||||
data class State(
|
||||
val items: List<TodoItem> = emptyList(),
|
||||
val text: String = ""
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package example.todo.common.main.store
|
||||
|
||||
import com.arkivanov.mvikotlin.core.store.Reducer
|
||||
import com.arkivanov.mvikotlin.core.store.SimpleBootstrapper
|
||||
import com.arkivanov.mvikotlin.core.store.Store
|
||||
import com.arkivanov.mvikotlin.core.store.StoreFactory
|
||||
import com.arkivanov.mvikotlin.extensions.reaktive.ReaktiveExecutor
|
||||
import com.badoo.reaktive.completable.Completable
|
||||
import com.badoo.reaktive.observable.Observable
|
||||
import com.badoo.reaktive.observable.map
|
||||
import com.badoo.reaktive.observable.observeOn
|
||||
import com.badoo.reaktive.scheduler.mainScheduler
|
||||
import example.todo.common.main.store.TodoMainStore.Intent
|
||||
import example.todo.common.main.store.TodoMainStore.State
|
||||
|
||||
internal class TodoMainStoreProvider(
|
||||
private val storeFactory: StoreFactory,
|
||||
private val database: Database
|
||||
) {
|
||||
|
||||
fun provide(): TodoMainStore =
|
||||
object : TodoMainStore, Store<Intent, State, Nothing> by storeFactory.create(
|
||||
name = "TodoListStore",
|
||||
initialState = State(),
|
||||
bootstrapper = SimpleBootstrapper(Unit),
|
||||
executorFactory = ::ExecutorImpl,
|
||||
reducer = ReducerImpl
|
||||
) {}
|
||||
|
||||
private sealed class Result {
|
||||
data class ItemsLoaded(val items: List<TodoItem>) : Result()
|
||||
data class ItemDoneChanged(val id: Long, val isDone: Boolean) : Result()
|
||||
data class ItemDeleted(val id: Long) : Result()
|
||||
data class TextChanged(val text: String) : Result()
|
||||
}
|
||||
|
||||
private inner class ExecutorImpl : ReaktiveExecutor<Intent, Unit, State, Result, Nothing>() {
|
||||
override fun executeAction(action: Unit, getState: () -> State) {
|
||||
database
|
||||
.updates
|
||||
.observeOn(mainScheduler)
|
||||
.map(Result::ItemsLoaded)
|
||||
.subscribeScoped(onNext = ::dispatch)
|
||||
}
|
||||
|
||||
override fun executeIntent(intent: Intent, getState: () -> State): Unit =
|
||||
when (intent) {
|
||||
is Intent.SetItemDone -> setItemDone(id = intent.id, isDone = intent.isDone)
|
||||
is Intent.DeleteItem -> deleteItem(id = intent.id)
|
||||
is Intent.SetText -> dispatch(Result.TextChanged(text = intent.text))
|
||||
is Intent.AddItem -> addItem(state = getState())
|
||||
}
|
||||
|
||||
private fun setItemDone(id: Long, isDone: Boolean) {
|
||||
dispatch(Result.ItemDoneChanged(id = id, isDone = isDone))
|
||||
database.setDone(id = id, isDone = isDone).subscribeScoped()
|
||||
}
|
||||
|
||||
private fun deleteItem(id: Long) {
|
||||
dispatch(Result.ItemDeleted(id = id))
|
||||
database.delete(id = id).subscribeScoped()
|
||||
}
|
||||
|
||||
private fun addItem(state: State) {
|
||||
if (state.text.isNotEmpty()) {
|
||||
dispatch(Result.TextChanged(text = ""))
|
||||
database.add(text = state.text).subscribeScoped()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private object ReducerImpl : Reducer<State, Result> {
|
||||
override fun State.reduce(result: Result): State =
|
||||
when (result) {
|
||||
is Result.ItemsLoaded -> copy(items = result.items.sorted())
|
||||
is Result.ItemDoneChanged -> update(id = result.id) { copy(isDone = result.isDone) }
|
||||
is Result.ItemDeleted -> copy(items = items.filterNot { it.id == result.id })
|
||||
is Result.TextChanged -> copy(text = result.text)
|
||||
}
|
||||
|
||||
private inline fun State.update(id: Long, func: TodoItem.() -> TodoItem): State {
|
||||
val item = items.find { it.id == id } ?: return this
|
||||
|
||||
return put(item.func())
|
||||
}
|
||||
|
||||
private fun State.put(item: TodoItem): State {
|
||||
val oldItems = items.associateByTo(mutableMapOf(), TodoItem::id)
|
||||
val oldItem: TodoItem? = oldItems.put(item.id, item)
|
||||
|
||||
return copy(items = if (oldItem?.order == item.order) oldItems.values.toList() else oldItems.values.sorted())
|
||||
}
|
||||
|
||||
private fun Iterable<TodoItem>.sorted(): List<TodoItem> = sortedByDescending(TodoItem::order)
|
||||
}
|
||||
|
||||
interface Database {
|
||||
val updates: Observable<List<TodoItem>>
|
||||
|
||||
fun setDone(id: Long, isDone: Boolean): Completable
|
||||
|
||||
fun delete(id: Long): Completable
|
||||
|
||||
fun add(text: String): Completable
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package example.todo.common.main.ui
|
||||
|
||||
import androidx.compose.foundation.Icon
|
||||
import androidx.compose.foundation.Text
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumnFor
|
||||
import androidx.compose.material.Button
|
||||
import androidx.compose.material.Checkbox
|
||||
import androidx.compose.material.Divider
|
||||
import androidx.compose.material.IconButton
|
||||
import androidx.compose.material.OutlinedTextField
|
||||
import androidx.compose.material.TopAppBar
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.key.ExperimentalKeyInput
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.keyInputFilter
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.badoo.reaktive.base.Consumer
|
||||
import example.todo.common.main.TodoMain.Output
|
||||
import example.todo.common.main.store.TodoItem
|
||||
import example.todo.common.main.store.TodoMainStore.Intent
|
||||
import example.todo.common.main.store.TodoMainStore.State
|
||||
import example.todo.common.utils.onKeyUp
|
||||
|
||||
@Composable
|
||||
internal fun TodoMainUi(
|
||||
state: State,
|
||||
output: Consumer<Output>,
|
||||
intents: (Intent) -> Unit
|
||||
) {
|
||||
Column {
|
||||
TopAppBar(title = { Text(text = "Todo List") })
|
||||
|
||||
Box(Modifier.weight(1F)) {
|
||||
TodoList(
|
||||
items = state.items,
|
||||
onItemClicked = { output.onNext(Output.Selected(id = it)) },
|
||||
onDoneChanged = { id, isDone -> intents(Intent.SetItemDone(id = id, isDone = isDone)) },
|
||||
onDeleteItemClicked = { intents(Intent.DeleteItem(id = it)) }
|
||||
)
|
||||
}
|
||||
|
||||
TodoInput(
|
||||
text = state.text,
|
||||
onAddClicked = { intents(Intent.AddItem) },
|
||||
onTextChanged = { intents(Intent.SetText(text = it)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TodoList(
|
||||
items: List<TodoItem>,
|
||||
onItemClicked: (id: Long) -> Unit,
|
||||
onDoneChanged: (id: Long, isDone: Boolean) -> Unit,
|
||||
onDeleteItemClicked: (id: Long) -> Unit
|
||||
) {
|
||||
LazyColumnFor(items = items) { item ->
|
||||
Row(modifier = Modifier.clickable(onClick = { onItemClicked(item.id) })) {
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
Checkbox(
|
||||
checked = item.isDone,
|
||||
modifier = Modifier.align(Alignment.CenterVertically),
|
||||
onCheckedChange = { onDoneChanged(item.id, it) }
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
Text(
|
||||
text = AnnotatedString(item.text),
|
||||
modifier = Modifier.weight(1F).align(Alignment.CenterVertically),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
IconButton(onClick = { onDeleteItemClicked(item.id) }) {
|
||||
Icon(Icons.Default.Delete)
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalKeyInput::class)
|
||||
@Composable
|
||||
private fun TodoInput(
|
||||
text: String,
|
||||
onTextChanged: (String) -> Unit,
|
||||
onAddClicked: () -> Unit
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = text,
|
||||
modifier = Modifier.weight(weight = 1F).keyInputFilter(onKeyUp(Key.Enter, onAddClicked)),
|
||||
onValueChange = onTextChanged,
|
||||
label = { Text(text = "Add a todo") }
|
||||
)
|
||||
|
||||
Button(modifier = Modifier.padding(start = 8.dp), onClick = onAddClicked) {
|
||||
Text(text = "+")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package example.todo.common.main.integration
|
||||
|
||||
import com.arkivanov.decompose.DefaultComponentContext
|
||||
import com.arkivanov.decompose.lifecycle.LifecycleRegistry
|
||||
import com.arkivanov.mvikotlin.core.store.StoreFactory
|
||||
import com.arkivanov.mvikotlin.main.store.DefaultStoreFactory
|
||||
import com.badoo.reaktive.base.Consumer
|
||||
import com.badoo.reaktive.scheduler.overrideSchedulers
|
||||
import com.badoo.reaktive.subject.publish.PublishSubject
|
||||
import com.badoo.reaktive.test.observable.assertValue
|
||||
import com.badoo.reaktive.test.observable.test
|
||||
import com.badoo.reaktive.test.scheduler.TestScheduler
|
||||
import example.todo.common.database.TestDatabaseDriver
|
||||
import example.todo.common.database.TodoItemEntity
|
||||
import example.todo.common.main.TodoMain.Dependencies
|
||||
import example.todo.common.main.TodoMain.Output
|
||||
import example.todo.common.main.store.TodoItem
|
||||
import example.todo.common.main.store.TodoMainStore.Intent
|
||||
import example.todo.database.TodoDatabase
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
@Suppress("TestFunctionName")
|
||||
class TodoMainTest {
|
||||
|
||||
private val lifecycle = LifecycleRegistry()
|
||||
private val database = TodoDatabase(TestDatabaseDriver())
|
||||
private val outputSubject = PublishSubject<Output>()
|
||||
private val output = outputSubject.test()
|
||||
|
||||
private val queries = database.todoDatabaseQueries
|
||||
|
||||
private val impl by lazy {
|
||||
TodoMainImpl(
|
||||
componentContext = DefaultComponentContext(lifecycle = lifecycle),
|
||||
dependencies = object : Dependencies {
|
||||
override val storeFactory: StoreFactory = DefaultStoreFactory
|
||||
override val database: TodoDatabase = this@TodoMainTest.database
|
||||
override val mainOutput: Consumer<Output> = outputSubject
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@BeforeTest
|
||||
fun before() {
|
||||
overrideSchedulers(
|
||||
main = { TestScheduler() },
|
||||
io = { TestScheduler() }
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun WHEN_item_added_to_database_THEN_item_displayed() {
|
||||
queries.add("Item1")
|
||||
|
||||
assertEquals("Item1", firstItem().text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun WHEN_item_deleted_from_database_THEN_item_not_displayed() {
|
||||
queries.add("Item1")
|
||||
val id = lastInsertItem().id
|
||||
|
||||
queries.delete(id = id)
|
||||
|
||||
assertFalse(impl.state.items.any { it.id == id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun WHEN_item_selected_THEN_Output_Selected_emitted() {
|
||||
queries.add("Item1")
|
||||
val id = firstItem().id
|
||||
|
||||
impl.onOutput(Output.Selected(id = id))
|
||||
|
||||
output.assertValue(Output.Selected(id = id))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun GIVEN_item_isDone_false_WHEN_done_changed_to_true_THEN_item_isDone_true_in_database() {
|
||||
queries.add("Item1")
|
||||
val id = firstItem().id
|
||||
queries.setDone(id = id, isDone = false)
|
||||
|
||||
impl.onIntent(Intent.SetItemDone(id = id, isDone = true))
|
||||
|
||||
assertTrue(queries.select(id = id).executeAsOne().isDone)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun GIVEN_item_isDone_true_WHEN_done_changed_to_false_THEN_item_isDone_false_in_database() {
|
||||
queries.add("Item1")
|
||||
val id = firstItem().id
|
||||
queries.setDone(id = id, isDone = true)
|
||||
|
||||
impl.onIntent(Intent.SetItemDone(id = id, isDone = false))
|
||||
|
||||
assertFalse(queries.select(id = id).executeAsOne().isDone)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun WHEN_delete_clicked_THEN_item_deleted_in_database() {
|
||||
queries.add("Item1")
|
||||
val id = firstItem().id
|
||||
|
||||
impl.onIntent(Intent.DeleteItem(id = id))
|
||||
|
||||
assertNull(queries.select(id = id).executeAsOneOrNull())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun WHEN_item_text_changed_in_database_THEN_item_updated() {
|
||||
queries.add("Item1")
|
||||
val id = firstItem().id
|
||||
|
||||
queries.setText(id = id, text = "New text")
|
||||
|
||||
assertEquals("New text", firstItem().text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun WHEN_text_changed_THEN_text_updated() {
|
||||
impl.onIntent(Intent.SetText(text = "Item text"))
|
||||
|
||||
assertEquals("Item text", impl.state.text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun GIVEN_text_entered_WHEN_add_clicked_THEN_item_added_in_database() {
|
||||
impl.onIntent(Intent.SetText(text = "Item text"))
|
||||
|
||||
impl.onIntent(Intent.AddItem)
|
||||
|
||||
assertEquals("Item text", lastInsertItem().text)
|
||||
}
|
||||
|
||||
private fun firstItem(): TodoItem = impl.state.items[0]
|
||||
|
||||
private fun lastInsertItem(): TodoItemEntity {
|
||||
val lastInsertId = queries.getLastInsertId().executeAsOne()
|
||||
|
||||
return queries.select(id = lastInsertId).executeAsOne()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package example.todo.common.main.store
|
||||
|
||||
import com.badoo.reaktive.completable.Completable
|
||||
import com.badoo.reaktive.completable.completableFromFunction
|
||||
import com.badoo.reaktive.observable.Observable
|
||||
import com.badoo.reaktive.subject.behavior.BehaviorSubject
|
||||
|
||||
internal class TestTodoMainStoreDatabase : TodoMainStoreProvider.Database {
|
||||
|
||||
private val subject = BehaviorSubject<List<TodoItem>>(emptyList())
|
||||
|
||||
var items: List<TodoItem>
|
||||
get() = subject.value
|
||||
set(value) {
|
||||
subject.onNext(value)
|
||||
}
|
||||
|
||||
override val updates: Observable<List<TodoItem>> = subject
|
||||
|
||||
override fun setDone(id: Long, isDone: Boolean): Completable =
|
||||
completableFromFunction {
|
||||
update(id = id) { copy(isDone = isDone) }
|
||||
}
|
||||
|
||||
override fun delete(id: Long): Completable =
|
||||
completableFromFunction {
|
||||
this.items = items.filterNot { it.id == id }
|
||||
}
|
||||
|
||||
override fun add(text: String): Completable =
|
||||
completableFromFunction {
|
||||
val id = items.maxBy(TodoItem::id)?.id?.inc() ?: 1L
|
||||
this.items += TodoItem(id = id, order = id, text = text)
|
||||
}
|
||||
|
||||
private fun update(id: Long, func: TodoItem.() -> TodoItem) {
|
||||
items = items.map { if (it.id == id) it.func() else it }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package example.todo.common.main.store
|
||||
|
||||
import com.arkivanov.mvikotlin.main.store.DefaultStoreFactory
|
||||
import com.badoo.reaktive.scheduler.overrideSchedulers
|
||||
import com.badoo.reaktive.test.scheduler.TestScheduler
|
||||
import example.todo.common.main.store.TodoMainStore.Intent
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
@Suppress("TestFunctionName")
|
||||
class TodoMainStoreTest {
|
||||
|
||||
private val database = TestTodoMainStoreDatabase()
|
||||
private val provider = TodoMainStoreProvider(storeFactory = DefaultStoreFactory, database = database)
|
||||
|
||||
@BeforeTest
|
||||
fun before() {
|
||||
overrideSchedulers(main = { TestScheduler() })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun GIVEN_items_in_database_WHEN_created_THEN_loads_items_from_database() {
|
||||
val item1 = TodoItem(id = 1L, order = 2L, text = "item1")
|
||||
val item2 = TodoItem(id = 2L, order = 1L, text = "item2", isDone = true)
|
||||
val item3 = TodoItem(id = 3L, order = 3L, text = "item3")
|
||||
database.items = listOf(item1, item2, item3)
|
||||
|
||||
val store = provider.provide()
|
||||
|
||||
assertEquals(listOf(item3, item1, item2), store.state.items)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun WHEN_items_changed_in_database_THEN_contains_new_items() {
|
||||
database.items = listOf(TodoItem())
|
||||
val store = provider.provide()
|
||||
|
||||
val item1 = TodoItem(id = 1L, order = 2L, text = "item1")
|
||||
val item2 = TodoItem(id = 2L, order = 1L, text = "item2", isDone = true)
|
||||
val item3 = TodoItem(id = 3L, order = 3L, text = "item3")
|
||||
database.items = listOf(item1, item2, item3)
|
||||
|
||||
assertEquals(listOf(item3, item1, item2), store.state.items)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun WHEN_Intent_SetItemDone_THEN_done_changed_in_state() {
|
||||
val item1 = TodoItem(id = 1L, text = "item1")
|
||||
val item2 = TodoItem(id = 2L, text = "item2", isDone = false)
|
||||
database.items = listOf(item1, item2)
|
||||
val store = provider.provide()
|
||||
|
||||
store.accept(Intent.SetItemDone(id = 2L, isDone = true))
|
||||
|
||||
assertTrue(store.state.items.first { it.id == 2L }.isDone)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun WHEN_Intent_SetItemDone_THEN_done_changed_in_database() {
|
||||
val item1 = TodoItem(id = 1L, text = "item1")
|
||||
val item2 = TodoItem(id = 2L, text = "item2", isDone = false)
|
||||
database.items = listOf(item1, item2)
|
||||
val store = provider.provide()
|
||||
|
||||
store.accept(Intent.SetItemDone(id = 2L, isDone = true))
|
||||
|
||||
assertTrue(database.items.first { it.id == 2L }.isDone)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun WHEN_Intent_DeleteItem_THEN_item_deleted_in_state() {
|
||||
val item1 = TodoItem(id = 1L, text = "item1")
|
||||
val item2 = TodoItem(id = 2L, text = "item2")
|
||||
database.items = listOf(item1, item2)
|
||||
val store = provider.provide()
|
||||
|
||||
store.accept(Intent.DeleteItem(id = 2L))
|
||||
|
||||
assertFalse(store.state.items.any { it.id == 2L })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun WHEN_Intent_DeleteItem_THEN_item_deleted_in_database() {
|
||||
val item1 = TodoItem(id = 1L, text = "item1")
|
||||
val item2 = TodoItem(id = 2L, text = "item2")
|
||||
database.items = listOf(item1, item2)
|
||||
val store = provider.provide()
|
||||
|
||||
store.accept(Intent.DeleteItem(id = 2L))
|
||||
|
||||
assertFalse(database.items.any { it.id == 2L })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun WHEN_Intent_SetText_WHEN_text_changed_in_state() {
|
||||
val store = provider.provide()
|
||||
|
||||
store.accept(Intent.SetText(text = "Item text"))
|
||||
|
||||
assertEquals("Item text", store.state.text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun GIVEN_text_entered_WHEN_Intent_AddItem_THEN_item_added_in_database() {
|
||||
val store = provider.provide()
|
||||
store.accept(Intent.SetText(text = "Item text"))
|
||||
|
||||
store.accept(Intent.AddItem)
|
||||
|
||||
assertTrue(database.items.any { it.text == "Item text" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun GIVEN_text_entered_WHEN_Intent_AddItem_THEN_text_cleared_in_state() {
|
||||
val store = provider.provide()
|
||||
store.accept(Intent.SetText(text = "Item text"))
|
||||
|
||||
store.accept(Intent.AddItem)
|
||||
|
||||
assertEquals("", store.state.text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun GIVEN_no_text_entered_WHEN_Intent_AddItem_THEN_item_not_added() {
|
||||
val store = provider.provide()
|
||||
|
||||
store.accept(Intent.AddItem)
|
||||
|
||||
assertEquals(0, store.state.items.size)
|
||||
}
|
||||
}
|
||||
1
examples/todoapp/common/root/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/build
|
||||
24
examples/todoapp/common/root/build.gradle.kts
Executable file
@@ -0,0 +1,24 @@
|
||||
plugins {
|
||||
id("multiplatform-setup")
|
||||
id("multiplatform-compose-setup")
|
||||
id("kotlin-android-extensions")
|
||||
}
|
||||
|
||||
androidExtensions {
|
||||
features = setOf("parcelize")
|
||||
}
|
||||
|
||||
kotlin {
|
||||
sourceSets {
|
||||
named("commonMain") {
|
||||
dependencies {
|
||||
implementation(project(":common:utils"))
|
||||
implementation(project(":common:database"))
|
||||
implementation(project(":common:main"))
|
||||
implementation(project(":common:edit"))
|
||||
implementation(Deps.ArkIvanov.Decompose.decompose)
|
||||
implementation(Deps.Badoo.Reaktive.reaktive)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
2
examples/todoapp/common/root/src/androidMain/AndroidManifest.xml
Executable file
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest package="example.todo.common.root"/>
|
||||
@@ -0,0 +1,20 @@
|
||||
package example.todo.common.root
|
||||
|
||||
import com.arkivanov.decompose.ComponentContext
|
||||
import com.arkivanov.mvikotlin.core.store.StoreFactory
|
||||
import example.todo.common.root.TodoRoot.Dependencies
|
||||
import example.todo.common.root.integration.TodoRootImpl
|
||||
import example.todo.common.utils.Component
|
||||
import example.todo.database.TodoDatabase
|
||||
|
||||
interface TodoRoot : Component {
|
||||
|
||||
interface Dependencies {
|
||||
val storeFactory: StoreFactory
|
||||
val database: TodoDatabase
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("FunctionName") // Factory function
|
||||
fun TodoRoot(componentContext: ComponentContext, dependencies: Dependencies): TodoRoot =
|
||||
TodoRootImpl(componentContext, dependencies)
|
||||
@@ -0,0 +1,79 @@
|
||||
package example.todo.common.root.integration
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.arkivanov.decompose.ComponentContext
|
||||
import com.arkivanov.decompose.router
|
||||
import com.arkivanov.decompose.statekeeper.Parcelable
|
||||
import com.arkivanov.decompose.statekeeper.Parcelize
|
||||
import com.badoo.reaktive.base.Consumer
|
||||
import example.todo.common.edit.TodoEdit
|
||||
import example.todo.common.main.TodoMain
|
||||
import example.todo.common.root.TodoRoot
|
||||
import example.todo.common.root.TodoRoot.Dependencies
|
||||
import example.todo.common.utils.Component
|
||||
import example.todo.common.utils.Consumer
|
||||
import example.todo.common.utils.Crossfade
|
||||
import example.todo.common.utils.children
|
||||
|
||||
internal class TodoRootImpl(
|
||||
componentContext: ComponentContext,
|
||||
dependencies: Dependencies
|
||||
) : TodoRoot, ComponentContext by componentContext, Dependencies by dependencies {
|
||||
|
||||
private val router =
|
||||
router<Configuration, Component>(
|
||||
initialConfiguration = Configuration.Main,
|
||||
handleBackButton = true,
|
||||
componentFactory = ::createChild
|
||||
)
|
||||
|
||||
private fun createChild(configuration: Configuration, componentContext: ComponentContext): Component =
|
||||
when (configuration) {
|
||||
is Configuration.Main -> todoMain(componentContext)
|
||||
is Configuration.Edit -> todoEdit(componentContext, itemId = configuration.itemId)
|
||||
}
|
||||
|
||||
private fun todoMain(componentContext: ComponentContext): TodoMain =
|
||||
TodoMain(
|
||||
componentContext = componentContext,
|
||||
dependencies = object : TodoMain.Dependencies, Dependencies by this {
|
||||
override val mainOutput: Consumer<TodoMain.Output> = Consumer(::onMainOutput)
|
||||
}
|
||||
)
|
||||
|
||||
private fun todoEdit(componentContext: ComponentContext, itemId: Long): TodoEdit =
|
||||
TodoEdit(
|
||||
componentContext = componentContext,
|
||||
dependencies = object : TodoEdit.Dependencies, Dependencies by this {
|
||||
override val itemId: Long = itemId
|
||||
override val editOutput: Consumer<TodoEdit.Output> = Consumer(::onEditOutput)
|
||||
}
|
||||
)
|
||||
|
||||
private fun onMainOutput(output: TodoMain.Output): Unit =
|
||||
when (output) {
|
||||
is TodoMain.Output.Selected -> router.push(Configuration.Edit(itemId = output.id))
|
||||
}
|
||||
|
||||
private fun onEditOutput(output: TodoEdit.Output): Unit =
|
||||
when (output) {
|
||||
is TodoEdit.Output.Finished -> router.pop()
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun invoke() {
|
||||
router.state.children { child, configuration ->
|
||||
Crossfade(currentChild = child, currentKey = configuration) { currentChild ->
|
||||
currentChild()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class Configuration : Parcelable {
|
||||
@Parcelize
|
||||
object Main : Configuration()
|
||||
|
||||
@Parcelize
|
||||
data class Edit(val itemId: Long) : Configuration()
|
||||
}
|
||||
}
|
||||
1
examples/todoapp/common/utils/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/build
|
||||
16
examples/todoapp/common/utils/build.gradle.kts
Executable file
@@ -0,0 +1,16 @@
|
||||
plugins {
|
||||
id("multiplatform-setup")
|
||||
id("multiplatform-compose-setup")
|
||||
}
|
||||
|
||||
kotlin {
|
||||
sourceSets {
|
||||
named("commonMain") {
|
||||
dependencies {
|
||||
implementation(Deps.ArkIvanov.MVIKotlin.mvikotlin)
|
||||
implementation(Deps.ArkIvanov.MVIKotlin.mvikotlinExtensionsReaktive)
|
||||
implementation(Deps.ArkIvanov.Decompose.decompose)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
2
examples/todoapp/common/utils/src/androidMain/AndroidManifest.xml
Executable file
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest package="example.todo.common.list"/>
|
||||
@@ -0,0 +1,24 @@
|
||||
package example.todo.common.utils
|
||||
|
||||
import com.arkivanov.decompose.ComponentContext
|
||||
import com.arkivanov.decompose.lifecycle.Lifecycle
|
||||
import com.arkivanov.decompose.lifecycle.subscribe
|
||||
import com.arkivanov.mvikotlin.core.binder.Binder
|
||||
import com.arkivanov.mvikotlin.core.binder.BinderLifecycleMode
|
||||
import com.arkivanov.mvikotlin.extensions.reaktive.BindingsBuilder
|
||||
import com.arkivanov.mvikotlin.extensions.reaktive.bind
|
||||
|
||||
fun bind(lifecycle: Lifecycle, mode: BinderLifecycleMode, builder: BindingsBuilder.() -> Unit): Binder {
|
||||
val binder = bind(builder)
|
||||
|
||||
when (mode) {
|
||||
BinderLifecycleMode.CREATE_DESTROY -> lifecycle.subscribe(onCreate = { binder.start() }, onDestroy = { binder.stop() })
|
||||
BinderLifecycleMode.START_STOP -> lifecycle.subscribe(onStart = { binder.start() }, onStop = { binder.stop() })
|
||||
BinderLifecycleMode.RESUME_PAUSE -> lifecycle.subscribe(onResume = { binder.start() }, onPause = { binder.stop() })
|
||||
}.let {}
|
||||
|
||||
return binder
|
||||
}
|
||||
|
||||
fun ComponentContext.bind(mode: BinderLifecycleMode, builder: BindingsBuilder.() -> Unit): Binder =
|
||||
bind(lifecycle, mode, builder)
|
||||
@@ -0,0 +1,9 @@
|
||||
package example.todo.common.utils
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
interface Component {
|
||||
|
||||
@Composable
|
||||
operator fun invoke()
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package example.todo.common.utils
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
@Composable
|
||||
fun <T> Crossfade(currentChild: T, currentKey: Any, children: @Composable() (T) -> Unit) {
|
||||
Crossfade(current = ChildWrapper(currentChild, currentKey)) {
|
||||
children(it.child)
|
||||
}
|
||||
}
|
||||
|
||||
private class ChildWrapper<out T>(val child: T, val key: Any) {
|
||||
override fun equals(other: Any?): Boolean = key == (other as? ChildWrapper<*>)?.key
|
||||
override fun hashCode(): Int = key.hashCode()
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package example.todo.common.utils
|
||||
|
||||
import com.arkivanov.decompose.instancekeeper.InstanceKeeper
|
||||
import com.arkivanov.decompose.instancekeeper.getOrCreate
|
||||
import com.arkivanov.mvikotlin.core.store.Store
|
||||
|
||||
fun <T : Store<*, *, *>> InstanceKeeper.getStore(key: Any, factory: () -> T): T =
|
||||
getOrCreate(key) { StoreHolder(factory()) }
|
||||
.store
|
||||
|
||||
inline fun <reified T : Store<*, *, *>> InstanceKeeper.getStore(noinline factory: () -> T): T =
|
||||
getStore(T::class, factory)
|
||||
|
||||
private class StoreHolder<T : Store<*, *, *>>(
|
||||
val store: T
|
||||
) : InstanceKeeper.Instance {
|
||||
override fun onDestroy() {
|
||||
store.dispose()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package example.todo.common.utils
|
||||
|
||||
import com.badoo.reaktive.base.Consumer
|
||||
|
||||
@Suppress("FunctionName") // Factory function
|
||||
inline fun <T> Consumer(crossinline block: (T) -> Unit): Consumer<T> =
|
||||
object : Consumer<T> {
|
||||
override fun onNext(value: T) {
|
||||
block(value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* Copied from Decompose
|
||||
*/
|
||||
|
||||
package example.todo.common.utils
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Providers
|
||||
import androidx.compose.runtime.onDispose
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.savedinstancestate.UiSavedStateRegistry
|
||||
import androidx.compose.runtime.savedinstancestate.UiSavedStateRegistryAmbient
|
||||
import com.arkivanov.decompose.RouterState
|
||||
import com.arkivanov.decompose.statekeeper.Parcelable
|
||||
import com.arkivanov.decompose.value.Value
|
||||
|
||||
private typealias SavedState = Map<String, List<Any?>>
|
||||
|
||||
@Composable
|
||||
fun <C : Parcelable, T : Any> Value<RouterState<C, T>>.children(render: @Composable() (child: T, configuration: C) -> Unit) {
|
||||
val parentRegistry: UiSavedStateRegistry? = UiSavedStateRegistryAmbient.current
|
||||
val children = remember { Children<C>() }
|
||||
|
||||
if (parentRegistry != null) {
|
||||
onDispose {
|
||||
children.inactive.entries.forEach { (key, value) ->
|
||||
parentRegistry.unregisterProvider(key, value.provider)
|
||||
}
|
||||
children.active?.also {
|
||||
parentRegistry.unregisterProvider(it.key, it.provider)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
invoke { state ->
|
||||
val activeChildConfiguration = state.activeChild.configuration
|
||||
|
||||
val currentChild: ActiveChild<C>? = children.active
|
||||
if ((currentChild != null) && state.backStack.any { it.configuration === currentChild.configuration }) {
|
||||
parentRegistry?.unregisterProvider(currentChild.key, currentChild.provider)
|
||||
val inactiveChild = InactiveChild(configuration = currentChild.configuration, savedState = currentChild.provider())
|
||||
children.inactive[currentChild.key] = inactiveChild
|
||||
parentRegistry?.registerProvider(currentChild.key, inactiveChild.provider)
|
||||
}
|
||||
|
||||
val activeChildRegistry: UiSavedStateRegistry
|
||||
|
||||
if (currentChild?.configuration === activeChildConfiguration) {
|
||||
activeChildRegistry = currentChild.registry
|
||||
} else {
|
||||
val key = activeChildConfiguration.toString()
|
||||
|
||||
val savedChild: InactiveChild<C>? = children.inactive.remove(key)
|
||||
if (savedChild != null) {
|
||||
parentRegistry?.unregisterProvider(key, savedChild.provider)
|
||||
}
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val savedState: SavedState? = savedChild?.savedState ?: parentRegistry?.consumeRestored(key) as SavedState?
|
||||
|
||||
activeChildRegistry = UiSavedStateRegistry(savedState) { true }
|
||||
|
||||
val newActiveChild = ActiveChild(configuration = activeChildConfiguration, key = key, registry = activeChildRegistry)
|
||||
children.active = newActiveChild
|
||||
parentRegistry?.registerProvider(key, newActiveChild.provider)
|
||||
}
|
||||
|
||||
children.inactive.entries.removeAll { (key, value) ->
|
||||
val remove = state.backStack.none { it.configuration === value.configuration }
|
||||
if (remove) {
|
||||
parentRegistry?.unregisterProvider(key, value.provider)
|
||||
}
|
||||
remove
|
||||
}
|
||||
|
||||
Providers(UiSavedStateRegistryAmbient provides activeChildRegistry) {
|
||||
render(state.activeChild.component, activeChildConfiguration)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class Children<C : Parcelable> {
|
||||
val inactive: MutableMap<String, InactiveChild<C>> = HashMap()
|
||||
var active: ActiveChild<C>? = null
|
||||
}
|
||||
|
||||
private class ActiveChild<out C : Parcelable>(
|
||||
val configuration: C,
|
||||
val key: String,
|
||||
val registry: UiSavedStateRegistry
|
||||
) {
|
||||
val provider: () -> SavedState = registry::performSave
|
||||
}
|
||||
|
||||
private class InactiveChild<out C : Parcelable>(
|
||||
val configuration: C,
|
||||
val savedState: SavedState
|
||||
) {
|
||||
val provider: () -> SavedState = ::savedState
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package example.todo.common.utils
|
||||
|
||||
import androidx.compose.ui.input.key.ExperimentalKeyInput
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEvent
|
||||
|
||||
@OptIn(ExperimentalKeyInput::class)
|
||||
fun onKeyUp(key: Key, onEvent: () -> Unit): (KeyEvent) -> Boolean =
|
||||
{ keyEvent ->
|
||||
if (keyEvent.key == key) {
|
||||
onEvent()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package example.todo.common.utils
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.onDispose
|
||||
import androidx.compose.runtime.remember
|
||||
import com.arkivanov.mvikotlin.core.store.Store
|
||||
import com.arkivanov.mvikotlin.extensions.reaktive.states
|
||||
import com.badoo.reaktive.observable.subscribe
|
||||
|
||||
@Composable
|
||||
val <T : Any> Store<*, T, *>.composeState: State<T>
|
||||
get() {
|
||||
val composeState = remember(this) { mutableStateOf(state) }
|
||||
val disposable = states.subscribe(onNext = { composeState.value = it })
|
||||
onDispose(disposable::dispose)
|
||||
|
||||
return composeState
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Copied from Decompose
|
||||
*/
|
||||
|
||||
package example.todo.common.utils
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.onDispose
|
||||
import androidx.compose.runtime.remember
|
||||
import com.arkivanov.decompose.value.Value
|
||||
import com.arkivanov.decompose.value.ValueObserver
|
||||
|
||||
@Composable
|
||||
fun <T : Any> Value<T>.asState(): State<T> {
|
||||
val composeState = remember(this) { mutableStateOf(value) }
|
||||
|
||||
val observer =
|
||||
remember(this) {
|
||||
val observer: ValueObserver<T> = { composeState.value = it }
|
||||
subscribe(observer)
|
||||
observer
|
||||
}
|
||||
|
||||
onDispose { unsubscribe(observer) }
|
||||
|
||||
return composeState
|
||||
}
|
||||
|
||||
@Composable
|
||||
operator fun <T : Any> Value<T>.invoke(render: @Composable() (T) -> Unit) {
|
||||
render(asState().value)
|
||||
}
|
||||
1
examples/todoapp/desktop/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/build
|
||||
24
examples/todoapp/desktop/build.gradle.kts
Executable file
@@ -0,0 +1,24 @@
|
||||
import org.jetbrains.compose.compose
|
||||
|
||||
plugins {
|
||||
kotlin("jvm")
|
||||
id("org.jetbrains.compose")
|
||||
java
|
||||
application
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(compose.desktop.all)
|
||||
implementation(project(":common:utils"))
|
||||
implementation(project(":common:database"))
|
||||
implementation(project(":common:root"))
|
||||
implementation(Deps.ArkIvanov.Decompose.decompose)
|
||||
implementation(Deps.ArkIvanov.MVIKotlin.mvikotlin)
|
||||
implementation(Deps.ArkIvanov.MVIKotlin.mvikotlinMain)
|
||||
implementation(Deps.Badoo.Reaktive.reaktive)
|
||||
implementation(Deps.Badoo.Reaktive.coroutinesInterop)
|
||||
}
|
||||
|
||||
application {
|
||||
mainClassName = "example.todo.desktop.MainKt"
|
||||
}
|
||||
35
examples/todoapp/desktop/src/main/kotlin/example/todo/desktop/Main.kt
Executable file
@@ -0,0 +1,35 @@
|
||||
package example.todo.desktop
|
||||
|
||||
import androidx.compose.desktop.AppWindow
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material.Surface
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.arkivanov.decompose.DefaultComponentContext
|
||||
import com.arkivanov.decompose.lifecycle.LifecycleRegistry
|
||||
import com.arkivanov.decompose.lifecycle.resume
|
||||
import com.arkivanov.mvikotlin.main.store.DefaultStoreFactory
|
||||
import com.badoo.reaktive.coroutinesinterop.asScheduler
|
||||
import com.badoo.reaktive.scheduler.overrideSchedulers
|
||||
import example.todo.common.database.TodoDatabaseDriver
|
||||
import example.todo.common.root.TodoRoot
|
||||
import example.todo.database.TodoDatabase
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
||||
fun main() {
|
||||
overrideSchedulers(main = Dispatchers.Main::asScheduler)
|
||||
|
||||
val lifecycle = LifecycleRegistry()
|
||||
lifecycle.resume()
|
||||
|
||||
AppWindow("Todo").show {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
TodoRoot(
|
||||
componentContext = DefaultComponentContext(lifecycle),
|
||||
dependencies = object : TodoRoot.Dependencies {
|
||||
override val storeFactory = DefaultStoreFactory
|
||||
override val database = TodoDatabase(TodoDatabaseDriver())
|
||||
}
|
||||
).invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
23
examples/todoapp/gradle.properties
Executable file
@@ -0,0 +1,23 @@
|
||||
# Project-wide Gradle settings.
|
||||
# IDE (e.g. Android Studio) users:
|
||||
# Gradle settings configured through the IDE *will override*
|
||||
# any settings specified in this file.
|
||||
# For more details on how to configure your build environment visit
|
||||
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||
# Specifies the JVM arguments used for the daemon process.
|
||||
# The setting is particularly useful for tweaking memory settings.
|
||||
org.gradle.jvmargs=-Xmx2048m
|
||||
# When configured, Gradle will run in incubating parallel mode.
|
||||
# This option should only be used with decoupled projects. More details, visit
|
||||
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
|
||||
# org.gradle.parallel=true
|
||||
# AndroidX package structure to make it clearer which packages are bundled with the
|
||||
# Android operating system, and which are packaged with your app"s APK
|
||||
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||
android.useAndroidX=true
|
||||
# Automatically convert third-party libraries to use AndroidX
|
||||
android.enableJetifier=true
|
||||
# Kotlin code style for this project: "official" or "obsolete":
|
||||
kotlin.code.style=official
|
||||
org.gradle.parallel=true
|
||||
org.gradle.caching=true
|
||||
BIN
examples/todoapp/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
5
examples/todoapp/gradle/wrapper/gradle-wrapper.properties
vendored
Executable file
@@ -0,0 +1,5 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-6.5-bin.zip
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
185
examples/todoapp/gradlew
vendored
Executable file
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
#
|
||||
# Copyright 2015 the original author or authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
##
|
||||
## Gradle start up script for UN*X
|
||||
##
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
# Resolve links: $0 may be a link
|
||||
PRG="$0"
|
||||
# Need this for relative symlinks.
|
||||
while [ -h "$PRG" ] ; do
|
||||
ls=`ls -ld "$PRG"`
|
||||
link=`expr "$ls" : '.*-> \(.*\)$'`
|
||||
if expr "$link" : '/.*' > /dev/null; then
|
||||
PRG="$link"
|
||||
else
|
||||
PRG=`dirname "$PRG"`"/$link"
|
||||
fi
|
||||
done
|
||||
SAVED="`pwd`"
|
||||
cd "`dirname \"$PRG\"`/" >/dev/null
|
||||
APP_HOME="`pwd -P`"
|
||||
cd "$SAVED" >/dev/null
|
||||
|
||||
APP_NAME="Gradle"
|
||||
APP_BASE_NAME=`basename "$0"`
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD="maximum"
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
}
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
}
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "`uname`" in
|
||||
CYGWIN* )
|
||||
cygwin=true
|
||||
;;
|
||||
Darwin* )
|
||||
darwin=true
|
||||
;;
|
||||
MINGW* )
|
||||
msys=true
|
||||
;;
|
||||
NONSTOP* )
|
||||
nonstop=true
|
||||
;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD="$JAVA_HOME/jre/sh/java"
|
||||
else
|
||||
JAVACMD="$JAVA_HOME/bin/java"
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD="java"
|
||||
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
|
||||
MAX_FD_LIMIT=`ulimit -H -n`
|
||||
if [ $? -eq 0 ] ; then
|
||||
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
|
||||
MAX_FD="$MAX_FD_LIMIT"
|
||||
fi
|
||||
ulimit -n $MAX_FD
|
||||
if [ $? -ne 0 ] ; then
|
||||
warn "Could not set maximum file descriptor limit: $MAX_FD"
|
||||
fi
|
||||
else
|
||||
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
|
||||
fi
|
||||
fi
|
||||
|
||||
# For Darwin, add options to specify how the application appears in the dock
|
||||
if $darwin; then
|
||||
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
|
||||
fi
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
|
||||
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
|
||||
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
|
||||
|
||||
JAVACMD=`cygpath --unix "$JAVACMD"`
|
||||
|
||||
# We build the pattern for arguments to be converted via cygpath
|
||||
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
|
||||
SEP=""
|
||||
for dir in $ROOTDIRSRAW ; do
|
||||
ROOTDIRS="$ROOTDIRS$SEP$dir"
|
||||
SEP="|"
|
||||
done
|
||||
OURCYGPATTERN="(^($ROOTDIRS))"
|
||||
# Add a user-defined pattern to the cygpath arguments
|
||||
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
|
||||
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
|
||||
fi
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
i=0
|
||||
for arg in "$@" ; do
|
||||
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
|
||||
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
|
||||
|
||||
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
|
||||
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
|
||||
else
|
||||
eval `echo args$i`="\"$arg\""
|
||||
fi
|
||||
i=`expr $i + 1`
|
||||
done
|
||||
case $i in
|
||||
0) set -- ;;
|
||||
1) set -- "$args0" ;;
|
||||
2) set -- "$args0" "$args1" ;;
|
||||
3) set -- "$args0" "$args1" "$args2" ;;
|
||||
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
|
||||
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
|
||||
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
|
||||
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
|
||||
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
|
||||
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Escape application args
|
||||
save () {
|
||||
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
|
||||
echo " "
|
||||
}
|
||||
APP_ARGS=`save "$@"`
|
||||
|
||||
# Collect all arguments for the java command, following the shell quoting and substitution rules
|
||||
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
104
examples/todoapp/gradlew.bat
vendored
Executable file
@@ -0,0 +1,104 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%" == "" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%" == "" set DIRNAME=.
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if "%ERRORLEVEL%" == "0" goto init
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto init
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:init
|
||||
@rem Get command-line arguments, handling Windows variants
|
||||
|
||||
if not "%OS%" == "Windows_NT" goto win9xME_args
|
||||
|
||||
:win9xME_args
|
||||
@rem Slurp the command line arguments.
|
||||
set CMD_LINE_ARGS=
|
||||
set _SKIP=2
|
||||
|
||||
:win9xME_args_slurp
|
||||
if "x%~1" == "x" goto execute
|
||||
|
||||
set CMD_LINE_ARGS=%*
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if "%ERRORLEVEL%"=="0" goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
|
||||
exit /b 1
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
9
examples/todoapp/settings.gradle.kts
Executable file
@@ -0,0 +1,9 @@
|
||||
include(
|
||||
":common:utils",
|
||||
":common:database",
|
||||
":common:main",
|
||||
":common:edit",
|
||||
":common:root",
|
||||
":android",
|
||||
":desktop"
|
||||
)
|
||||