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'