diff --git a/README.md b/README.md
index 96824fce..8c50e3a4 100644
--- a/README.md
+++ b/README.md
@@ -21,14 +21,14 @@ Sesame components are separate modules. Use only that you like.
## Gradle Setup
```gradle
dependencies {
- implementation 'com.github.aartikov:sesame-property:1.2.0-beta1'
- implementation 'com.github.aartikov:sesame-dialog:1.2.0-beta1'
- implementation 'com.github.aartikov:sesame-navigation:1.2.0-beta1'
- implementation 'com.github.aartikov:sesame-activable:1.2.0-beta1'
- implementation 'com.github.aartikov:sesame-loading:1.2.0-beta1'
- implementation 'com.github.aartikov:sesame-loop:1.2.0-beta1'
- implementation 'com.github.aartikov:sesame-localized-string:1.2.0-beta1'
- implementation 'com.github.aartikov:sesame-form:1.2.0-beta1'
+ implementation 'com.github.aartikov:sesame-property:1.3.0-beta1'
+ implementation 'com.github.aartikov:sesame-dialog:1.3.0-beta1'
+ implementation 'com.github.aartikov:sesame-navigation:1.3.0-beta1'
+ implementation 'com.github.aartikov:sesame-activable:1.3.0-beta1'
+ implementation 'com.github.aartikov:sesame-loading:1.3.0-beta1'
+ implementation 'com.github.aartikov:sesame-loop:1.3.0-beta1'
+ implementation 'com.github.aartikov:sesame-localized-string:1.3.0-beta1'
+ implementation 'com.github.aartikov:sesame-form:1.3.0-beta1'
}
```
diff --git a/build.gradle b/build.gradle
index 7bfb4ece..5b0f30ab 100644
--- a/build.gradle
+++ b/build.gradle
@@ -2,16 +2,15 @@ apply from: "dependencies.gradle"
apply from: "androidLibraryConfig.gradle"
buildscript {
- ext.kotlin_version = "1.4.31"
+ ext.kotlin_version = "1.5.31"
repositories {
mavenCentral()
google()
- jcenter()
}
dependencies {
- classpath "com.android.tools.build:gradle:4.1.2"
+ classpath "com.android.tools.build:gradle:7.0.1"
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
- classpath "com.google.dagger:hilt-android-gradle-plugin:2.32-alpha"
+ classpath "com.google.dagger:hilt-android-gradle-plugin:2.38.1"
classpath "org.jetbrains.dokka:dokka-gradle-plugin:1.4.20"
}
}
diff --git a/compose-sample/.gitignore b/compose-sample/.gitignore
new file mode 100644
index 00000000..42afabfd
--- /dev/null
+++ b/compose-sample/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/compose-sample/README.md b/compose-sample/README.md
new file mode 100644
index 00000000..dd832191
--- /dev/null
+++ b/compose-sample/README.md
@@ -0,0 +1,12 @@
+# Sample
+The sample application consists of several screens. Each screen demonstrates certain Sesame feature.
+
+COUNTER - shows how to use properties and commands from [property](https://github.com/aartikov/Sesame/tree/master/sesame-property).
+PROFILE - loads ordinary data with [loading](https://github.com/aartikov/Sesame/tree/master/sesame-loading).
+DIALOGS - shows how to use [dialog](https://github.com/aartikov/Sesame/tree/master/sesame-dialog) and [localized string](https://github.com/aartikov/Sesame/tree/master/sesame-localized-string).
+MOVIES - loads paged data with [loading](https://github.com/aartikov/Sesame/tree/master/sesame-loading).
+CLOCK - shows how to use [activable](https://github.com/aartikov/Sesame/tree/master/sesame-activable).
+FORM - validates input fields with [form](https://github.com/aartikov/Sesame/tree/master/sesame-form).
+The whole app - demonstrates [navigation](https://github.com/aartikov/Sesame/tree/master/sesame-navigation).
+
+There is no sample for [loop](https://github.com/aartikov/Sesame/tree/master/sesame-loop). See [LoadingLoop](https://github.com/aartikov/Sesame/blob/master/sesame-loading/src/main/kotlin/me/aartikov/sesame/loading/simple/internal/LoadingLoop.kt) and [PagedLoadingLoop](https://github.com/aartikov/Sesame/blob/master/sesame-loading/src/main/kotlin/me/aartikov/sesame/loading/paged/internal/PagedLoadingLoop.kt) as good examples how to use it.
\ No newline at end of file
diff --git a/compose-sample/build.gradle b/compose-sample/build.gradle
new file mode 100644
index 00000000..3fcecf1e
--- /dev/null
+++ b/compose-sample/build.gradle
@@ -0,0 +1,71 @@
+apply plugin: "com.android.application"
+apply plugin: "kotlin-android"
+apply plugin: "kotlin-parcelize"
+
+android {
+ compileSdkVersion 31
+
+ defaultConfig {
+ applicationId "me.aartikov.sesamecomposesample"
+ minSdkVersion 21
+ targetSdkVersion 31
+ versionCode 1
+ versionName "1.0"
+ }
+
+ compileOptions {
+ coreLibraryDesugaringEnabled true
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ }
+
+ kotlinOptions {
+ jvmTarget = '1.8'
+ useIR = true
+ freeCompilerArgs += "-Xopt-in=kotlin.RequiresOptIn"
+ }
+ buildFeatures {
+ compose true
+ }
+ composeOptions {
+ kotlinCompilerExtensionVersion versions.compose
+ }
+
+ sourceSets {
+ main.java.srcDirs += "src/main/kotlin"
+ }
+}
+
+dependencies {
+ coreLibraryDesugaring desugaring
+
+ implementation project(":sesame-activable")
+ implementation project(":sesame-dialog")
+ implementation project(":sesame-form")
+ implementation project(":sesame-loading")
+ implementation project(":sesame-localized-string")
+ implementation project(":sesame-compose-form")
+
+ implementation androidx.appCompat
+ implementation androidx.activityCompose
+
+ implementation compose.ui
+ implementation compose.material
+ implementation compose.uiTooling
+ implementation "androidx.compose.material:material-icons-extended:1.0.1"
+
+ implementation decompose.core
+ implementation decompose.compose
+
+ implementation coroutines.core
+ implementation coroutines.android
+ implementation dateTime
+
+ implementation coil
+
+ implementation koin
+
+ implementation accompanist.swiperefresh
+
+ implementation konfetti
+}
\ No newline at end of file
diff --git a/compose-sample/src/main/AndroidManifest.xml b/compose-sample/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..0522b493
--- /dev/null
+++ b/compose-sample/src/main/AndroidManifest.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/MainActivity.kt b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/MainActivity.kt
new file mode 100644
index 00000000..4a4a50e7
--- /dev/null
+++ b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/MainActivity.kt
@@ -0,0 +1,42 @@
+package me.aartikov.sesamecomposesample
+
+import android.os.Bundle
+import androidx.activity.compose.setContent
+import androidx.appcompat.app.AppCompatActivity
+import com.arkivanov.decompose.defaultComponentContext
+import me.aartikov.sesamecomposesample.di.*
+import me.aartikov.sesamecomposesample.root.RootUi
+import me.aartikov.sesamecomposesample.theme.AppTheme
+import org.koin.android.ext.koin.androidContext
+import org.koin.core.module.Module
+import org.koin.dsl.koinApplication
+
+class MainActivity : AppCompatActivity() {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ val koin = koinApplication {
+ androidContext(this@MainActivity)
+ modules(getAllModules())
+ }.koin
+
+ koin.declare(ComponentFactory(koin))
+
+ val rootComponent = koin.get().createRootComponent(
+ defaultComponentContext()
+ )
+
+ setContent {
+ AppTheme {
+ RootUi(rootComponent)
+ }
+ }
+ }
+
+ private fun getAllModules(): List = listOf(
+ GatewayModule.create(),
+ InteractorModule.create(),
+ ServiceModule.create()
+ )
+}
\ No newline at end of file
diff --git a/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/base/widget/ComposeWidget.kt b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/base/widget/ComposeWidget.kt
new file mode 100644
index 00000000..188a5ec8
--- /dev/null
+++ b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/base/widget/ComposeWidget.kt
@@ -0,0 +1,58 @@
+package me.aartikov.sesamecomposesample.base.widget
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.material.CircularProgressIndicator
+import androidx.compose.material.MaterialTheme
+import androidx.compose.material.Text
+import androidx.compose.material.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import me.aartikov.sesamecomposesample.R
+
+@Composable
+fun ProgressWidget() {
+ Row(
+ modifier = Modifier.fillMaxSize(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.Center
+ ) {
+ CircularProgressIndicator(
+ color = MaterialTheme.colors.secondary
+ )
+ }
+}
+
+@Composable
+fun PlaceholderWidget(
+ message: String,
+ onRetry: () -> Unit
+) {
+ Box {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(18.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ modifier = Modifier
+ .padding(horizontal = 18.dp)
+ .align(Alignment.Center)
+ .fillMaxWidth()
+ ) {
+ Text(
+ text = message,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.fillMaxWidth(),
+ style = MaterialTheme.typography.body2
+ )
+ TextButton(
+ onClick = onRetry
+ ) {
+ Text(
+ text = stringResource(R.string.common_retry).uppercase()
+ )
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/counter/CounterButton.kt b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/counter/CounterButton.kt
new file mode 100644
index 00000000..1f89402f
--- /dev/null
+++ b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/counter/CounterButton.kt
@@ -0,0 +1,52 @@
+package me.aartikov.sesamecomposesample.counter
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.Button
+import androidx.compose.material.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import me.aartikov.sesamecomposesample.theme.AppTheme
+
+enum class CounterIcon(val symbol: Char) {
+ Minus('–'),
+ Plus('+')
+}
+
+@Composable
+fun CounterButton(
+ icon: CounterIcon,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true
+) {
+ Button(
+ onClick = onClick,
+ enabled = enabled,
+ shape = CircleShape,
+ modifier = modifier.size(42.dp),
+ contentPadding = PaddingValues(0.dp)
+ ) {
+ Text(
+ text = icon.symbol.toString(),
+ fontSize = 24.sp
+ )
+ }
+}
+
+@Preview
+@Composable
+fun CounterButtonsPreview() {
+ AppTheme {
+ Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
+ CounterButton(CounterIcon.Minus, onClick = {}, enabled = false)
+ CounterButton(CounterIcon.Plus, onClick = {}, enabled = true)
+ }
+ }
+}
\ No newline at end of file
diff --git a/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/counter/CounterComponent.kt b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/counter/CounterComponent.kt
new file mode 100644
index 00000000..93572b87
--- /dev/null
+++ b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/counter/CounterComponent.kt
@@ -0,0 +1,14 @@
+package me.aartikov.sesamecomposesample.counter
+
+interface CounterComponent {
+
+ val count: Int
+
+ val minusButtonEnabled: Boolean
+
+ val plusButtonEnabled: Boolean
+
+ fun onMinusButtonClick()
+
+ fun onPlusButtonClick()
+}
\ No newline at end of file
diff --git a/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/counter/CounterUi.kt b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/counter/CounterUi.kt
new file mode 100644
index 00000000..d7312a6b
--- /dev/null
+++ b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/counter/CounterUi.kt
@@ -0,0 +1,63 @@
+package me.aartikov.sesamecomposesample.counter
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material.MaterialTheme
+import androidx.compose.material.Surface
+import androidx.compose.material.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import me.aartikov.sesamecomposesample.theme.AppTheme
+
+@Composable
+fun CounterUi(
+ component: CounterComponent,
+ modifier: Modifier = Modifier
+) {
+ Surface(
+ modifier = modifier.fillMaxSize(),
+ color = MaterialTheme.colors.background
+ ) {
+ Row(
+ modifier = Modifier.fillMaxSize(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(56.dp, Alignment.CenterHorizontally)
+ ) {
+ CounterButton(
+ icon = CounterIcon.Minus,
+ onClick = component::onMinusButtonClick,
+ enabled = component.minusButtonEnabled
+ )
+ Text(
+ text = component.count.toString(),
+ style = MaterialTheme.typography.h6,
+ )
+ CounterButton(
+ icon = CounterIcon.Plus,
+ onClick = component::onPlusButtonClick,
+ enabled = component.plusButtonEnabled
+ )
+ }
+ }
+}
+
+@Preview
+@Composable
+fun CounterUiPreview() {
+ AppTheme {
+ CounterUi(FakeCounterComponent())
+ }
+}
+
+class FakeCounterComponent : CounterComponent {
+ override val count = 10
+ override val minusButtonEnabled = true
+ override val plusButtonEnabled = false
+
+ override fun onMinusButtonClick() {}
+ override fun onPlusButtonClick() {}
+}
\ No newline at end of file
diff --git a/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/counter/RealCounterComponent.kt b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/counter/RealCounterComponent.kt
new file mode 100644
index 00000000..acd8cd04
--- /dev/null
+++ b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/counter/RealCounterComponent.kt
@@ -0,0 +1,43 @@
+package me.aartikov.sesamecomposesample.counter
+
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import com.arkivanov.decompose.ComponentContext
+import me.aartikov.sesame.localizedstring.LocalizedString
+import me.aartikov.sesamecomposesample.services.message.MessageService
+import me.aartikov.sesamecomposesample.R
+
+class RealCounterComponent(
+ componentContext: ComponentContext,
+ private val messageService: MessageService
+) : ComponentContext by componentContext, CounterComponent {
+
+ companion object {
+ private const val MAX_COUNT = 10
+ }
+
+ override var count by mutableStateOf(0)
+ private set
+
+ override val minusButtonEnabled by derivedStateOf { count > 0 }
+
+ override val plusButtonEnabled by derivedStateOf { count < MAX_COUNT }
+
+ override fun onMinusButtonClick() {
+ if (minusButtonEnabled) {
+ count--
+ }
+ }
+
+ override fun onPlusButtonClick() {
+ if (plusButtonEnabled) {
+ count++
+ }
+
+ if (count == MAX_COUNT) {
+ messageService.showMessage(LocalizedString.resource(R.string.overflow_message))
+ }
+ }
+}
\ No newline at end of file
diff --git a/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/di/ComponentFactory.kt b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/di/ComponentFactory.kt
new file mode 100644
index 00000000..465e02c2
--- /dev/null
+++ b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/di/ComponentFactory.kt
@@ -0,0 +1,59 @@
+package me.aartikov.sesamecomposesample.di
+
+import com.arkivanov.decompose.ComponentContext
+import me.aartikov.sesamecomposesample.counter.RealCounterComponent
+import me.aartikov.sesamecomposesample.dialogs.RealDialogsComponent
+import me.aartikov.sesamecomposesample.form.FormComponent
+import me.aartikov.sesamecomposesample.form.RealFormComponent
+import me.aartikov.sesamecomposesample.menu.MenuComponent
+import me.aartikov.sesamecomposesample.menu.RealMenuComponent
+import me.aartikov.sesamecomposesample.movies.ui.RealMoviesComponent
+import me.aartikov.sesamecomposesample.profile.ui.RealProfileComponent
+import me.aartikov.sesamecomposesample.root.RealRootComponent
+import org.koin.core.Koin
+import org.koin.core.component.KoinComponent
+import org.koin.core.component.get
+
+class ComponentFactory(private val localKoin: Koin) : KoinComponent {
+
+ override fun getKoin(): Koin = localKoin
+
+ fun createProfileComponent(componentContext: ComponentContext) = RealProfileComponent(
+ componentContext,
+ get(),
+ get()
+ )
+
+ fun createDialogsComponent(componentContext: ComponentContext) = RealDialogsComponent(
+ componentContext,
+ get()
+ )
+
+ fun createCounterComponent(componentContext: ComponentContext) = RealCounterComponent(
+ componentContext,
+ get()
+ )
+
+ fun createMenuComponent(
+ componentContext: ComponentContext,
+ output: (MenuComponent.Output) -> Unit
+ ) = RealMenuComponent(
+ componentContext,
+ output
+ )
+
+ fun createMoviesComponent(componentContext: ComponentContext) = RealMoviesComponent(
+ componentContext,
+ get(),
+ get()
+ )
+
+ fun createRootComponent(componentContext: ComponentContext) = RealRootComponent(
+ componentContext,
+ get()
+ )
+
+ fun createFormComponent(componentContext: ComponentContext) = RealFormComponent(
+ componentContext
+ )
+}
\ No newline at end of file
diff --git a/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/di/GatewayModule.kt b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/di/GatewayModule.kt
new file mode 100644
index 00000000..63f8e272
--- /dev/null
+++ b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/di/GatewayModule.kt
@@ -0,0 +1,16 @@
+package me.aartikov.sesamecomposesample.di
+
+import me.aartikov.sesamecomposesample.movies.data.MoviesGateway
+import me.aartikov.sesamecomposesample.movies.data.MoviesGatewayImpl
+import me.aartikov.sesamecomposesample.profile.data.ProfileGateway
+import me.aartikov.sesamecomposesample.profile.data.ProfileGatewayImpl
+import org.koin.dsl.module
+
+object GatewayModule {
+
+ fun create() = module {
+
+ single { ProfileGatewayImpl() }
+ single { MoviesGatewayImpl() }
+ }
+}
\ No newline at end of file
diff --git a/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/di/InteractorModule.kt b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/di/InteractorModule.kt
new file mode 100644
index 00000000..3bc17541
--- /dev/null
+++ b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/di/InteractorModule.kt
@@ -0,0 +1,12 @@
+package me.aartikov.sesamecomposesample.di
+
+import me.aartikov.sesamecomposesample.movies.domain.LoadMoviesPageInteractor
+import org.koin.dsl.module
+
+object InteractorModule {
+
+ fun create() = module {
+
+ factory { LoadMoviesPageInteractor(get()) }
+ }
+}
\ No newline at end of file
diff --git a/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/di/ServiceModule.kt b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/di/ServiceModule.kt
new file mode 100644
index 00000000..299cb7eb
--- /dev/null
+++ b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/di/ServiceModule.kt
@@ -0,0 +1,16 @@
+package me.aartikov.sesamecomposesample.di
+
+import me.aartikov.sesamecomposesample.services.message.MessageServiceImpl
+import me.aartikov.sesamecomposesample.services.message.MessageService
+import org.koin.android.ext.koin.androidContext
+import org.koin.dsl.module
+
+object ServiceModule {
+
+ fun create() = module {
+
+ single {
+ MessageServiceImpl(androidContext())
+ }
+ }
+}
\ No newline at end of file
diff --git a/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/dialogs/DialogButton.kt b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/dialogs/DialogButton.kt
new file mode 100644
index 00000000..d48fd704
--- /dev/null
+++ b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/dialogs/DialogButton.kt
@@ -0,0 +1,30 @@
+package me.aartikov.sesamecomposesample.dialogs
+
+import androidx.compose.material.Text
+import androidx.compose.material.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.tooling.preview.Preview
+import me.aartikov.sesamecomposesample.theme.AppTheme
+
+@Composable
+fun DialogButton(
+ text: String,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ TextButton(
+ onClick = onClick,
+ modifier = modifier,
+ ) {
+ Text(text.uppercase())
+ }
+}
+
+@Preview
+@Composable
+fun CounterButtonsPreview() {
+ AppTheme {
+ DialogButton("Cancel", onClick = {})
+ }
+}
\ No newline at end of file
diff --git a/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/dialogs/DialogResult.kt b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/dialogs/DialogResult.kt
new file mode 100644
index 00000000..4ec11561
--- /dev/null
+++ b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/dialogs/DialogResult.kt
@@ -0,0 +1,5 @@
+package me.aartikov.sesamecomposesample.dialogs
+
+enum class DialogResult {
+ Ok, Cancel
+}
\ No newline at end of file
diff --git a/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/dialogs/DialogsComponent.kt b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/dialogs/DialogsComponent.kt
new file mode 100644
index 00000000..ff7a11ce
--- /dev/null
+++ b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/dialogs/DialogsComponent.kt
@@ -0,0 +1,15 @@
+package me.aartikov.sesamecomposesample.dialogs
+
+import me.aartikov.sesame.dialog.DialogControl
+import me.aartikov.sesame.localizedstring.LocalizedString
+
+interface DialogsComponent {
+
+ val dialog: DialogControl
+
+ val dialogForResult: DialogControl
+
+ fun onShowDialogButtonClick()
+
+ fun onShowForResultButtonClick()
+}
\ No newline at end of file
diff --git a/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/dialogs/DialogsUi.kt b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/dialogs/DialogsUi.kt
new file mode 100644
index 00000000..4a1aa493
--- /dev/null
+++ b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/dialogs/DialogsUi.kt
@@ -0,0 +1,113 @@
+package me.aartikov.sesamecomposesample.dialogs
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.material.MaterialTheme
+import androidx.compose.material.Surface
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import me.aartikov.sesame.dialog.DialogControl
+import me.aartikov.sesame.localizedstring.LocalizedString
+import me.aartikov.sesamecomposesample.R
+import me.aartikov.sesamecomposesample.menu.MenuButton
+import me.aartikov.sesamecomposesample.theme.AppTheme
+import me.aartikov.sesamecomposesample.utils.ShowDialog
+import me.aartikov.sesamecomposesample.utils.resolve
+
+@Composable
+fun DialogsUi(
+ component: DialogsComponent,
+ modifier: Modifier = Modifier
+) {
+ Surface(
+ modifier = modifier.fillMaxSize(),
+ color = MaterialTheme.colors.background
+ ) {
+ Box(modifier = Modifier.padding(32.dp)) {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ modifier = Modifier
+ .align(Alignment.Center)
+ .width(IntrinsicSize.Max)
+ ) {
+ MenuButton(
+ modifier = Modifier.fillMaxWidth(),
+ text = stringResource(R.string.show_dialog_button_text),
+ onClick = component::onShowDialogButtonClick
+ )
+ MenuButton(
+ modifier = Modifier.fillMaxWidth(),
+ text = stringResource(R.string.show_for_result_button_text),
+ onClick = component::onShowForResultButtonClick
+ )
+ }
+ }
+
+ Dialog(component.dialog)
+
+ DialogForResult(component.dialogForResult)
+ }
+}
+
+@Composable
+fun Dialog(dialog: DialogControl) {
+ ShowDialog(dialog) { message ->
+ SimpleDialog(
+ title = stringResource(R.string.dialog_title),
+ text = message.resolve(),
+ buttons = listOf(
+ ButtonDescriptor(
+ text = stringResource(R.string.common_ok),
+ onClick = dialog::dismiss
+ )
+ ),
+ onDismissRequest = dialog::dismiss
+ )
+ }
+}
+
+@Composable
+fun DialogForResult(dialog: DialogControl) {
+ ShowDialog(dialog) { message ->
+ SimpleDialog(
+ title = stringResource(R.string.dialog_title),
+ text = message.resolve(),
+ buttons = listOf(
+ ButtonDescriptor(
+ text = stringResource(R.string.common_cancel),
+ onClick = {
+ dialog.sendResult(DialogResult.Cancel)
+ }
+ ),
+ ButtonDescriptor(
+ text = stringResource(R.string.common_ok),
+ onClick = {
+ dialog.sendResult(DialogResult.Ok)
+ }
+ )
+ ),
+ onDismissRequest = dialog::dismiss
+ )
+ }
+}
+
+@Preview
+@Composable
+fun MenuUiPreview() {
+ AppTheme {
+ DialogsUi(FakeDialogsComponent())
+ }
+}
+
+class FakeDialogsComponent : DialogsComponent {
+ override val dialog = DialogControl()
+
+ override val dialogForResult = DialogControl()
+
+ override fun onShowDialogButtonClick() {}
+
+ override fun onShowForResultButtonClick() {}
+}
\ No newline at end of file
diff --git a/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/dialogs/RealDialogsComponent.kt b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/dialogs/RealDialogsComponent.kt
new file mode 100644
index 00000000..9ca98c36
--- /dev/null
+++ b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/dialogs/RealDialogsComponent.kt
@@ -0,0 +1,37 @@
+package me.aartikov.sesamecomposesample.dialogs
+
+import com.arkivanov.decompose.ComponentContext
+import kotlinx.coroutines.launch
+import me.aartikov.sesame.dialog.DialogControl
+import me.aartikov.sesame.localizedstring.LocalizedString
+import me.aartikov.sesamecomposesample.services.message.MessageService
+import me.aartikov.sesamecomposesample.R
+import me.aartikov.sesamecomposesample.utils.componentCoroutineScope
+
+class RealDialogsComponent(
+ componentContext: ComponentContext,
+ private val messageService: MessageService
+) : ComponentContext by componentContext, DialogsComponent {
+
+ private val coroutineScope = componentCoroutineScope()
+
+ override val dialog = DialogControl()
+ override val dialogForResult = DialogControl()
+
+ override fun onShowDialogButtonClick() {
+ dialog.show(LocalizedString.resource(R.string.dialog_message))
+ }
+
+ override fun onShowForResultButtonClick() {
+ coroutineScope.launch {
+ val result = dialogForResult.showForResult(LocalizedString.resource(R.string.dialog_message_for_result))
+ ?: DialogResult.Cancel
+
+ if (result == DialogResult.Ok) {
+ messageService.showMessage(LocalizedString.resource(R.string.common_ok))
+ } else {
+ messageService.showMessage(LocalizedString.resource(R.string.common_cancel))
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/dialogs/SimpleDialog.kt b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/dialogs/SimpleDialog.kt
new file mode 100644
index 00000000..a7533248
--- /dev/null
+++ b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/dialogs/SimpleDialog.kt
@@ -0,0 +1,78 @@
+package me.aartikov.sesamecomposesample.dialogs
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.AlertDialog
+import androidx.compose.material.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import me.aartikov.sesamecomposesample.theme.AppTheme
+
+data class ButtonDescriptor(
+ val text: String,
+ val onClick: () -> Unit
+)
+
+@Composable
+fun SimpleDialog(
+ title: String,
+ text: String,
+ buttons: List,
+ onDismissRequest: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ AlertDialog(
+ title = {
+ Text(text = title)
+ },
+
+ text = {
+ Text(text = text)
+ },
+
+ buttons = {
+ Row(
+ modifier = Modifier
+ .padding(end = 12.dp, bottom = 8.dp)
+ .fillMaxWidth(),
+ horizontalArrangement = Arrangement.End
+ ) {
+ buttons.forEach { descriptor ->
+ DialogButton(
+ text = descriptor.text,
+ onClick = descriptor.onClick
+ )
+ }
+ }
+ },
+
+ onDismissRequest = onDismissRequest,
+ modifier = modifier,
+ )
+}
+
+@Preview
+@Composable
+fun SimpleDialogPreview() {
+ AppTheme {
+ SimpleDialog(
+ title = "Title",
+ text = "Some message",
+ buttons = listOf(
+ ButtonDescriptor(
+ text = "Cancel",
+ onClick = {}
+ ),
+ ButtonDescriptor(
+ text = "Ok",
+ onClick = {}
+ )
+ ),
+ onDismissRequest = {}
+ )
+ }
+}
\ No newline at end of file
diff --git a/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/form/FormComponent.kt b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/form/FormComponent.kt
new file mode 100644
index 00000000..d117ff39
--- /dev/null
+++ b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/form/FormComponent.kt
@@ -0,0 +1,26 @@
+package me.aartikov.sesamecomposesample.form
+
+import kotlinx.coroutines.flow.Flow
+import me.aartikov.sesame.compose.form.control.CheckControl
+import me.aartikov.sesame.compose.form.control.InputControl
+
+interface FormComponent {
+
+ val nameInput: InputControl
+
+ val emailInput: InputControl
+
+ val phoneInput: InputControl
+
+ val passwordInput: InputControl
+
+ val confirmPasswordInput: InputControl
+
+ val termsCheckBox: CheckControl
+
+ val submitButtonState: SubmitButtonState
+
+ val dropKonfettiEvent: Flow
+
+ fun onSubmitClicked()
+}
\ No newline at end of file
diff --git a/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/form/FormUi.kt b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/form/FormUi.kt
new file mode 100644
index 00000000..99a50182
--- /dev/null
+++ b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/form/FormUi.kt
@@ -0,0 +1,352 @@
+package me.aartikov.sesamecomposesample.form
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.relocation.BringIntoViewRequester
+import androidx.compose.foundation.relocation.bringIntoViewRequester
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.*
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Visibility
+import androidx.compose.material.icons.filled.VisibilityOff
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.res.colorResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.KeyboardCapitalization
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.viewinterop.AndroidView
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.launch
+import me.aartikov.sesame.compose.form.control.CheckControl
+import me.aartikov.sesame.compose.form.control.InputControl
+import me.aartikov.sesamecomposesample.R
+import me.aartikov.sesamecomposesample.menu.MenuButton
+import me.aartikov.sesamecomposesample.theme.AppTheme
+import me.aartikov.sesamecomposesample.utils.resolve
+import nl.dionsegijn.konfetti.KonfettiView
+import nl.dionsegijn.konfetti.models.Shape
+import nl.dionsegijn.konfetti.models.Size
+
+@Composable
+fun FormUi(
+ component: FormComponent,
+ modifier: Modifier = Modifier
+) {
+ Surface(
+ modifier = modifier.fillMaxSize(),
+ color = MaterialTheme.colors.background
+ ) {
+ BoxWithConstraints(Modifier.fillMaxSize()) {
+ KonfettiWidget(maxWidth, component.dropKonfettiEvent, modifier)
+
+ val scrollState = rememberScrollState()
+ Column(
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ modifier = Modifier
+ .verticalScroll(scrollState)
+ .padding(vertical = 20.dp, horizontal = 8.dp)
+ ) {
+ CommonTextField(
+ modifier.padding(horizontal = 8.dp),
+ component.nameInput,
+ stringResource(id = R.string.name_hint)
+ )
+
+ CommonTextField(
+ modifier.padding(horizontal = 8.dp),
+ component.emailInput,
+ stringResource(id = R.string.email_hint)
+ )
+
+ CommonTextField(
+ modifier.padding(horizontal = 8.dp),
+ component.phoneInput,
+ stringResource(id = R.string.phone_hint)
+ )
+
+ PasswordField(
+ modifier.padding(horizontal = 8.dp),
+ component.passwordInput,
+ stringResource(id = R.string.password_hint)
+ )
+
+ PasswordField(
+ modifier.padding(horizontal = 8.dp),
+ component.confirmPasswordInput,
+ stringResource(id = R.string.confirm_password_hint)
+ )
+
+ CheckboxField(
+ modifier,
+ component.termsCheckBox,
+ stringResource(id = R.string.terms_hint)
+ )
+
+ MenuButton(
+ text = stringResource(R.string.submit_button),
+ onClick = component::onSubmitClicked,
+ colors = ButtonDefaults.buttonColors(
+ backgroundColor = colorResource(id = component.submitButtonState.color),
+ ),
+ modifier = Modifier.padding(top = 8.dp)
+ )
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+fun CommonTextField(
+ modifier: Modifier = Modifier,
+ inputControl: InputControl,
+ label: String
+) {
+ val bringIntoViewRequester = remember { BringIntoViewRequester() }
+
+ Column(
+ modifier = modifier
+ .fillMaxWidth()
+ .bringIntoViewRequester(bringIntoViewRequester)
+ ) {
+ val focusRequester = remember { FocusRequester() }
+
+ if (inputControl.hasFocus) {
+ SideEffect {
+ focusRequester.requestFocus()
+ }
+ }
+
+ LaunchedEffect(key1 = inputControl) {
+ inputControl.scrollToItEvent.collectLatest {
+ bringIntoViewRequester.bringIntoView()
+ }
+ }
+
+ OutlinedTextField(
+ value = inputControl.text,
+ keyboardOptions = inputControl.keyboardOptions,
+ singleLine = inputControl.singleLine,
+ label = { Text(text = label) },
+ onValueChange = inputControl::onTextChanged,
+ isError = inputControl.error != null,
+ visualTransformation = inputControl.visualTransformation,
+ modifier = modifier
+ .fillMaxWidth()
+ .focusRequester(focusRequester)
+ .onFocusChanged {
+ inputControl.onFocusChanged(it.isFocused)
+ }
+ )
+
+ ErrorText(inputControl.error?.resolve() ?: "")
+ }
+}
+
+@Composable
+fun KonfettiWidget(width: Dp, dropKonfettiEvent: Flow, modifier: Modifier = Modifier) {
+
+ val widthPx = with(LocalDensity.current) { width.toPx() }
+ val scope = rememberCoroutineScope()
+ val colors = listOf(
+ colorResource(id = R.color.orange).toArgb(),
+ colorResource(id = R.color.purple).toArgb(),
+ colorResource(id = R.color.pink).toArgb(),
+ colorResource(id = R.color.red).toArgb()
+ )
+
+ AndroidView(
+ modifier = modifier,
+ factory = { context ->
+ val view = KonfettiView(context)
+
+ scope.launch {
+ dropKonfettiEvent.collectLatest {
+ view
+ .build()
+ .addColors(colors)
+ .setDirection(0.0, 359.0)
+ .setSpeed(1f, 5f)
+ .setFadeOutEnabled(true)
+ .setTimeToLive(2000L)
+ .addShapes(Shape.Square, Shape.Circle)
+ .addSizes(Size(12))
+ .setPosition(-50f, widthPx + 50f, -50f, -50f)
+ .streamFor(300, 5000L)
+ }
+ }
+
+ view
+ },
+ )
+}
+
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+fun CheckboxField(
+ modifier: Modifier = Modifier,
+ checkControl: CheckControl,
+ label: String
+) {
+ val bringIntoViewRequester = remember { BringIntoViewRequester() }
+
+ Column(modifier = modifier.fillMaxWidth()) {
+
+ LaunchedEffect(key1 = checkControl) {
+ checkControl.scrollToItEvent.collectLatest {
+ bringIntoViewRequester.bringIntoView()
+ }
+ }
+
+ Row(
+ modifier = modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Checkbox(
+ checked = checkControl.checked,
+ onCheckedChange = { checkControl.onCheckedChanged(it) },
+ enabled = checkControl.enabled
+ )
+
+ Text(text = label)
+ }
+
+ ErrorText(
+ checkControl.error?.resolve() ?: "",
+ paddingValues = PaddingValues(horizontal = 16.dp)
+ )
+ }
+}
+
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+fun PasswordField(
+ modifier: Modifier = Modifier,
+ inputControl: InputControl,
+ label: String
+) {
+ val bringIntoViewRequester = remember { BringIntoViewRequester() }
+ Column(
+ modifier = modifier
+ .fillMaxWidth()
+ .bringIntoViewRequester(bringIntoViewRequester)
+ ) {
+ val focusRequester = remember { FocusRequester() }
+
+ var passwordVisibility by remember { mutableStateOf(false) }
+
+ if (inputControl.hasFocus) {
+ SideEffect {
+ focusRequester.requestFocus()
+ }
+ }
+
+ LaunchedEffect(key1 = inputControl) {
+ inputControl.scrollToItEvent.collectLatest {
+ bringIntoViewRequester.bringIntoView()
+ }
+ }
+
+ OutlinedTextField(
+ value = inputControl.text,
+ keyboardOptions = inputControl.keyboardOptions,
+ singleLine = inputControl.singleLine,
+ label = { Text(text = label) },
+ isError = inputControl.error != null,
+ onValueChange = inputControl::onTextChanged,
+ visualTransformation = if (passwordVisibility) {
+ inputControl.visualTransformation
+ } else {
+ PasswordVisualTransformation()
+ },
+ trailingIcon = {
+ val image = if (passwordVisibility) {
+ Icons.Filled.VisibilityOff
+ } else {
+ Icons.Filled.Visibility
+ }
+
+ IconButton(onClick = { passwordVisibility = !passwordVisibility }) {
+ Icon(imageVector = image, null)
+ }
+ },
+ modifier = modifier
+ .fillMaxWidth()
+ .focusRequester(focusRequester)
+ .onFocusChanged {
+ inputControl.onFocusChanged(it.isFocused)
+ }
+ )
+
+ ErrorText(inputControl.error?.resolve() ?: "")
+ }
+}
+
+@Composable
+fun ErrorText(
+ errorText: String,
+ modifier: Modifier = Modifier,
+ paddingValues: PaddingValues = PaddingValues(horizontal = 8.dp)
+) {
+ Text(
+ modifier = modifier.padding(paddingValues),
+ text = errorText,
+ style = MaterialTheme.typography.caption.copy(color = MaterialTheme.colors.error)
+ )
+}
+
+@Preview
+@Composable
+fun FormUiPreview() {
+ AppTheme {
+ FormUi(FakeFormComponent())
+ }
+}
+
+
+class FakeFormComponent : FormComponent {
+
+ override val nameInput = InputControl(
+ keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Words)
+ )
+
+ override val emailInput = InputControl(
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email)
+ )
+
+ override val phoneInput = InputControl(
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
+ )
+
+ override val passwordInput = InputControl(
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password)
+ )
+
+ override val confirmPasswordInput = InputControl(
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password)
+ )
+
+ override val termsCheckBox = CheckControl()
+
+ override val submitButtonState = SubmitButtonState.Valid
+
+ override val dropKonfettiEvent: Flow = flow { }
+
+ override fun onSubmitClicked() = Unit
+}
diff --git a/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/form/RealFormComponent.kt b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/form/RealFormComponent.kt
new file mode 100644
index 00000000..375de990
--- /dev/null
+++ b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/form/RealFormComponent.kt
@@ -0,0 +1,140 @@
+package me.aartikov.sesamecomposesample.form
+
+import android.util.Patterns
+import androidx.annotation.ColorRes
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.runtime.*
+import androidx.compose.ui.text.input.KeyboardCapitalization
+import androidx.compose.ui.text.input.KeyboardType
+import com.arkivanov.decompose.ComponentContext
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.receiveAsFlow
+import me.aartikov.sesame.compose.form.control.CheckControl
+import me.aartikov.sesame.compose.form.control.InputControl
+import me.aartikov.sesame.compose.form.validation.control.*
+import me.aartikov.sesame.compose.form.validation.form.*
+import me.aartikov.sesame.compose.form.validation.form.ValidateOnFocusLost
+import me.aartikov.sesame.compose.form.validation.form.checked
+import me.aartikov.sesame.compose.form.validation.form.formValidator
+import me.aartikov.sesame.localizedstring.LocalizedString
+import me.aartikov.sesamecomposesample.R
+import me.aartikov.sesamecomposesample.utils.componentCoroutineScope
+
+enum class SubmitButtonState(@ColorRes val color: Int) {
+ Valid(R.color.green),
+ Invalid(R.color.red)
+}
+
+class RealFormComponent(
+ componentContext: ComponentContext
+) : ComponentContext by componentContext, FormComponent {
+
+ companion object {
+ private const val NAME_MAX_LENGTH = 30
+ private const val PHONE_MAX_LENGTH = 11
+ private const val PASSWORD_MIN_LENGTH = 6
+ private const val RUS_PHONE_DIGIT_COUNT = 10
+ }
+
+ private val coroutineScope = componentCoroutineScope()
+
+ override val nameInput = InputControl(
+ maxLength = NAME_MAX_LENGTH,
+ textTransformation = {
+ it.replace(Regex("[1234567890+=]"), "")
+ },
+ keyboardOptions = KeyboardOptions(
+ capitalization = KeyboardCapitalization.Words
+ )
+ )
+
+ override val emailInput = InputControl(
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Email
+ )
+ )
+
+ override val phoneInput = InputControl(
+ maxLength = PHONE_MAX_LENGTH,
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Phone
+ ),
+ visualTransformation = RussianPhoneNumberVisualTransformation
+ )
+
+ override val passwordInput = InputControl(
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Password
+ )
+ )
+
+ override val confirmPasswordInput = InputControl(
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Password
+ )
+ )
+
+ override val termsCheckBox = CheckControl()
+
+ private val dropKonfettiChannel = Channel(Channel.UNLIMITED)
+
+ override val dropKonfettiEvent = dropKonfettiChannel.receiveAsFlow()
+
+ private val formValidator = coroutineScope.formValidator {
+
+ features = listOf(
+ ValidateOnFocusLost,
+ RevalidateOnValueChanged,
+ SetFocusOnFirstInvalidControlAfterValidation
+ )
+
+ input(nameInput) {
+ isNotBlank(R.string.field_is_blank_error_message)
+ }
+
+ input(emailInput, required = false) {
+ isNotBlank(R.string.field_is_blank_error_message)
+ regex(Patterns.EMAIL_ADDRESS.toRegex(), R.string.invalid_email_error_message)
+ }
+
+ input(phoneInput) {
+ isNotBlank(R.string.field_is_blank_error_message)
+ validation(
+ { str -> str.count { it.isDigit() } == RUS_PHONE_DIGIT_COUNT },
+ R.string.invalid_phone_error_message
+ )
+ }
+
+ input(passwordInput) {
+ isNotBlank(R.string.field_is_blank_error_message)
+ minLength(
+ PASSWORD_MIN_LENGTH,
+ LocalizedString.resource(R.string.min_length_error_message, PASSWORD_MIN_LENGTH)
+ )
+ validation(
+ { str -> str.any { it.isDigit() } },
+ LocalizedString.resource(R.string.must_contain_digit_error_message)
+ )
+ }
+
+ input(confirmPasswordInput) {
+ isNotBlank(R.string.field_is_blank_error_message)
+ equalsTo(passwordInput, R.string.passwords_do_not_match)
+ }
+
+ checked(termsCheckBox, R.string.terms_are_accepted_error_message)
+ }
+
+ private val dynamicResult by coroutineScope.dynamicValidationResult(formValidator)
+
+ override val submitButtonState by derivedStateOf {
+ if (dynamicResult.isValid) SubmitButtonState.Valid else SubmitButtonState.Invalid
+ }
+
+ override fun onSubmitClicked() {
+ val result = formValidator.validate()
+ if (result.isValid) {
+ dropKonfettiChannel.trySend(Unit)
+ }
+ }
+}
\ No newline at end of file
diff --git a/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/form/RussianPhoneNumberFormatter.kt b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/form/RussianPhoneNumberFormatter.kt
new file mode 100644
index 00000000..4cd01d99
--- /dev/null
+++ b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/form/RussianPhoneNumberFormatter.kt
@@ -0,0 +1,47 @@
+package me.aartikov.sesamecomposesample.form
+
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.input.OffsetMapping
+import androidx.compose.ui.text.input.TransformedText
+import androidx.compose.ui.text.input.VisualTransformation
+
+object RussianPhoneNumberVisualTransformation : VisualTransformation {
+ private const val FIRST_HARDCODE_SLOT = "+7 ("
+ private const val SECOND_HARDCODE_SLOT = ") "
+ private const val DECORATE_HARDCODE_SLOT = "-"
+
+ override fun filter(text: AnnotatedString): TransformedText {
+ val trimmed = if (text.text.length >= 10) text.text.substring(0..9) else text.text
+ var output = ""
+ if (text.text.isNotEmpty()) output += FIRST_HARDCODE_SLOT
+ for (i in trimmed.indices) {
+ output += trimmed[i]
+ when (i) {
+ 2 -> output += SECOND_HARDCODE_SLOT
+ 5 -> output += DECORATE_HARDCODE_SLOT
+ 7 -> output += DECORATE_HARDCODE_SLOT
+ }
+ }
+
+ val numberOffsetTranslator = object : OffsetMapping {
+ override fun originalToTransformed(offset: Int): Int {
+ if (offset <= 0) return offset
+ if (offset <= 2) return offset + 4
+ if (offset <= 5) return offset + 6
+ if (offset <= 7) return offset + 7
+ if (offset <= 9) return offset + 8
+ return 18
+ }
+
+ override fun transformedToOriginal(offset: Int): Int {
+ if (offset <= 4) return offset
+ if (offset <= 7) return offset - 4
+ if (offset <= 11) return offset - 6
+ if (offset <= 15) return offset - 7
+ return 10
+ }
+ }
+
+ return TransformedText(AnnotatedString(output), numberOffsetTranslator)
+ }
+}
\ No newline at end of file
diff --git a/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/menu/MenuButton.kt b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/menu/MenuButton.kt
new file mode 100644
index 00000000..5bdc6f59
--- /dev/null
+++ b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/menu/MenuButton.kt
@@ -0,0 +1,38 @@
+package me.aartikov.sesamecomposesample.menu
+
+import androidx.compose.material.Button
+import androidx.compose.material.ButtonColors
+import androidx.compose.material.ButtonDefaults
+import androidx.compose.material.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import me.aartikov.sesamecomposesample.theme.AppTheme
+
+@Composable
+fun MenuButton(
+ text: String,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ colors: ButtonColors = ButtonDefaults.buttonColors()
+) {
+ Button(
+ modifier = modifier,
+ onClick = onClick,
+ colors = colors
+ ) {
+ Text(
+ text = text.uppercase(),
+ textAlign = TextAlign.Center
+ )
+ }
+}
+
+@Preview
+@Composable
+fun CounterButtonsPreview() {
+ AppTheme {
+ MenuButton(text = "Menu item", onClick = {})
+ }
+}
\ No newline at end of file
diff --git a/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/menu/MenuComponent.kt b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/menu/MenuComponent.kt
new file mode 100644
index 00000000..e009290e
--- /dev/null
+++ b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/menu/MenuComponent.kt
@@ -0,0 +1,10 @@
+package me.aartikov.sesamecomposesample.menu
+
+interface MenuComponent {
+
+ fun onMenuItemClick(item: MenuItem)
+
+ sealed interface Output {
+ data class OpenScreen(val menuItem: MenuItem) : Output
+ }
+}
\ No newline at end of file
diff --git a/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/menu/MenuItem.kt b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/menu/MenuItem.kt
new file mode 100644
index 00000000..232580ab
--- /dev/null
+++ b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/menu/MenuItem.kt
@@ -0,0 +1,9 @@
+package me.aartikov.sesamecomposesample.menu
+
+enum class MenuItem {
+ Counter,
+ Dialogs,
+ Profile,
+ Movies,
+ Form
+}
\ No newline at end of file
diff --git a/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/menu/MenuUi.kt b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/menu/MenuUi.kt
new file mode 100644
index 00000000..ca8a9192
--- /dev/null
+++ b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/menu/MenuUi.kt
@@ -0,0 +1,72 @@
+package me.aartikov.sesamecomposesample.menu
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.material.MaterialTheme
+import androidx.compose.material.Surface
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import me.aartikov.sesamecomposesample.R
+import me.aartikov.sesamecomposesample.theme.AppTheme
+
+@Composable
+fun MenuUi(
+ component: MenuComponent,
+ modifier: Modifier = Modifier
+) {
+
+ Surface(
+ modifier = modifier.fillMaxSize(),
+ color = MaterialTheme.colors.background
+ ) {
+ Box(modifier = Modifier.padding(32.dp)) {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ modifier = Modifier
+ .align(Alignment.Center)
+ .width(IntrinsicSize.Max)
+ ) {
+ MenuButton(
+ modifier = Modifier.fillMaxWidth(),
+ text = stringResource(R.string.counter_title),
+ onClick = { component.onMenuItemClick(MenuItem.Counter) }
+ )
+ MenuButton(
+ modifier = Modifier.fillMaxWidth(),
+ text = stringResource(R.string.profile_title),
+ onClick = { component.onMenuItemClick(MenuItem.Profile) }
+ )
+ MenuButton(
+ modifier = Modifier.fillMaxWidth(),
+ text = stringResource(R.string.dialogs_title),
+ onClick = { component.onMenuItemClick(MenuItem.Dialogs) }
+ )
+ MenuButton(
+ modifier = Modifier.fillMaxWidth(),
+ text = stringResource(R.string.movies_title),
+ onClick = { component.onMenuItemClick(MenuItem.Movies) }
+ )
+ MenuButton(
+ modifier = Modifier.fillMaxWidth(),
+ text = stringResource(R.string.form_title),
+ onClick = { component.onMenuItemClick(MenuItem.Form) }
+ )
+ }
+ }
+ }
+}
+
+@Preview
+@Composable
+fun MenuUiPreview() {
+ AppTheme {
+ MenuUi(FakeMenuComponent())
+ }
+}
+
+class FakeMenuComponent : MenuComponent {
+ override fun onMenuItemClick(item: MenuItem) {}
+}
\ No newline at end of file
diff --git a/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/menu/RealMenuComponent.kt b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/menu/RealMenuComponent.kt
new file mode 100644
index 00000000..aa05517e
--- /dev/null
+++ b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/menu/RealMenuComponent.kt
@@ -0,0 +1,13 @@
+package me.aartikov.sesamecomposesample.menu
+
+import com.arkivanov.decompose.ComponentContext
+
+class RealMenuComponent(
+ componentContext: ComponentContext,
+ val output: (MenuComponent.Output) -> Unit
+) : ComponentContext by componentContext, MenuComponent {
+
+ override fun onMenuItemClick(item: MenuItem) {
+ output(MenuComponent.Output.OpenScreen(item))
+ }
+}
\ No newline at end of file
diff --git a/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/movies/data/MoviesGateway.kt b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/movies/data/MoviesGateway.kt
new file mode 100644
index 00000000..a5a2cd60
--- /dev/null
+++ b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/movies/data/MoviesGateway.kt
@@ -0,0 +1,8 @@
+package me.aartikov.sesamecomposesample.movies.data
+
+import me.aartikov.sesamecomposesample.movies.domain.Movie
+
+interface MoviesGateway {
+
+ suspend fun loadMovies(offset: Int, limit: Int): List
+}
\ No newline at end of file
diff --git a/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/movies/data/MoviesGatewayImpl.kt b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/movies/data/MoviesGatewayImpl.kt
new file mode 100644
index 00000000..dec52a73
--- /dev/null
+++ b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/movies/data/MoviesGatewayImpl.kt
@@ -0,0 +1,32 @@
+package me.aartikov.sesamecomposesample.movies.data
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.withContext
+import me.aartikov.sesamecomposesample.movies.domain.Movie
+import java.lang.Integer.min
+
+class MoviesGatewayImpl : MoviesGateway {
+
+ companion object {
+ private const val TOTAL_COUNT = 95
+ }
+
+ private var counter = 0
+
+ override suspend fun loadMovies(offset: Int, limit: Int): List = withContext(Dispatchers.IO) {
+ delay(1000)
+ val success = counter % 4 != 0
+ counter++
+
+ if (success)
+ generateMovies(offset, limit)
+ else
+ throw RuntimeException("Emulated failure. Please, try again.")
+ }
+
+ private fun generateMovies(offset: Int, limit: Int): List {
+ return (offset until min(offset + limit, TOTAL_COUNT))
+ .map { Movie(id = it) }
+ }
+}
\ No newline at end of file
diff --git a/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/movies/domain/LoadMoviesPageInteractor.kt b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/movies/domain/LoadMoviesPageInteractor.kt
new file mode 100644
index 00000000..f5782972
--- /dev/null
+++ b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/movies/domain/LoadMoviesPageInteractor.kt
@@ -0,0 +1,18 @@
+package me.aartikov.sesamecomposesample.movies.domain
+
+import me.aartikov.sesame.loading.paged.Page
+import me.aartikov.sesamecomposesample.movies.data.MoviesGateway
+
+class LoadMoviesPageInteractor(
+ private val moviesGateway: MoviesGateway
+) {
+
+ companion object {
+ private const val PAGE_SIZE = 20
+ }
+
+ suspend fun execute(offset: Int): Page {
+ val movies = moviesGateway.loadMovies(offset, PAGE_SIZE)
+ return Page(movies, hasNextPage = movies.size >= PAGE_SIZE)
+ }
+}
\ No newline at end of file
diff --git a/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/movies/domain/Movie.kt b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/movies/domain/Movie.kt
new file mode 100644
index 00000000..e8066514
--- /dev/null
+++ b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/movies/domain/Movie.kt
@@ -0,0 +1,7 @@
+package me.aartikov.sesamecomposesample.movies.domain
+
+data class Movie(
+ val id: Int,
+ val title: String = "Movie ${id + 1}",
+ val overview: String = "Some overview"
+)
\ No newline at end of file
diff --git a/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/movies/ui/MoviesComponent.kt b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/movies/ui/MoviesComponent.kt
new file mode 100644
index 00000000..4eab1f0f
--- /dev/null
+++ b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/movies/ui/MoviesComponent.kt
@@ -0,0 +1,15 @@
+package me.aartikov.sesamecomposesample.movies.ui
+
+import me.aartikov.sesame.loading.paged.PagedLoading
+import me.aartikov.sesamecomposesample.movies.domain.Movie
+
+interface MoviesComponent {
+
+ val moviesState: PagedLoading.State
+
+ fun onPullToRefresh()
+
+ fun onRetryClicked()
+
+ fun onLoadMore()
+}
\ No newline at end of file
diff --git a/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/movies/ui/MoviesUi.kt b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/movies/ui/MoviesUi.kt
new file mode 100644
index 00000000..a6f08f8b
--- /dev/null
+++ b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/movies/ui/MoviesUi.kt
@@ -0,0 +1,189 @@
+package me.aartikov.sesamecomposesample.movies.ui
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyListState
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.material.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.google.accompanist.swiperefresh.SwipeRefresh
+import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
+import kotlinx.coroutines.flow.collect
+import me.aartikov.sesame.loading.paged.PagedLoading
+import me.aartikov.sesamecomposesample.R
+import me.aartikov.sesamecomposesample.base.widget.PlaceholderWidget
+import me.aartikov.sesamecomposesample.base.widget.ProgressWidget
+import me.aartikov.sesamecomposesample.movies.domain.Movie
+import me.aartikov.sesamecomposesample.theme.AppTheme
+
+@Composable
+fun MoviesUi(
+ component: MoviesComponent,
+ modifier: Modifier = Modifier
+) {
+ Surface(
+ modifier = modifier.fillMaxSize(),
+ color = MaterialTheme.colors.background
+ ) {
+ when (val currentState = component.moviesState) {
+ is PagedLoading.State.Data -> {
+ MoviesContent(
+ movies = currentState.data,
+ isRefreshing = currentState.refreshing,
+ loadMoreEnabled = currentState.loadMoreEnabled,
+ loadingMore = currentState.loadingMore,
+ onRefresh = component::onPullToRefresh,
+ onLoadMore = component::onLoadMore
+ )
+ }
+
+ is PagedLoading.State.Error -> {
+ val message = currentState.throwable.message
+ PlaceholderWidget(
+ message = message ?: stringResource(R.string.common_some_error_description),
+ onRetry = component::onRetryClicked
+ )
+ }
+
+ is PagedLoading.State.Loading -> {
+ ProgressWidget()
+ }
+
+ is PagedLoading.State.Empty -> {
+ PlaceholderWidget(
+ message = stringResource(R.string.empty_view_text),
+ onRetry = component::onRetryClicked
+ )
+ }
+ }
+ }
+}
+
+@Composable
+fun MoviesContent(
+ movies: List,
+ isRefreshing: Boolean,
+ loadMoreEnabled: Boolean,
+ loadingMore: Boolean,
+ onRefresh: () -> Unit,
+ onLoadMore: () -> Unit
+) {
+ val listState = rememberLazyListState()
+ SwipeRefresh(
+ state = rememberSwipeRefreshState(isRefreshing),
+ onRefresh = onRefresh,
+ ) {
+ LazyColumn(state = listState) {
+ items(movies) {
+ ItemMovie(movie = it)
+ Divider(color = MaterialTheme.colors.surface, thickness = 1.dp)
+ }
+
+ if (loadingMore) {
+ item {
+ ItemLoading()
+ }
+ }
+ }
+
+ listState.OnBottomReached(loadMoreEnabled, onLoadMore)
+ }
+}
+
+@Composable
+fun LazyListState.OnBottomReached(
+ loadMoreEnabled: Boolean,
+ onLoadMore: () -> Unit
+) {
+ val shouldLoadMore = remember {
+ derivedStateOf {
+ val lastVisibleItem = layoutInfo.visibleItemsInfo.lastOrNull()
+ ?: return@derivedStateOf false
+
+ loadMoreEnabled && lastVisibleItem.index >= layoutInfo.totalItemsCount - 1
+ }
+ }
+
+ LaunchedEffect(shouldLoadMore) {
+ snapshotFlow {
+ shouldLoadMore.value
+ }.collect { loadMoreEnabled ->
+ if (loadMoreEnabled) onLoadMore()
+ }
+ }
+}
+
+@Composable
+fun ItemMovie(
+ movie: Movie
+) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 8.dp)
+ ) {
+ Text(
+ text = movie.title,
+ style = MaterialTheme.typography.h6,
+ modifier = Modifier.fillMaxWidth()
+ )
+ Text(
+ text = movie.overview,
+ style = MaterialTheme.typography.body2,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 4.dp)
+ )
+ }
+}
+
+@Composable
+fun ItemLoading() {
+ Column(
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 16.dp)
+ ) {
+ CircularProgressIndicator(
+ color = MaterialTheme.colors.secondary
+ )
+ }
+}
+
+@Preview
+@Composable
+fun MoviesUiPreview() {
+ AppTheme {
+ MoviesUi(FakeMoviesComponent())
+ }
+}
+
+class FakeMoviesComponent : MoviesComponent {
+
+ override val moviesState: PagedLoading.State = PagedLoading.State.Data(
+ getMovies(),
+ PagedLoading.DataStatus.Normal
+ )
+
+ override fun onPullToRefresh() {}
+
+ override fun onRetryClicked() {}
+
+ override fun onLoadMore() {}
+
+ private fun getMovies(): List {
+ val list = arrayListOf()
+ repeat(13) {
+ list.add(Movie(it))
+ }
+ return list.toList()
+ }
+}
\ No newline at end of file
diff --git a/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/movies/ui/RealMoviesComponent.kt b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/movies/ui/RealMoviesComponent.kt
new file mode 100644
index 00000000..685e13c6
--- /dev/null
+++ b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/movies/ui/RealMoviesComponent.kt
@@ -0,0 +1,52 @@
+package me.aartikov.sesamecomposesample.movies.ui
+
+import androidx.compose.runtime.getValue
+import com.arkivanov.decompose.ComponentContext
+import me.aartikov.sesame.loading.paged.PagedLoading
+import me.aartikov.sesame.loading.paged.handleErrors
+import me.aartikov.sesame.loading.paged.refresh
+import me.aartikov.sesame.localizedstring.LocalizedString
+import me.aartikov.sesamecomposesample.services.message.MessageService
+import me.aartikov.sesamecomposesample.movies.domain.LoadMoviesPageInteractor
+import me.aartikov.sesamecomposesample.movies.domain.Movie
+import me.aartikov.sesamecomposesample.utils.componentCoroutineScope
+import me.aartikov.sesamecomposesample.utils.toComposeState
+
+class RealMoviesComponent(
+ componentContext: ComponentContext,
+ private val loadMoviesPageInteractor: LoadMoviesPageInteractor,
+ private val messageService: MessageService
+) : ComponentContext by componentContext, MoviesComponent {
+
+ private val coroutineScope = componentCoroutineScope()
+
+ private val moviesLoading = PagedLoading(
+ coroutineScope,
+ loadPage = { loadMoviesPageInteractor.execute(it.loadedData.size) }
+ )
+
+ override val moviesState by moviesLoading.stateFlow.toComposeState(coroutineScope)
+
+ init {
+ moviesLoading.handleErrors(coroutineScope) { error ->
+ if (error.hasData) {
+ val message = error.throwable.message
+ messageService.showMessage(LocalizedString.raw(message ?: "Error"))
+ }
+ }
+
+ moviesLoading.refresh()
+ }
+
+ override fun onPullToRefresh() {
+ moviesLoading.refresh()
+ }
+
+ override fun onRetryClicked() {
+ moviesLoading.refresh()
+ }
+
+ override fun onLoadMore() {
+ moviesLoading.loadMore()
+ }
+}
\ No newline at end of file
diff --git a/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/profile/data/ProfileGateway.kt b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/profile/data/ProfileGateway.kt
new file mode 100644
index 00000000..637405da
--- /dev/null
+++ b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/profile/data/ProfileGateway.kt
@@ -0,0 +1,8 @@
+package me.aartikov.sesamecomposesample.profile.data
+
+import me.aartikov.sesamecomposesample.profile.domain.Profile
+
+interface ProfileGateway {
+
+ suspend fun loadProfile(): Profile
+}
\ No newline at end of file
diff --git a/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/profile/data/ProfileGatewayImpl.kt b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/profile/data/ProfileGatewayImpl.kt
new file mode 100644
index 00000000..13525bf0
--- /dev/null
+++ b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/profile/data/ProfileGatewayImpl.kt
@@ -0,0 +1,26 @@
+package me.aartikov.sesamecomposesample.profile.data
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.withContext
+import me.aartikov.sesamecomposesample.profile.domain.Profile
+
+class ProfileGatewayImpl : ProfileGateway {
+
+ private var counter = 0
+
+ override suspend fun loadProfile(): Profile = withContext(Dispatchers.IO) {
+ delay(1000)
+ val success = counter % 2 == 1
+ counter++
+
+ if (success) {
+ Profile(
+ name = "John Smith",
+ avatarUrl = "https://www.biography.com/.image/ar_1:1%2Cc_fill%2Ccs_srgb%2Cg_face%2Cq_auto:good%2Cw_300/MTIwNjA4NjMzOTc0MTk1NzI0/john-smith-9486928-1-402.jpg"
+ )
+ } else {
+ throw RuntimeException("Emulated failure. Please, try again.")
+ }
+ }
+}
\ No newline at end of file
diff --git a/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/profile/domain/Profile.kt b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/profile/domain/Profile.kt
new file mode 100644
index 00000000..79409982
--- /dev/null
+++ b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/profile/domain/Profile.kt
@@ -0,0 +1,6 @@
+package me.aartikov.sesamecomposesample.profile.domain
+
+data class Profile(
+ val name: String,
+ val avatarUrl: String
+)
\ No newline at end of file
diff --git a/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/profile/ui/ProfileComponent.kt b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/profile/ui/ProfileComponent.kt
new file mode 100644
index 00000000..47db1cf1
--- /dev/null
+++ b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/profile/ui/ProfileComponent.kt
@@ -0,0 +1,13 @@
+package me.aartikov.sesamecomposesample.profile.ui
+
+import me.aartikov.sesame.loading.simple.Loading
+import me.aartikov.sesamecomposesample.profile.domain.Profile
+
+interface ProfileComponent {
+
+ val profileState: Loading.State
+
+ fun onPullToRefresh()
+
+ fun onRetryClicked()
+}
\ No newline at end of file
diff --git a/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/profile/ui/ProfileUi.kt b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/profile/ui/ProfileUi.kt
new file mode 100644
index 00000000..8a3abba7
--- /dev/null
+++ b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/profile/ui/ProfileUi.kt
@@ -0,0 +1,116 @@
+package me.aartikov.sesamecomposesample.profile.ui
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import coil.compose.rememberImagePainter
+import com.google.accompanist.swiperefresh.SwipeRefresh
+import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
+import me.aartikov.sesame.loading.simple.Loading
+import me.aartikov.sesamecomposesample.R
+import me.aartikov.sesamecomposesample.base.widget.PlaceholderWidget
+import me.aartikov.sesamecomposesample.base.widget.ProgressWidget
+import me.aartikov.sesamecomposesample.theme.AppTheme
+import me.aartikov.sesamecomposesample.profile.domain.Profile
+
+@Composable
+fun ProfileUi(
+ component: ProfileComponent,
+ modifier: Modifier = Modifier
+) {
+ Surface(
+ modifier = modifier.fillMaxSize(),
+ color = MaterialTheme.colors.background
+ ) {
+ when (val currentState = component.profileState) {
+ is Loading.State.Data -> {
+ ProfileContent(
+ profile = currentState.data,
+ isRefreshing = currentState.refreshing,
+ onRefresh = { component.onPullToRefresh() }
+ )
+ }
+
+ is Loading.State.Error -> {
+ val message = currentState.throwable.message
+ PlaceholderWidget(
+ message = message ?: stringResource(R.string.common_some_error_description),
+ onRetry = component::onRetryClicked
+ )
+ }
+
+ is Loading.State.Loading -> {
+ ProgressWidget()
+ }
+
+ is Loading.State.Empty -> {
+ ProgressWidget()
+ }
+ }
+ }
+}
+
+@Composable
+fun ProfileContent(
+ profile: Profile,
+ isRefreshing: Boolean,
+ onRefresh: () -> Unit
+) {
+ SwipeRefresh(
+ state = rememberSwipeRefreshState(isRefreshing),
+ onRefresh = onRefresh,
+ ) {
+ Column(
+ modifier = Modifier.verticalScroll(rememberScrollState()),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(48.dp)
+ ) {
+ Text(
+ textAlign = TextAlign.Center,
+ text = profile.name,
+ style = MaterialTheme.typography.h5,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(start = 18.dp, end = 18.dp, top = 32.dp)
+ )
+ Image(
+ painter = rememberImagePainter(profile.avatarUrl),
+ contentDescription = null,
+ modifier = Modifier
+ .size(200.dp)
+ .clip(CircleShape)
+ .background(color = MaterialTheme.colors.surface)
+ )
+ }
+ }
+}
+
+@Preview
+@Composable
+fun ProfileUiPreview() {
+ AppTheme {
+ ProfileUi(FakeProfileComponent())
+ }
+}
+
+
+class FakeProfileComponent : ProfileComponent {
+
+ override val profileState: Loading.State = Loading.State.Loading
+
+ override fun onPullToRefresh() {}
+
+ override fun onRetryClicked() {}
+}
diff --git a/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/profile/ui/RealProfileComponent.kt b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/profile/ui/RealProfileComponent.kt
new file mode 100644
index 00000000..c713feb3
--- /dev/null
+++ b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/profile/ui/RealProfileComponent.kt
@@ -0,0 +1,47 @@
+package me.aartikov.sesamecomposesample.profile.ui
+
+import androidx.compose.runtime.*
+import com.arkivanov.decompose.ComponentContext
+import me.aartikov.sesame.loading.simple.OrdinaryLoading
+import me.aartikov.sesame.loading.simple.handleErrors
+import me.aartikov.sesame.loading.simple.refresh
+import me.aartikov.sesame.localizedstring.LocalizedString
+import me.aartikov.sesamecomposesample.services.message.MessageService
+import me.aartikov.sesamecomposesample.profile.data.ProfileGateway
+import me.aartikov.sesamecomposesample.utils.componentCoroutineScope
+import me.aartikov.sesamecomposesample.utils.toComposeState
+
+class RealProfileComponent(
+ componentContext: ComponentContext,
+ private val messageService: MessageService,
+ private val profileGateway: ProfileGateway
+) : ComponentContext by componentContext, ProfileComponent {
+
+ private val coroutineScope = componentCoroutineScope()
+
+ private val profileLoading = OrdinaryLoading(
+ coroutineScope,
+ load = { profileGateway.loadProfile() }
+ )
+
+ override val profileState by profileLoading.stateFlow.toComposeState(coroutineScope)
+
+ init {
+ profileLoading.handleErrors(coroutineScope) { error ->
+ if (error.hasData) {
+ val message = error.throwable.message
+ messageService.showMessage(LocalizedString.raw(message ?: "Error"))
+ }
+ }
+
+ profileLoading.refresh()
+ }
+
+ override fun onPullToRefresh() {
+ profileLoading.refresh()
+ }
+
+ override fun onRetryClicked() {
+ profileLoading.refresh()
+ }
+}
\ No newline at end of file
diff --git a/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/root/RealRootComponent.kt b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/root/RealRootComponent.kt
new file mode 100644
index 00000000..a0d8ea45
--- /dev/null
+++ b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/root/RealRootComponent.kt
@@ -0,0 +1,120 @@
+package me.aartikov.sesamecomposesample.root
+
+import android.os.Parcelable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import com.arkivanov.decompose.ComponentContext
+import com.arkivanov.decompose.RouterState
+import com.arkivanov.decompose.push
+import com.arkivanov.decompose.router
+import com.arkivanov.decompose.value.Value
+import kotlinx.parcelize.Parcelize
+import me.aartikov.sesame.localizedstring.LocalizedString
+import me.aartikov.sesamecomposesample.R
+import me.aartikov.sesamecomposesample.di.ComponentFactory
+import me.aartikov.sesamecomposesample.menu.MenuComponent
+import me.aartikov.sesamecomposesample.menu.MenuItem
+
+class RealRootComponent(
+ componentContext: ComponentContext,
+ private val componentFactory: ComponentFactory
+) : ComponentContext by componentContext, RootComponent {
+
+ private val router = router(
+ initialConfiguration = ChildConfig.Menu,
+ handleBackButton = true,
+ childFactory = ::createChild
+ )
+
+ override val routerState: Value> = router.state
+
+ override var title by mutableStateOf(getTitle(router.state.value))
+
+ init {
+ routerState.subscribe { state ->
+ title = getTitle(state)
+ }
+ }
+
+ private fun createChild(config: ChildConfig, componentContext: ComponentContext) =
+ when (config) {
+ is ChildConfig.Menu -> {
+ RootComponent.Child.Menu(
+ componentFactory.createMenuComponent(componentContext, ::onMenuOutput)
+ )
+ }
+
+ is ChildConfig.Counter -> {
+ RootComponent.Child.Counter(
+ componentFactory.createCounterComponent(componentContext)
+ )
+ }
+
+ is ChildConfig.Dialogs -> {
+ RootComponent.Child.Dialogs(
+ componentFactory.createDialogsComponent(componentContext)
+ )
+ }
+
+ is ChildConfig.Profile -> {
+ RootComponent.Child.Profile(
+ componentFactory.createProfileComponent(componentContext)
+ )
+ }
+
+ is ChildConfig.Movies -> {
+ RootComponent.Child.Movies(
+ componentFactory.createMoviesComponent(componentContext)
+ )
+ }
+
+ is ChildConfig.Form -> {
+ RootComponent.Child.Form(
+ componentFactory.createFormComponent(componentContext)
+ )
+ }
+ }
+
+ private fun onMenuOutput(output: MenuComponent.Output): Unit = when (output) {
+ is MenuComponent.Output.OpenScreen -> when (output.menuItem) {
+ MenuItem.Counter -> router.push(ChildConfig.Counter)
+ MenuItem.Dialogs -> router.push(ChildConfig.Dialogs)
+ MenuItem.Profile -> router.push(ChildConfig.Profile)
+ MenuItem.Movies -> router.push(ChildConfig.Movies)
+ MenuItem.Form -> router.push(ChildConfig.Form)
+ }
+ }
+
+ private fun getTitle(routerState: RouterState<*, RootComponent.Child>): LocalizedString =
+ when (routerState.activeChild.instance) {
+ is RootComponent.Child.Menu -> LocalizedString.resource(R.string.app_name)
+ is RootComponent.Child.Counter -> LocalizedString.resource(R.string.counter_title)
+ is RootComponent.Child.Dialogs -> LocalizedString.resource(R.string.dialogs_title)
+ is RootComponent.Child.Profile -> LocalizedString.resource(R.string.profile_title)
+ is RootComponent.Child.Movies -> LocalizedString.resource(R.string.movies_title)
+ is RootComponent.Child.Form -> LocalizedString.resource(R.string.form_title)
+ }
+
+ private sealed interface ChildConfig : Parcelable {
+
+ @Parcelize
+ object Menu : ChildConfig
+
+ @Parcelize
+ object Counter : ChildConfig
+
+ @Parcelize
+ object Dialogs : ChildConfig
+
+ @Parcelize
+ object Profile : ChildConfig
+
+ @Parcelize
+ object Movies : ChildConfig
+
+ @Parcelize
+ object Form : ChildConfig
+ }
+}
+
diff --git a/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/root/RootComponent.kt b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/root/RootComponent.kt
new file mode 100644
index 00000000..fb946a03
--- /dev/null
+++ b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/root/RootComponent.kt
@@ -0,0 +1,27 @@
+package me.aartikov.sesamecomposesample.root
+
+import com.arkivanov.decompose.RouterState
+import com.arkivanov.decompose.value.Value
+import me.aartikov.sesame.localizedstring.LocalizedString
+import me.aartikov.sesamecomposesample.counter.CounterComponent
+import me.aartikov.sesamecomposesample.dialogs.DialogsComponent
+import me.aartikov.sesamecomposesample.form.FormComponent
+import me.aartikov.sesamecomposesample.menu.MenuComponent
+import me.aartikov.sesamecomposesample.movies.ui.MoviesComponent
+import me.aartikov.sesamecomposesample.profile.ui.ProfileComponent
+
+interface RootComponent {
+
+ val routerState: Value>
+
+ val title: LocalizedString
+
+ sealed interface Child {
+ class Menu(val component: MenuComponent) : Child
+ class Counter(val component: CounterComponent) : Child
+ class Dialogs(val component: DialogsComponent) : Child
+ class Profile(val component: ProfileComponent) : Child
+ class Movies(val component: MoviesComponent) : Child
+ class Form(val component: FormComponent) : Child
+ }
+}
\ No newline at end of file
diff --git a/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/root/RootUi.kt b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/root/RootUi.kt
new file mode 100644
index 00000000..4442029c
--- /dev/null
+++ b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/root/RootUi.kt
@@ -0,0 +1,78 @@
+package me.aartikov.sesamecomposesample.root
+
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material.Scaffold
+import androidx.compose.material.Text
+import androidx.compose.material.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.tooling.preview.Preview
+import com.arkivanov.decompose.RouterState
+import com.arkivanov.decompose.extensions.compose.jetpack.Children
+import com.arkivanov.decompose.value.Value
+import me.aartikov.sesame.localizedstring.LocalizedString
+import me.aartikov.sesamecomposesample.counter.CounterUi
+import me.aartikov.sesamecomposesample.dialogs.DialogsUi
+import me.aartikov.sesamecomposesample.form.FormUi
+import me.aartikov.sesamecomposesample.menu.FakeMenuComponent
+import me.aartikov.sesamecomposesample.menu.MenuUi
+import me.aartikov.sesamecomposesample.movies.ui.MoviesUi
+import me.aartikov.sesamecomposesample.profile.ui.ProfileUi
+import me.aartikov.sesamecomposesample.theme.AppTheme
+import me.aartikov.sesamecomposesample.utils.createFakeRouterStateValue
+import me.aartikov.sesamecomposesample.utils.resolve
+
+@Composable
+fun RootUi(
+ component: RootComponent,
+ modifier: Modifier = Modifier
+) {
+ Scaffold(
+ modifier = modifier.fillMaxSize(),
+ topBar = {
+ TopBar(component.title)
+ }
+ ) {
+ Content(component.routerState)
+ }
+}
+
+@Composable
+private fun TopBar(title: LocalizedString) {
+ TopAppBar(
+ title = {
+ Text(title.resolve())
+ }
+ )
+}
+
+@Composable
+private fun Content(routerState: Value>) {
+ Children(routerState) { child ->
+ when (val instance = child.instance) {
+ is RootComponent.Child.Menu -> MenuUi(instance.component)
+ is RootComponent.Child.Counter -> CounterUi(instance.component)
+ is RootComponent.Child.Dialogs -> DialogsUi(instance.component)
+ is RootComponent.Child.Profile -> ProfileUi(instance.component)
+ is RootComponent.Child.Movies -> MoviesUi(instance.component)
+ is RootComponent.Child.Form -> FormUi(instance.component)
+ }
+ }
+}
+
+@Preview(showSystemUi = true)
+@Composable
+fun RootUiPreview() {
+ AppTheme {
+ RootUi(FakeRootComponent())
+ }
+}
+
+class FakeRootComponent : RootComponent {
+
+ override val routerState = createFakeRouterStateValue(
+ RootComponent.Child.Menu(FakeMenuComponent())
+ )
+
+ override val title = LocalizedString.raw("Sesame compose sample")
+}
\ No newline at end of file
diff --git a/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/services/message/MessageService.kt b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/services/message/MessageService.kt
new file mode 100644
index 00000000..141727b5
--- /dev/null
+++ b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/services/message/MessageService.kt
@@ -0,0 +1,8 @@
+package me.aartikov.sesamecomposesample.services.message
+
+import me.aartikov.sesame.localizedstring.LocalizedString
+
+interface MessageService {
+
+ fun showMessage(message: LocalizedString)
+}
\ No newline at end of file
diff --git a/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/services/message/MessageServiceImpl.kt b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/services/message/MessageServiceImpl.kt
new file mode 100644
index 00000000..868ba968
--- /dev/null
+++ b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/services/message/MessageServiceImpl.kt
@@ -0,0 +1,13 @@
+package me.aartikov.sesamecomposesample.services.message
+
+import android.content.Context
+import android.widget.Toast
+import me.aartikov.sesame.localizedstring.LocalizedString
+
+class MessageServiceImpl(
+ private val context: Context
+) : MessageService {
+ override fun showMessage(message: LocalizedString) {
+ Toast.makeText(context, message.resolve(context), Toast.LENGTH_SHORT).show()
+ }
+}
\ No newline at end of file
diff --git a/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/theme/Color.kt b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/theme/Color.kt
new file mode 100644
index 00000000..909e7758
--- /dev/null
+++ b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/theme/Color.kt
@@ -0,0 +1,8 @@
+package me.aartikov.sesamecomposesample.theme
+
+import androidx.compose.ui.graphics.Color
+
+val Red = Color(0xFFD42346)
+val DarkRed = Color(0xFFA11833)
+val Green = Color(0xFF4CB651)
+val Gray = Color(0xFFDDDDDD)
\ No newline at end of file
diff --git a/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/theme/Shape.kt b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/theme/Shape.kt
new file mode 100644
index 00000000..558c4af7
--- /dev/null
+++ b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/theme/Shape.kt
@@ -0,0 +1,11 @@
+package me.aartikov.sesamecomposesample.theme
+
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.Shapes
+import androidx.compose.ui.unit.dp
+
+val Shapes = Shapes(
+ small = RoundedCornerShape(4.dp),
+ medium = RoundedCornerShape(8.dp),
+ large = RoundedCornerShape(16.dp)
+)
\ No newline at end of file
diff --git a/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/theme/Theme.kt b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/theme/Theme.kt
new file mode 100644
index 00000000..294b22f2
--- /dev/null
+++ b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/theme/Theme.kt
@@ -0,0 +1,34 @@
+package me.aartikov.sesamecomposesample.theme
+
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material.MaterialTheme
+import androidx.compose.material.darkColors
+import androidx.compose.material.lightColors
+import androidx.compose.runtime.Composable
+
+private val DarkColorPalette = darkColors(
+ primary = Red,
+ primaryVariant = DarkRed,
+ secondary = Green,
+ surface = Gray
+)
+
+private val LightColorPalette = lightColors(
+ primary = Red,
+ primaryVariant = DarkRed,
+ secondary = Green,
+ surface = Gray
+)
+
+@Composable
+fun AppTheme(
+ darkTheme: Boolean = isSystemInDarkTheme(),
+ content: @Composable() () -> Unit
+) {
+ MaterialTheme(
+ colors = if (darkTheme) DarkColorPalette else LightColorPalette,
+ typography = Typography,
+ shapes = Shapes,
+ content = content
+ )
+}
\ No newline at end of file
diff --git a/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/theme/Type.kt b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/theme/Type.kt
new file mode 100644
index 00000000..09179737
--- /dev/null
+++ b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/theme/Type.kt
@@ -0,0 +1,20 @@
+package me.aartikov.sesamecomposesample.theme
+
+import androidx.compose.material.Typography
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.sp
+
+val Typography = Typography(
+ body1 = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 16.sp
+ ),
+ h6 = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Bold,
+ fontSize = 18.sp
+ )
+)
\ No newline at end of file
diff --git a/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/utils/ComponentCoroutineScope.kt b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/utils/ComponentCoroutineScope.kt
new file mode 100644
index 00000000..10716e2b
--- /dev/null
+++ b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/utils/ComponentCoroutineScope.kt
@@ -0,0 +1,16 @@
+package me.aartikov.sesamecomposesample.utils
+
+import com.arkivanov.essenty.lifecycle.LifecycleOwner
+import com.arkivanov.essenty.lifecycle.doOnDestroy
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+
+fun LifecycleOwner.componentCoroutineScope(): CoroutineScope {
+ val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
+ lifecycle.doOnDestroy {
+ scope.cancel()
+ }
+ return scope
+}
\ No newline at end of file
diff --git a/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/utils/ComposeExtensions.kt b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/utils/ComposeExtensions.kt
new file mode 100644
index 00000000..defad47c
--- /dev/null
+++ b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/utils/ComposeExtensions.kt
@@ -0,0 +1,19 @@
+package me.aartikov.sesamecomposesample.utils
+
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.State
+import androidx.compose.runtime.mutableStateOf
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.launch
+
+fun StateFlow.toComposeState(coroutineScope: CoroutineScope): State {
+ val state: MutableState = mutableStateOf(this.value)
+ coroutineScope.launch {
+ this@toComposeState.collect {
+ state.value = it
+ }
+ }
+ return state
+}
\ No newline at end of file
diff --git a/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/utils/FakeRouterStateValue.kt b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/utils/FakeRouterStateValue.kt
new file mode 100644
index 00000000..251767eb
--- /dev/null
+++ b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/utils/FakeRouterStateValue.kt
@@ -0,0 +1,17 @@
+package me.aartikov.sesamecomposesample.utils
+
+import com.arkivanov.decompose.Child
+import com.arkivanov.decompose.RouterState
+import com.arkivanov.decompose.value.MutableValue
+import com.arkivanov.decompose.value.Value
+
+fun createFakeRouterStateValue(instance: T): Value> {
+ return MutableValue(
+ RouterState(
+ activeChild = Child.Created(
+ configuration = "",
+ instance = instance
+ )
+ )
+ )
+}
\ No newline at end of file
diff --git a/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/utils/ResolveLocalizedString.kt b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/utils/ResolveLocalizedString.kt
new file mode 100644
index 00000000..44f7a715
--- /dev/null
+++ b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/utils/ResolveLocalizedString.kt
@@ -0,0 +1,12 @@
+package me.aartikov.sesamecomposesample.utils
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.platform.LocalConfiguration
+import androidx.compose.ui.platform.LocalContext
+import me.aartikov.sesame.localizedstring.LocalizedString
+
+@Composable
+fun LocalizedString.resolve(): String {
+ LocalConfiguration.current // required to recompose when a locale is changed
+ return resolve(LocalContext.current).toString()
+}
diff --git a/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/utils/ShowDialog.kt b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/utils/ShowDialog.kt
new file mode 100644
index 00000000..c2a7a756
--- /dev/null
+++ b/compose-sample/src/main/kotlin/me/aartikov/sesamecomposesample/utils/ShowDialog.kt
@@ -0,0 +1,18 @@
+package me.aartikov.sesamecomposesample.utils
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import me.aartikov.sesame.dialog.DialogControl
+import me.aartikov.sesame.dialog.dataOrNull
+
+@Composable
+fun ShowDialog(
+ dialogControl: DialogControl,
+ dialog: @Composable (data: T) -> Unit
+) {
+ val state by dialogControl.stateFlow.collectAsState()
+ state.dataOrNull?.let { data ->
+ dialog(data)
+ }
+}
\ No newline at end of file
diff --git a/compose-sample/src/main/res/drawable-v24/ic_launcher_foreground.xml b/compose-sample/src/main/res/drawable-v24/ic_launcher_foreground.xml
new file mode 100644
index 00000000..a85b1867
--- /dev/null
+++ b/compose-sample/src/main/res/drawable-v24/ic_launcher_foreground.xml
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/compose-sample/src/main/res/drawable/ic_launcher_background.xml b/compose-sample/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 00000000..344925ad
--- /dev/null
+++ b/compose-sample/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,171 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/compose-sample/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/compose-sample/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 00000000..f4bc9d94
--- /dev/null
+++ b/compose-sample/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/compose-sample/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/compose-sample/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 00000000..f4bc9d94
--- /dev/null
+++ b/compose-sample/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/compose-sample/src/main/res/mipmap-hdpi/ic_launcher.png b/compose-sample/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 00000000..a571e600
Binary files /dev/null and b/compose-sample/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/compose-sample/src/main/res/mipmap-hdpi/ic_launcher_round.png b/compose-sample/src/main/res/mipmap-hdpi/ic_launcher_round.png
new file mode 100644
index 00000000..61da551c
Binary files /dev/null and b/compose-sample/src/main/res/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/compose-sample/src/main/res/mipmap-mdpi/ic_launcher.png b/compose-sample/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 00000000..c41dd285
Binary files /dev/null and b/compose-sample/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/compose-sample/src/main/res/mipmap-mdpi/ic_launcher_round.png b/compose-sample/src/main/res/mipmap-mdpi/ic_launcher_round.png
new file mode 100644
index 00000000..db5080a7
Binary files /dev/null and b/compose-sample/src/main/res/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/compose-sample/src/main/res/mipmap-xhdpi/ic_launcher.png b/compose-sample/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 00000000..6dba46da
Binary files /dev/null and b/compose-sample/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/compose-sample/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/compose-sample/src/main/res/mipmap-xhdpi/ic_launcher_round.png
new file mode 100644
index 00000000..da31a871
Binary files /dev/null and b/compose-sample/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/compose-sample/src/main/res/mipmap-xxhdpi/ic_launcher.png b/compose-sample/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 00000000..15ac6817
Binary files /dev/null and b/compose-sample/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/compose-sample/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/compose-sample/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
new file mode 100644
index 00000000..b216f2d3
Binary files /dev/null and b/compose-sample/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/compose-sample/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/compose-sample/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 00000000..f25a4197
Binary files /dev/null and b/compose-sample/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/compose-sample/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/compose-sample/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
new file mode 100644
index 00000000..e96783cc
Binary files /dev/null and b/compose-sample/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/compose-sample/src/main/res/values/colors.xml b/compose-sample/src/main/res/values/colors.xml
new file mode 100644
index 00000000..b4759f26
--- /dev/null
+++ b/compose-sample/src/main/res/values/colors.xml
@@ -0,0 +1,9 @@
+
+
+ #d42346
+ #a11833
+ #4cb651
+ #F6D467
+ #EA918E
+ #b48def
+
\ No newline at end of file
diff --git a/compose-sample/src/main/res/values/strings.xml b/compose-sample/src/main/res/values/strings.xml
new file mode 100644
index 00000000..43fe440d
--- /dev/null
+++ b/compose-sample/src/main/res/values/strings.xml
@@ -0,0 +1,52 @@
+
+ Sesame compose sample
+
+
+ OK
+ Cancel
+ Retry
+ An error has occurred
+
+
+ Counter
+ It\'s enough!
+
+
+ Profile
+ No data to display
+
+
+ Dialogs
+ Show Dialog
+ Show For Result
+ Dialog
+ Some message
+ Dialog for result
+ Some message for result
+
+
+ Movies
+ No items to display
+
+
+ Clock
+ Put the app to background and check logs. The clock doesn\'t tick when it is inactive.
+
+
+ Form
+ Name
+ E-mail
+ Phone
+ Password
+ Confirm Password
+ Accept Terms of Use
+ Submit
+
+ Please fill this field
+ Invalid e-mail address
+ Invalid phone number
+ Minimum %d symbols
+ Must contain a digit
+ Passwords do not match
+ Please accept the terms of use
+
\ No newline at end of file
diff --git a/compose-sample/src/main/res/values/styles.xml b/compose-sample/src/main/res/values/styles.xml
new file mode 100644
index 00000000..9b62b279
--- /dev/null
+++ b/compose-sample/src/main/res/values/styles.xml
@@ -0,0 +1,7 @@
+
+
+
\ No newline at end of file
diff --git a/dependencies.gradle b/dependencies.gradle
index 79aac58d..afb32648 100644
--- a/dependencies.gradle
+++ b/dependencies.gradle
@@ -13,6 +13,7 @@ ext {
fragment : "1.2.5",
coreKtx : "1.3.2",
appCompat : "1.2.0",
+ activityCompose : "1.3.1",
androidxAnnotation : "1.1.0",
constraintLayout : "2.0.4",
swiperefreshlayout : "1.1.0",
@@ -20,18 +21,23 @@ ext {
material : "1.3.0-rc01",
groupie : "2.9.0",
glide : "4.11.0",
- hilt : "2.33-beta",
+ dagger : "2.38.1",
viewBindingDelegates: "1.4.1",
dateTime : "0.1.1",
decoro : "1.5.0",
- konfetti : "1.3.2"
+ konfetti : "1.3.2",
+ compose : "1.1.0-beta03",
+ decompose : "0.3.1",
+ coil : "1.3.2",
+ koin : "3.1.2",
+ accompanist : "0.19.0"
]
desugaring = "com.android.tools:desugar_jdk_libs:${versions.desugaring}"
tests = [
junit : "junit:junit:${versions.junit}",
- androidxTestCore: "androidx.test:core:${versions.androidxTest}",
+ androidxTestCore : "androidx.test:core:${versions.androidxTest}",
androidxTestRunner: "androidx.test:runner:${versions.androidxTest}",
androidxJunitExt : "androidx.test.ext:junit:${versions.androidxJunitExt}",
coroutinesTest : "org.jetbrains.kotlinx:kotlinx-coroutines-test:${versions.coroutines}",
@@ -48,7 +54,8 @@ ext {
annotation : "androidx.annotation:annotation:${versions.androidxAnnotation}",
constraintLayout: "androidx.constraintlayout:constraintlayout:${versions.constraintLayout}",
swipeRefresh : "androidx.swiperefreshlayout:swiperefreshlayout:${versions.swiperefreshlayout}",
- cardView : "androidx.cardview:cardview:${versions.cardView}"
+ cardView : "androidx.cardview:cardview:${versions.cardView}",
+ activityCompose : "androidx.activity:activity-compose:${versions.activityCompose}"
]
groupie = [
@@ -57,8 +64,8 @@ ext {
]
daggerHilt = [
- coreAndroid : "com.google.dagger:hilt-android:${versions.hilt}",
- androidCompiler: "com.google.dagger:hilt-android-compiler:${versions.hilt}",
+ coreAndroid : "com.google.dagger:hilt-android:${versions.dagger}",
+ androidCompiler: "com.google.dagger:hilt-android-compiler:${versions.dagger}",
]
coroutines = [
@@ -66,6 +73,20 @@ ext {
android: "org.jetbrains.kotlinx:kotlinx-coroutines-android:${versions.coroutines}",
]
+ compose = [
+ ui : "androidx.compose.ui:ui:${versions.compose}",
+ material : "androidx.compose.material:material:${versions.compose}",
+ uiTooling: "androidx.compose.ui:ui-tooling:${versions.compose}"
+ ]
+ decompose = [
+ core : "com.arkivanov.decompose:decompose:${versions.decompose}",
+ compose: "com.arkivanov.decompose:extensions-compose-jetpack:${versions.decompose}"
+ ]
+
+ accompanist = [
+ swiperefresh: "com.google.accompanist:accompanist-swiperefresh:${versions.accompanist}"
+ ]
+
dateTime = "org.jetbrains.kotlinx:kotlinx-datetime:${versions.dateTime}"
material = "com.google.android.material:material:${versions.material}"
@@ -77,4 +98,9 @@ ext {
decoro = "ru.tinkoff.decoro:decoro:${versions.decoro}"
konfetti = "nl.dionsegijn:konfetti:${versions.konfetti}"
+
+ coil = "io.coil-kt:coil-compose:${versions.coil}"
+
+ koin = "io.insert-koin:koin-android:${versions.koin}"
+
}
\ No newline at end of file
diff --git a/gradle.properties b/gradle.properties
index 3b0f058a..7c029f42 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -1,19 +1,17 @@
-# 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
+## 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
+# Default value: -Xmx1024m -XX:MaxPermSize=256m
+# org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
+#
# 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
+#Wed Oct 06 12:10:34 MSK 2021
+kotlin.code.style=official
+org.gradle.jvmargs=-Xmx2048m
android.useAndroidX=true
-# Kotlin code style for this project: "official" or "obsolete":
-kotlin.code.style=official
\ No newline at end of file
+android.enableJetifier=true
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 93d02e2f..91f7d24e 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-6.8-all.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-7.0.2-bin.zip
diff --git a/publish.gradle b/publish.gradle
index dc016d89..749b589b 100644
--- a/publish.gradle
+++ b/publish.gradle
@@ -10,7 +10,7 @@ apply plugin: 'org.jetbrains.dokka'
ext {
PUBLISH_GROUP_ID = 'com.github.aartikov'
- PUBLISH_VERSION = '1.2.0-beta1'
+ PUBLISH_VERSION = '1.3.0-beta1'
DESCRIPTION = 'Sesame is a set of architecture components for Android development'
GITHUB_USER = 'aartikov'
diff --git a/sample/src/main/AndroidManifest.xml b/sample/src/main/AndroidManifest.xml
index 24751699..28c320b7 100644
--- a/sample/src/main/AndroidManifest.xml
+++ b/sample/src/main/AndroidManifest.xml
@@ -12,7 +12,9 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
-
+
diff --git a/sesame-compose-form/.gitignore b/sesame-compose-form/.gitignore
new file mode 100644
index 00000000..42afabfd
--- /dev/null
+++ b/sesame-compose-form/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/sesame-compose-form/build.gradle b/sesame-compose-form/build.gradle
new file mode 100644
index 00000000..dfd81687
--- /dev/null
+++ b/sesame-compose-form/build.gradle
@@ -0,0 +1,42 @@
+plugins {
+ id "com.android.library"
+ id "kotlin-android"
+}
+
+android {
+ compileSdkVersion libraryConfig.compileSdkVersion
+ buildToolsVersion libraryConfig.buildToolsVersion
+
+ defaultConfig {
+ minSdkVersion libraryConfig.minSdkVersion
+ }
+
+ sourceSets {
+ main.java.srcDirs += "src/main/kotlin"
+ test.java.srcDirs += "src/test/kotlin"
+ }
+
+ buildFeatures {
+ compose true
+ }
+ composeOptions {
+ kotlinCompilerExtensionVersion versions.compose
+ }
+}
+
+dependencies {
+ api project(':sesame-localized-string')
+ implementation androidx.annotation
+ implementation androidx.lifecycle
+ implementation material
+ implementation coroutines.core
+ implementation 'androidx.compose.foundation:foundation:1.0.3'
+
+ implementation compose.ui
+ implementation compose.material
+
+ testImplementation tests.junit
+ testImplementation tests.coroutinesTest
+}
+
+apply from: "${rootDir}/publish.gradle"
\ No newline at end of file
diff --git a/sesame-compose-form/src/main/AndroidManifest.xml b/sesame-compose-form/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..0a2622dd
--- /dev/null
+++ b/sesame-compose-form/src/main/AndroidManifest.xml
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/sesame-compose-form/src/main/kotlin/me/aartikov/sesame/compose/form/control/CheckControl.kt b/sesame-compose-form/src/main/kotlin/me/aartikov/sesame/compose/form/control/CheckControl.kt
new file mode 100644
index 00000000..29b271f0
--- /dev/null
+++ b/sesame-compose-form/src/main/kotlin/me/aartikov/sesame/compose/form/control/CheckControl.kt
@@ -0,0 +1,54 @@
+package me.aartikov.sesame.compose.form.control
+
+import androidx.compose.runtime.*
+import kotlinx.coroutines.channels.BufferOverflow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import me.aartikov.sesame.localizedstring.LocalizedString
+
+class CheckControl(
+ initialChecked: Boolean = false
+) : ValidatableControl {
+
+ /**
+ * Is control checked.
+ */
+ var checked: Boolean by mutableStateOf(initialChecked)
+
+ /**
+ * Is control visible.
+ */
+ var visible: Boolean by mutableStateOf(true)
+
+ /**
+ * Is control enabled.
+ */
+ var enabled: Boolean by mutableStateOf(true)
+
+ /**
+ * Displayed error.
+ */
+ override var error: LocalizedString? by mutableStateOf(null)
+
+ override val value by ::checked
+
+ override val skipInValidation by derivedStateOf { !visible || !enabled }
+
+ private val mutableScrollToItEventFlow = MutableSharedFlow(
+ extraBufferCapacity = 1,
+ onBufferOverflow = BufferOverflow.DROP_OLDEST
+ )
+
+ val scrollToItEvent get() = mutableScrollToItEventFlow.asSharedFlow()
+
+ override fun requestFocus() {
+ mutableScrollToItEventFlow.tryEmit(Unit)
+ }
+
+ /**
+ * Called automatically when checked is changed on a view side.
+ */
+ fun onCheckedChanged(checked: Boolean) {
+ this.checked = checked
+ }
+}
diff --git a/sesame-compose-form/src/main/kotlin/me/aartikov/sesame/compose/form/control/InputControl.kt b/sesame-compose-form/src/main/kotlin/me/aartikov/sesame/compose/form/control/InputControl.kt
new file mode 100644
index 00000000..d54ad5ed
--- /dev/null
+++ b/sesame-compose-form/src/main/kotlin/me/aartikov/sesame/compose/form/control/InputControl.kt
@@ -0,0 +1,85 @@
+package me.aartikov.sesame.compose.form.control
+
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.text.input.VisualTransformation
+import kotlinx.coroutines.channels.BufferOverflow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import me.aartikov.sesame.localizedstring.LocalizedString
+
+class InputControl(
+ initialText: String = "",
+ val singleLine: Boolean = true,
+ val maxLength: Int = Int.MAX_VALUE,
+ val keyboardOptions: KeyboardOptions,
+ val textTransformation: TextTransformation? = null,
+ val visualTransformation: VisualTransformation = VisualTransformation.None
+) : ValidatableControl {
+
+ private var _text by mutableStateOf(correctText(initialText))
+
+ /**
+ * Current text.
+ */
+ var text: String
+ get() = _text
+ set(value) {
+ _text = correctText(value)
+ }
+
+ /**
+ * Is control visible.
+ */
+ var visible: Boolean by mutableStateOf(true)
+
+ /**
+ * Is control enabled.
+ */
+ var enabled: Boolean by mutableStateOf(true)
+
+ /**
+ * Is control has focus.
+ */
+ var hasFocus: Boolean by mutableStateOf(false)
+
+ /**
+ * Displayed error.
+ */
+ override var error: LocalizedString? by mutableStateOf(null)
+
+ override val value by ::text
+
+ override val skipInValidation by derivedStateOf { !visible || !enabled }
+
+ private val mutableScrollToItEventFlow = MutableSharedFlow(
+ extraBufferCapacity = 1,
+ onBufferOverflow = BufferOverflow.DROP_OLDEST
+ )
+
+ val scrollToItEvent get() = mutableScrollToItEventFlow.asSharedFlow()
+
+ override fun requestFocus() {
+ this.hasFocus = true
+ mutableScrollToItEventFlow.tryEmit(Unit)
+ }
+
+ /**
+ * Called automatically when text is changed on a view side.
+ */
+ fun onTextChanged(text: String) {
+ this.text = text
+ }
+
+ fun onFocusChanged(hasFocus: Boolean) {
+ this.hasFocus = hasFocus
+ }
+
+ private fun correctText(text: String): String {
+ val transformedText = textTransformation?.transform(text) ?: text
+ return transformedText.take(maxLength)
+ }
+}
diff --git a/sesame-compose-form/src/main/kotlin/me/aartikov/sesame/compose/form/control/TextTransformation.kt b/sesame-compose-form/src/main/kotlin/me/aartikov/sesame/compose/form/control/TextTransformation.kt
new file mode 100644
index 00000000..0295419b
--- /dev/null
+++ b/sesame-compose-form/src/main/kotlin/me/aartikov/sesame/compose/form/control/TextTransformation.kt
@@ -0,0 +1,6 @@
+package me.aartikov.sesame.compose.form.control
+
+fun interface TextTransformation {
+
+ fun transform(text: String): String
+}
\ No newline at end of file
diff --git a/sesame-compose-form/src/main/kotlin/me/aartikov/sesame/compose/form/control/ValidatableControl.kt b/sesame-compose-form/src/main/kotlin/me/aartikov/sesame/compose/form/control/ValidatableControl.kt
new file mode 100644
index 00000000..b7ebe3a0
--- /dev/null
+++ b/sesame-compose-form/src/main/kotlin/me/aartikov/sesame/compose/form/control/ValidatableControl.kt
@@ -0,0 +1,33 @@
+package me.aartikov.sesame.compose.form.control
+
+import me.aartikov.sesame.localizedstring.LocalizedString
+
+/**
+ * Control that can be validated.
+ * @param ValueT type of value managed by a control.
+ *
+ * @see: [InputControl]
+ * @see: [CheckControl]
+ */
+interface ValidatableControl {
+
+ /**
+ * Control value.
+ */
+ val value: ValueT
+
+ /**
+ * Displayed error.
+ */
+ var error: LocalizedString?
+
+ /**
+ * Is control should be skipped during validation.
+ */
+ val skipInValidation: Boolean
+
+ /**
+ * Moves focus to a control.
+ */
+ fun requestFocus()
+}
\ No newline at end of file
diff --git a/sesame-compose-form/src/main/kotlin/me/aartikov/sesame/compose/form/validation/control/CheckValidator.kt b/sesame-compose-form/src/main/kotlin/me/aartikov/sesame/compose/form/validation/control/CheckValidator.kt
new file mode 100644
index 00000000..b8645c11
--- /dev/null
+++ b/sesame-compose-form/src/main/kotlin/me/aartikov/sesame/compose/form/validation/control/CheckValidator.kt
@@ -0,0 +1,43 @@
+package me.aartikov.sesame.compose.form.validation.control
+
+import me.aartikov.sesame.compose.form.control.CheckControl
+import me.aartikov.sesame.compose.form.validation.form.FormValidatorBuilder
+import me.aartikov.sesame.compose.form.validation.form.checked
+import me.aartikov.sesame.localizedstring.LocalizedString
+
+/**
+ * Validator for [CheckControl].
+ * @param validation implements validation logic.
+ * @param showError a callback that is called to show one-time error such as a toast. For permanent errors use [CheckControl.error] state.
+ *
+ * Use [FormValidatorBuilder.checked] to create it with a handy DSL.
+ */
+class CheckValidator constructor(
+ override val control: CheckControl,
+ private val validation: (Boolean) -> ValidationResult,
+ private val showError: ((LocalizedString) -> Unit)? = null
+) : ControlValidator {
+
+ override fun validate(displayResult: Boolean): ValidationResult {
+ return getValidationResult().also {
+ if (displayResult) displayValidationResult(it)
+ }
+ }
+
+ private fun getValidationResult(): ValidationResult {
+ if (control.skipInValidation) {
+ return ValidationResult.Skipped
+ }
+
+ return validation(control.value)
+ }
+
+ private fun displayValidationResult(validationResult: ValidationResult) =
+ when (validationResult) {
+ ValidationResult.Valid, ValidationResult.Skipped -> control.error = null
+ is ValidationResult.Invalid -> {
+ control.error = validationResult.errorMessage
+ showError?.invoke(validationResult.errorMessage)
+ }
+ }
+}
\ No newline at end of file
diff --git a/sesame-compose-form/src/main/kotlin/me/aartikov/sesame/compose/form/validation/control/ControlValidator.kt b/sesame-compose-form/src/main/kotlin/me/aartikov/sesame/compose/form/validation/control/ControlValidator.kt
new file mode 100644
index 00000000..ab61b43b
--- /dev/null
+++ b/sesame-compose-form/src/main/kotlin/me/aartikov/sesame/compose/form/validation/control/ControlValidator.kt
@@ -0,0 +1,20 @@
+package me.aartikov.sesame.compose.form.validation.control
+
+import me.aartikov.sesame.compose.form.control.ValidatableControl
+
+/**
+ * Interface for validation of a single control.
+ */
+interface ControlValidator> {
+
+ /**
+ * A control for validation
+ */
+ val control: ControlT
+
+ /**
+ * Validates a control.
+ * @param displayResult specifies if a result will be displayed on UI.
+ */
+ fun validate(displayResult: Boolean = true): ValidationResult
+}
\ No newline at end of file
diff --git a/sesame-compose-form/src/main/kotlin/me/aartikov/sesame/compose/form/validation/control/InputValidator.kt b/sesame-compose-form/src/main/kotlin/me/aartikov/sesame/compose/form/validation/control/InputValidator.kt
new file mode 100644
index 00000000..17fb863c
--- /dev/null
+++ b/sesame-compose-form/src/main/kotlin/me/aartikov/sesame/compose/form/validation/control/InputValidator.kt
@@ -0,0 +1,49 @@
+package me.aartikov.sesame.compose.form.validation.control
+
+import me.aartikov.sesame.compose.form.control.InputControl
+import me.aartikov.sesame.compose.form.validation.form.FormValidatorBuilder
+
+/**
+ * Validator for [InputControl].
+ * @param required specifies if blank input is considered valid.
+ * @param validations a list of functions that implements validation logic. Validations are processed sequentially until first error.
+ *
+ * Use [FormValidatorBuilder.input] to create it with a handy DSL.
+ */
+class InputValidator constructor(
+ override val control: InputControl,
+ private val required: Boolean = true,
+ private val validations: List<(String) -> ValidationResult>
+) : ControlValidator {
+
+ override fun validate(displayResult: Boolean): ValidationResult {
+ return getValidationResult().also {
+ if (displayResult) displayValidationResult(it)
+ }
+ }
+
+ private fun getValidationResult(): ValidationResult {
+ if (control.skipInValidation) {
+ return ValidationResult.Skipped
+ }
+
+ if (control.value.isBlank() && !required) {
+ return ValidationResult.Valid
+ }
+
+ validations.forEach { validation ->
+ val result = validation(control.value)
+ if (result is ValidationResult.Invalid) {
+ return result
+ }
+ }
+
+ return ValidationResult.Valid
+ }
+
+ private fun displayValidationResult(validationResult: ValidationResult) =
+ when (validationResult) {
+ ValidationResult.Valid, ValidationResult.Skipped -> control.error = null
+ is ValidationResult.Invalid -> control.error = validationResult.errorMessage
+ }
+}
\ No newline at end of file
diff --git a/sesame-compose-form/src/main/kotlin/me/aartikov/sesame/compose/form/validation/control/InputValidatorDsl.kt b/sesame-compose-form/src/main/kotlin/me/aartikov/sesame/compose/form/validation/control/InputValidatorDsl.kt
new file mode 100644
index 00000000..69fdbc2d
--- /dev/null
+++ b/sesame-compose-form/src/main/kotlin/me/aartikov/sesame/compose/form/validation/control/InputValidatorDsl.kt
@@ -0,0 +1,131 @@
+package me.aartikov.sesame.compose.form.validation.control
+
+import androidx.annotation.StringRes
+import me.aartikov.sesame.compose.form.control.InputControl
+import me.aartikov.sesame.localizedstring.LocalizedString
+
+class InputValidatorBuilder(
+ private val inputControl: InputControl,
+ private val required: Boolean
+) {
+
+ private val validations = mutableListOf<(String) -> ValidationResult>()
+
+ /**
+ * Adds an arbitrary validation. Validations are processed sequentially until first error.
+ */
+ fun validation(validation: (String) -> ValidationResult) {
+ validations.add(validation)
+ }
+
+ fun build(): InputValidator {
+ return InputValidator(inputControl, required, validations)
+ }
+}
+
+/**
+ * Adds an arbitrary validation. Validations are processed sequentially until first error.
+ */
+fun InputValidatorBuilder.validation(isValid: (String) -> Boolean, errorMessage: LocalizedString) {
+ validation {
+ if (isValid(it)) {
+ ValidationResult.Valid
+ } else {
+ ValidationResult.Invalid(errorMessage)
+ }
+ }
+}
+
+/**
+ * Adds an arbitrary validation. Validations are processed sequentially until first error.
+ */
+fun InputValidatorBuilder.validation(
+ isValid: (String) -> Boolean,
+ @StringRes errorMessageRes: Int
+) {
+ validation(isValid, LocalizedString.resource(errorMessageRes))
+}
+
+/**
+ * Adds an arbitrary validation. Validations are processed sequentially until first error.
+ */
+fun InputValidatorBuilder.validation(
+ isValid: (String) -> Boolean,
+ errorMessage: () -> LocalizedString
+) {
+ validation {
+ if (isValid(it)) {
+ ValidationResult.Valid
+ } else {
+ ValidationResult.Invalid(errorMessage.invoke())
+ }
+ }
+}
+
+/**
+ * Adds a validation that checks that an input is not blank.
+ */
+fun InputValidatorBuilder.isNotBlank(errorMessage: LocalizedString) {
+ validation(
+ isValid = { it.isNotBlank() },
+ errorMessage
+ )
+}
+
+/**
+ * Adds a validation that checks that an input is not blank.
+ */
+fun InputValidatorBuilder.isNotBlank(@StringRes errorMessageRes: Int) {
+ isNotBlank(LocalizedString.resource(errorMessageRes))
+}
+
+/**
+ * Adds a validation that checks that an input matches [regex].
+ */
+fun InputValidatorBuilder.regex(regex: Regex, errorMessage: LocalizedString) {
+ validation(
+ isValid = { regex.matches(it) },
+ errorMessage
+ )
+}
+
+/**
+ * Adds a validation that checks that an input matches [regex].
+ */
+fun InputValidatorBuilder.regex(regex: Regex, @StringRes errorMessageRes: Int) {
+ regex(regex, LocalizedString.resource(errorMessageRes))
+}
+
+/**
+ * Adds a validation that checks that an input has at least given number of symbols.
+ */
+fun InputValidatorBuilder.minLength(length: Int, errorMessage: LocalizedString) {
+ validation(
+ isValid = { it.length >= length },
+ errorMessage
+ )
+}
+
+/**
+ * Adds a validation that checks that an input has at least given number of symbols.
+ */
+fun InputValidatorBuilder.minLength(length: Int, @StringRes errorMessageRes: Int) {
+ minLength(length, LocalizedString.resource(errorMessageRes))
+}
+
+/**
+ * Adds a validation that checks that an input equals to an input of another input control.
+ */
+fun InputValidatorBuilder.equalsTo(inputControl: InputControl, errorMessage: LocalizedString) {
+ validation(
+ isValid = { it == inputControl.value },
+ errorMessage
+ )
+}
+
+/**
+ * Adds a validation that checks that an input equals to an input of another input control.
+ */
+fun InputValidatorBuilder.equalsTo(inputControl: InputControl, errorMessageRes: Int) {
+ equalsTo(inputControl, LocalizedString.resource(errorMessageRes))
+}
\ No newline at end of file
diff --git a/sesame-compose-form/src/main/kotlin/me/aartikov/sesame/compose/form/validation/control/ValidationResult.kt b/sesame-compose-form/src/main/kotlin/me/aartikov/sesame/compose/form/validation/control/ValidationResult.kt
new file mode 100644
index 00000000..cff5036d
--- /dev/null
+++ b/sesame-compose-form/src/main/kotlin/me/aartikov/sesame/compose/form/validation/control/ValidationResult.kt
@@ -0,0 +1,24 @@
+package me.aartikov.sesame.compose.form.validation.control
+
+import me.aartikov.sesame.localizedstring.LocalizedString
+
+/**
+ * Represents a result of validation.
+ */
+sealed class ValidationResult {
+
+ /**
+ * An input is valid.
+ */
+ object Valid : ValidationResult()
+
+ /**
+ * Validation was skipped.
+ */
+ object Skipped : ValidationResult()
+
+ /**
+ * An input is invalid.
+ */
+ data class Invalid(val errorMessage: LocalizedString) : ValidationResult()
+}
\ No newline at end of file
diff --git a/sesame-compose-form/src/main/kotlin/me/aartikov/sesame/compose/form/validation/form/DynamicValidationResult.kt b/sesame-compose-form/src/main/kotlin/me/aartikov/sesame/compose/form/validation/form/DynamicValidationResult.kt
new file mode 100644
index 00000000..b3884083
--- /dev/null
+++ b/sesame-compose-form/src/main/kotlin/me/aartikov/sesame/compose/form/validation/form/DynamicValidationResult.kt
@@ -0,0 +1,35 @@
+package me.aartikov.sesame.compose.form.validation.form
+
+import androidx.compose.runtime.State
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.snapshotFlow
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.*
+import me.aartikov.sesame.compose.form.control.ValidatableControl
+
+/**
+ * Validates a form dynamically and emits validation result. Validation called whenever a value or skipInValidation
+ * of some control is changed.
+ */
+fun CoroutineScope.dynamicValidationResult(formValidator: FormValidator): State {
+ val result = mutableStateOf(formValidator.validate(displayResult = false))
+ formValidator.validators.forEach { (control, _) ->
+ callWhenControlEdited(this, control) {
+ result.value = formValidator.validate(displayResult = false)
+ }
+ }
+ return result
+}
+
+private fun callWhenControlEdited(
+ coroutineScope: CoroutineScope,
+ control: ValidatableControl<*>,
+ callback: () -> Unit
+) {
+ snapshotFlow { control.value to control.skipInValidation }
+ .drop(1)
+ .onEach {
+ callback()
+ }
+ .launchIn(coroutineScope)
+}
\ No newline at end of file
diff --git a/sesame-compose-form/src/main/kotlin/me/aartikov/sesame/compose/form/validation/form/FormValidatedEvent.kt b/sesame-compose-form/src/main/kotlin/me/aartikov/sesame/compose/form/validation/form/FormValidatedEvent.kt
new file mode 100644
index 00000000..a55743f6
--- /dev/null
+++ b/sesame-compose-form/src/main/kotlin/me/aartikov/sesame/compose/form/validation/form/FormValidatedEvent.kt
@@ -0,0 +1,9 @@
+package me.aartikov.sesame.compose.form.validation.form
+
+/**
+ * Informs that a form was validated.
+ */
+class FormValidatedEvent(
+ val result: FormValidationResult,
+ val displayResult: Boolean
+)
\ No newline at end of file
diff --git a/sesame-compose-form/src/main/kotlin/me/aartikov/sesame/compose/form/validation/form/FormValidationFeature.kt b/sesame-compose-form/src/main/kotlin/me/aartikov/sesame/compose/form/validation/form/FormValidationFeature.kt
new file mode 100644
index 00000000..c3ce8d21
--- /dev/null
+++ b/sesame-compose-form/src/main/kotlin/me/aartikov/sesame/compose/form/validation/form/FormValidationFeature.kt
@@ -0,0 +1,122 @@
+package me.aartikov.sesame.compose.form.validation.form
+
+import androidx.compose.runtime.State
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.snapshotFlow
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.*
+import me.aartikov.sesame.compose.form.control.ValidatableControl
+import me.aartikov.sesame.compose.form.validation.control.ControlValidator
+import me.aartikov.sesame.compose.form.validation.control.InputValidator
+import me.aartikov.sesame.compose.form.validation.control.ValidationResult
+
+/**
+ * High level feature for [FormValidator].
+ */
+interface FormValidationFeature {
+
+ fun install(coroutineScope: CoroutineScope, formValidator: FormValidator)
+}
+
+/**
+ * Validates a control whenever it loses a focus.
+ */
+object ValidateOnFocusLost : FormValidationFeature {
+
+ override fun install(coroutineScope: CoroutineScope, formValidator: FormValidator) {
+ formValidator.validators.forEach { (_, validator) ->
+ if (validator is InputValidator) {
+ validateOnFocusLost(coroutineScope, validator)
+ }
+ }
+ }
+
+ private fun validateOnFocusLost(
+ coroutineScope: CoroutineScope,
+ inputValidator: InputValidator
+ ) {
+ val inputControl = inputValidator.control
+
+ snapshotFlow { inputControl.hasFocus }
+ .drop(1)
+ .filter { !it }
+ .onEach {
+ inputValidator.validate()
+ }
+ .launchIn(coroutineScope)
+ }
+}
+
+/**
+ * Validates control again whenever its value is changed and it already displays an error.
+ */
+object RevalidateOnValueChanged : FormValidationFeature {
+
+ override fun install(coroutineScope: CoroutineScope, formValidator: FormValidator) {
+ formValidator.validators.forEach { (_, validator) ->
+ revalidateOnValueChanged(coroutineScope, validator)
+ }
+ }
+
+ private fun revalidateOnValueChanged(
+ coroutineScope: CoroutineScope,
+ validator: ControlValidator<*>
+ ) {
+ val control = validator.control
+ snapshotFlow { control.value }
+ .drop(1)
+ .onEach {
+ if (control.error != null) {
+ validator.validate()
+ }
+ }
+ .launchIn(coroutineScope)
+ }
+}
+
+/**
+ * Hides an error on a control whenever some value is entered to it.
+ */
+object HideErrorOnValueChanged : FormValidationFeature {
+
+ override fun install(coroutineScope: CoroutineScope, formValidator: FormValidator) {
+ formValidator.validators.forEach { (control, _) ->
+ hideErrorOnValueChanged(coroutineScope, control)
+ }
+ }
+
+ private fun hideErrorOnValueChanged(
+ coroutineScope: CoroutineScope,
+ control: ValidatableControl<*>
+ ) {
+ snapshotFlow { control.value }
+ .drop(1)
+ .onEach {
+ control.error = null
+ }
+ .launchIn(coroutineScope)
+ }
+}
+
+/**
+ * Sets focus on a first invalid control after form validation has been processed.
+ */
+object SetFocusOnFirstInvalidControlAfterValidation : FormValidationFeature {
+
+ override fun install(coroutineScope: CoroutineScope, formValidator: FormValidator) {
+ formValidator.validatedEventFlow
+ .onEach {
+ if (it.displayResult) {
+ focusOnFirstInvalidControl(it.result)
+ }
+ }
+ .launchIn(coroutineScope)
+ }
+
+ private fun focusOnFirstInvalidControl(validationResult: FormValidationResult) {
+ val firstInvalidControl = validationResult.controlResults.entries
+ .firstOrNull { it.value is ValidationResult.Invalid }?.key
+
+ firstInvalidControl?.requestFocus()
+ }
+}
\ No newline at end of file
diff --git a/sesame-compose-form/src/main/kotlin/me/aartikov/sesame/compose/form/validation/form/FormValidationResult.kt b/sesame-compose-form/src/main/kotlin/me/aartikov/sesame/compose/form/validation/form/FormValidationResult.kt
new file mode 100644
index 00000000..0938cf1d
--- /dev/null
+++ b/sesame-compose-form/src/main/kotlin/me/aartikov/sesame/compose/form/validation/form/FormValidationResult.kt
@@ -0,0 +1,14 @@
+package me.aartikov.sesame.compose.form.validation.form
+
+import me.aartikov.sesame.compose.form.control.ValidatableControl
+import me.aartikov.sesame.compose.form.validation.control.ValidationResult
+
+/**
+ * Represents result of form validation.
+ */
+data class FormValidationResult(
+ val controlResults: Map, ValidationResult>
+) {
+
+ val isValid get() = controlResults.values.none { it is ValidationResult.Invalid }
+}
\ No newline at end of file
diff --git a/sesame-compose-form/src/main/kotlin/me/aartikov/sesame/compose/form/validation/form/FormValidator.kt b/sesame-compose-form/src/main/kotlin/me/aartikov/sesame/compose/form/validation/form/FormValidator.kt
new file mode 100644
index 00000000..c4b60967
--- /dev/null
+++ b/sesame-compose-form/src/main/kotlin/me/aartikov/sesame/compose/form/validation/form/FormValidator.kt
@@ -0,0 +1,43 @@
+package me.aartikov.sesame.compose.form.validation.form
+
+import kotlinx.coroutines.channels.BufferOverflow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import me.aartikov.sesame.compose.form.control.ValidatableControl
+import me.aartikov.sesame.compose.form.validation.control.ControlValidator
+import me.aartikov.sesame.compose.form.validation.control.ValidationResult
+
+/**
+ * Validator for multiple controls.
+ *
+ * Use [formValidator] to create it with a handy DSL.
+ */
+class FormValidator(
+ val validators: Map, ControlValidator<*>>
+) {
+
+ private val mutableValidatedEventFlow = MutableSharedFlow(
+ extraBufferCapacity = 1,
+ onBufferOverflow = BufferOverflow.DROP_OLDEST
+ )
+
+ /**
+ * Emits [FormValidatedEvent] after each validation.
+ */
+ val validatedEventFlow get() = mutableValidatedEventFlow.asSharedFlow()
+
+ /**
+ * Validates controls.
+ * @param displayResult specifies if a result will be displayed on UI.
+ */
+ fun validate(displayResult: Boolean = true): FormValidationResult {
+ val results = mutableMapOf, ValidationResult>()
+ validators.forEach { (control, validator) ->
+ results[control] = validator.validate(displayResult)
+ }
+
+ return FormValidationResult(results).also {
+ mutableValidatedEventFlow.tryEmit(FormValidatedEvent(it, displayResult))
+ }
+ }
+}
\ No newline at end of file
diff --git a/sesame-compose-form/src/main/kotlin/me/aartikov/sesame/compose/form/validation/form/FormValidatorDsl.kt b/sesame-compose-form/src/main/kotlin/me/aartikov/sesame/compose/form/validation/form/FormValidatorDsl.kt
new file mode 100644
index 00000000..7a3d2c25
--- /dev/null
+++ b/sesame-compose-form/src/main/kotlin/me/aartikov/sesame/compose/form/validation/form/FormValidatorDsl.kt
@@ -0,0 +1,107 @@
+package me.aartikov.sesame.compose.form.validation.form
+
+import androidx.annotation.StringRes
+import kotlinx.coroutines.CoroutineScope
+import me.aartikov.sesame.compose.form.control.CheckControl
+import me.aartikov.sesame.compose.form.control.InputControl
+import me.aartikov.sesame.compose.form.control.ValidatableControl
+import me.aartikov.sesame.compose.form.validation.control.CheckValidator
+import me.aartikov.sesame.compose.form.validation.control.ControlValidator
+import me.aartikov.sesame.compose.form.validation.control.InputValidatorBuilder
+import me.aartikov.sesame.compose.form.validation.control.ValidationResult
+import me.aartikov.sesame.localizedstring.LocalizedString
+
+class FormValidatorBuilder {
+
+ private val validators = mutableMapOf, ControlValidator<*>>()
+
+ /**
+ * Allows to add additional features to form validation. @see [FormValidationFeature].
+ */
+ var features = listOf()
+
+ /**
+ * Adds arbitrary [ControlValidator].
+ */
+ fun validator(validator: ControlValidator<*>) {
+ val control = validator.control
+ if (validators.containsKey(control)) {
+ throw IllegalArgumentException("Validator for $control is already added.")
+ }
+ validators[control] = validator
+ }
+
+ /**
+ * Adds a validator for [CheckControl].
+ * @param validation implements validation logic.
+ * @param showError a callback that is called to show one-time error such as a toast. For permanent errors use [CheckControl.error] state.
+ */
+ fun check(
+ checkControl: CheckControl,
+ validation: (Boolean) -> ValidationResult,
+ showError: ((LocalizedString) -> Unit)? = null
+ ) {
+ val checkValidator = CheckValidator(checkControl, validation, showError)
+ validator(checkValidator)
+ }
+
+ /**
+ * Adds a validator for [InputControl]. Use [buildBlock] to configure validation for a given control.
+ * @param required specifies if blank input is considered valid.
+ */
+ fun input(
+ inputControl: InputControl,
+ required: Boolean = true,
+ buildBlock: InputValidatorBuilder.() -> Unit
+ ) {
+ val inputValidator = InputValidatorBuilder(inputControl, required)
+ .apply(buildBlock)
+ .build()
+ validator(inputValidator)
+ }
+
+ fun build(coroutineScope: CoroutineScope): FormValidator {
+ return FormValidator(validators).apply {
+ features.forEach { feature ->
+ feature.install(coroutineScope, this)
+ }
+ }
+ }
+}
+
+/**
+ * Creates [FormValidator]. Use [buildBlock] to configure validation.
+ */
+fun CoroutineScope.formValidator(buildBlock: FormValidatorBuilder.() -> Unit): FormValidator {
+ return FormValidatorBuilder()
+ .apply(buildBlock)
+ .build(this)
+}
+
+/**
+ * Adds a validator that checks that [checkControl] is checked.
+ */
+fun FormValidatorBuilder.checked(
+ checkControl: CheckControl,
+ errorMessage: LocalizedString,
+ showError: ((LocalizedString) -> Unit)? = null
+) {
+ this.check(
+ checkControl,
+ validation = {
+ if (it) ValidationResult.Valid else ValidationResult.Invalid(errorMessage)
+ },
+ showError
+ )
+}
+
+/**
+ * Adds a validator that checks that [checkControl] is checked.
+ */
+fun FormValidatorBuilder.checked(
+ checkControl: CheckControl,
+ @StringRes errorMessageRes: Int,
+ showError: ((LocalizedString) -> Unit)? = null
+) {
+ checked(checkControl, LocalizedString.resource(errorMessageRes), showError)
+}
\ No newline at end of file
diff --git a/sesame-compose-form/src/test/kotlin/me/aartikov/sesame/compose/form/ExampleUnitTest.kt b/sesame-compose-form/src/test/kotlin/me/aartikov/sesame/compose/form/ExampleUnitTest.kt
new file mode 100644
index 00000000..f02868eb
--- /dev/null
+++ b/sesame-compose-form/src/test/kotlin/me/aartikov/sesame/compose/form/ExampleUnitTest.kt
@@ -0,0 +1,17 @@
+package me.aartikov.sesame.compose.form
+
+import org.junit.Test
+
+import org.junit.Assert.*
+
+/**
+ * Example local unit test, which will execute on the development machine (host).
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+class ExampleUnitTest {
+ @Test
+ fun addition_isCorrect() {
+ assertEquals(4, 2 + 2)
+ }
+}
\ No newline at end of file
diff --git a/sesame-dialog/src/main/kotlin/me/aartikov/sesame/dialog/DialogControl.kt b/sesame-dialog/src/main/kotlin/me/aartikov/sesame/dialog/DialogControl.kt
index 89e2cd47..10b16f82 100644
--- a/sesame-dialog/src/main/kotlin/me/aartikov/sesame/dialog/DialogControl.kt
+++ b/sesame-dialog/src/main/kotlin/me/aartikov/sesame/dialog/DialogControl.kt
@@ -81,4 +81,10 @@ fun DialogControl.show() = show(Unit)
/**
* A shortcut to show a dialog for result without custom data.
*/
-suspend fun DialogControl.showForResult(): R? = showForResult(Unit)
\ No newline at end of file
+suspend fun DialogControl.showForResult(): R? = showForResult(Unit)
+
+/**
+ * Returns [DialogControl.State.Shown.data] if it is available or null otherwise
+ */
+val DialogControl.State.dataOrNull
+ get() = (this as? DialogControl.State.Shown)?.data
\ No newline at end of file
diff --git a/settings.gradle b/settings.gradle
index 239e068b..34600cd4 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -7,4 +7,6 @@ include ':sesame-localized-string'
include ':sesame-loop'
include ':sesame-navigation'
include ':sesame-property'
-include ":sample"
\ No newline at end of file
+include ':sample'
+include ':compose-sample'
+include ':sesame-compose-form'