diff --git a/.gitignore b/.gitignore index 8d9ef2c09..a0e81a534 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,27 @@ buildNumber.properties .mvn/wrapper/maven-wrapper.jar # LibreOffice lock files (.rgg especially) -.~lock* \ No newline at end of file +.~lock* + +# https://github.com/github/gitignore/blob/main/Gradle.gitignore +.gradle +**/build/ +!src/**/build/ + +# Ignore Gradle GUI config +gradle-app.setting + +# Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) +!gradle-wrapper.jar + +# Avoid ignore Gradle wrappper properties +!gradle-wrapper.properties + +# Cache of project +.gradletasknamecache + +# Eclipse Gradle plugin generated files +# Eclipse Core +.project +# JDT-specific (Eclipse Java Development Tools) +.classpath \ No newline at end of file diff --git a/admin/pom.xml b/admin/pom.xml index 73039cf7b..230156de3 100644 --- a/admin/pom.xml +++ b/admin/pom.xml @@ -3,10 +3,10 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> - parent + buildsrc-kotlin-parent tech.coner.trailer 0.1.0-SNAPSHOT - ../pom.xml + ../buildsrc/buildsrc-kotlin-parent/pom.xml 4.0.0 diff --git a/api-client-motorsportreg-test/pom.xml b/api-client-motorsportreg-test/pom.xml index d1203ff18..2c84d123f 100644 --- a/api-client-motorsportreg-test/pom.xml +++ b/api-client-motorsportreg-test/pom.xml @@ -3,9 +3,10 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> - parent + buildsrc-kotlin-parent tech.coner.trailer 0.1.0-SNAPSHOT + ../buildsrc/buildsrc-kotlin-parent/pom.xml 4.0.0 diff --git a/api-client-motorsportreg/pom.xml b/api-client-motorsportreg/pom.xml index b759669e8..b0a91e0e9 100644 --- a/api-client-motorsportreg/pom.xml +++ b/api-client-motorsportreg/pom.xml @@ -3,9 +3,10 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> - parent + buildsrc-kotlin-parent tech.coner.trailer 0.1.0-SNAPSHOT + ../buildsrc/buildsrc-kotlin-parent/pom.xml 4.0.0 diff --git a/buildsrc/buildsrc-kotlin-parent/pom.xml b/buildsrc/buildsrc-kotlin-parent/pom.xml new file mode 100644 index 000000000..5eef5c04b --- /dev/null +++ b/buildsrc/buildsrc-kotlin-parent/pom.xml @@ -0,0 +1,146 @@ + + + 4.0.0 + + tech.coner.trailer + parent + 0.1.0-SNAPSHOT + ../../pom.xml + + + buildsrc-kotlin-parent + + pom + + + ../../core + ../../core-test + ../../datasource-crispy-fish + ../../datasource-crispy-fish-test + ../../datasource-snoozle + ../../api-client-motorsportreg + ../../api-client-motorsportreg-test + ../../datasource-motorsportreg + ../../io + ../../io-kodein-di + ../../admin + ../../io-test + ../../presentation/presentation + ../../presentation/presentation-text + ../../presentation/presentation-all-kodein-di + ../../presentation/presentation-json + ../../io-kodein-di-test + ../../io-testsupport + ../../core-kodein-di + ../../core-kodein-di-test + ../../webapp-competition + ../../testutil/assertk-ktor + ../../presentation/presentation-all-testsupport-mockk + ../../toolkit/konstraints + ../../toolkit/presentation/presentation + ../../toolkit/presentation/presentation-testsupport + ../../toolkit/samples/samples-common + ../../toolkit/validation/validation + ../../toolkit/validation/validation-testsupport + ../../toolkit/toolkit + ../../testutil/assertk-arrowkt + + + + UTF-8 + official + 1.8 + + + + + + org.jetbrains.kotlin + kotlin-stdlib + + + org.jetbrains.kotlin + kotlin-reflect + + + org.jetbrains.kotlinx + kotlinx-coroutines-core + + + io.arrow-kt + arrow-core-jvm + + + io.arrow-kt + arrow-fx-coroutines-jvm + + + + org.jetbrains.kotlinx + kotlinx-coroutines-test-jvm + + + org.junit.jupiter + junit-jupiter + + + com.willowtreeapps.assertk + assertk-jvm + + + io.mockk + mockk-jvm + + + app.cash.turbine + turbine-jvm + + + + + + mavenCentral + https://repo1.maven.org/maven2/ + + + + + src/main/kotlin + src/test/kotlin + + + org.jetbrains.kotlin + kotlin-maven-plugin + + + compile + compile + + compile + + + + test-compile + test-compile + + test-compile + + + + + + maven-surefire-plugin + + + maven-failsafe-plugin + + + org.codehaus.mojo + exec-maven-plugin + + + + + \ No newline at end of file diff --git a/core-kodein-di-test/pom.xml b/core-kodein-di-test/pom.xml index 6536a8b97..209d8fe0d 100644 --- a/core-kodein-di-test/pom.xml +++ b/core-kodein-di-test/pom.xml @@ -3,9 +3,10 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> - parent + buildsrc-kotlin-parent tech.coner.trailer 0.1.0-SNAPSHOT + ../buildsrc/buildsrc-kotlin-parent/pom.xml 4.0.0 diff --git a/core-kodein-di/pom.xml b/core-kodein-di/pom.xml index c6cda81b8..ed520e126 100644 --- a/core-kodein-di/pom.xml +++ b/core-kodein-di/pom.xml @@ -3,9 +3,10 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> - parent + buildsrc-kotlin-parent tech.coner.trailer 0.1.0-SNAPSHOT + ../buildsrc/buildsrc-kotlin-parent/pom.xml 4.0.0 diff --git a/core-test/pom.xml b/core-test/pom.xml index 931e8f837..943e0b884 100644 --- a/core-test/pom.xml +++ b/core-test/pom.xml @@ -3,10 +3,10 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> - parent + buildsrc-kotlin-parent tech.coner.trailer 0.1.0-SNAPSHOT - ../pom.xml + ../buildsrc/buildsrc-kotlin-parent/pom.xml 4.0.0 diff --git a/core/pom.xml b/core/pom.xml index 510d00867..382c99b2d 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -4,9 +4,9 @@ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> tech.coner.trailer - parent + buildsrc-kotlin-parent 0.1.0-SNAPSHOT - ../pom.xml + ../buildsrc/buildsrc-kotlin-parent/pom.xml 4.0.0 diff --git a/datasource-crispy-fish-test/pom.xml b/datasource-crispy-fish-test/pom.xml index 598c0359d..05947b890 100644 --- a/datasource-crispy-fish-test/pom.xml +++ b/datasource-crispy-fish-test/pom.xml @@ -3,9 +3,10 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> - parent + buildsrc-kotlin-parent tech.coner.trailer 0.1.0-SNAPSHOT + ../buildsrc/buildsrc-kotlin-parent/pom.xml 4.0.0 diff --git a/datasource-crispy-fish/pom.xml b/datasource-crispy-fish/pom.xml index 35e5dcb7a..abf504de2 100644 --- a/datasource-crispy-fish/pom.xml +++ b/datasource-crispy-fish/pom.xml @@ -4,9 +4,9 @@ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> tech.coner.trailer - parent + buildsrc-kotlin-parent 0.1.0-SNAPSHOT - ../pom.xml + ../buildsrc/buildsrc-kotlin-parent/pom.xml 4.0.0 diff --git a/datasource-motorsportreg/pom.xml b/datasource-motorsportreg/pom.xml index 0518e08b0..0292e37f4 100644 --- a/datasource-motorsportreg/pom.xml +++ b/datasource-motorsportreg/pom.xml @@ -3,9 +3,10 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> - parent + buildsrc-kotlin-parent tech.coner.trailer 0.1.0-SNAPSHOT + ../buildsrc/buildsrc-kotlin-parent/pom.xml 4.0.0 diff --git a/datasource-snoozle/pom.xml b/datasource-snoozle/pom.xml index 7692f51ba..e4966c200 100644 --- a/datasource-snoozle/pom.xml +++ b/datasource-snoozle/pom.xml @@ -3,10 +3,10 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> - parent + buildsrc-kotlin-parent tech.coner.trailer 0.1.0-SNAPSHOT - ../pom.xml + ../buildsrc/buildsrc-kotlin-parent/pom.xml 4.0.0 diff --git a/io-kodein-di-test/pom.xml b/io-kodein-di-test/pom.xml index ebc1c305d..312c5edcd 100644 --- a/io-kodein-di-test/pom.xml +++ b/io-kodein-di-test/pom.xml @@ -3,9 +3,10 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> - parent + buildsrc-kotlin-parent tech.coner.trailer 0.1.0-SNAPSHOT + ../buildsrc/buildsrc-kotlin-parent/pom.xml 4.0.0 diff --git a/io-kodein-di/pom.xml b/io-kodein-di/pom.xml index 887fa16f7..87d66b25f 100644 --- a/io-kodein-di/pom.xml +++ b/io-kodein-di/pom.xml @@ -3,9 +3,10 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> - parent + buildsrc-kotlin-parent tech.coner.trailer 0.1.0-SNAPSHOT + ../buildsrc/buildsrc-kotlin-parent/pom.xml 4.0.0 diff --git a/io-test/pom.xml b/io-test/pom.xml index 552a2d4bd..7c79c8b42 100644 --- a/io-test/pom.xml +++ b/io-test/pom.xml @@ -3,9 +3,10 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> - parent + buildsrc-kotlin-parent tech.coner.trailer 0.1.0-SNAPSHOT + ../buildsrc/buildsrc-kotlin-parent/pom.xml 4.0.0 diff --git a/io-testsupport/pom.xml b/io-testsupport/pom.xml index bd4c00fb9..6199df9bd 100644 --- a/io-testsupport/pom.xml +++ b/io-testsupport/pom.xml @@ -3,9 +3,10 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> - parent + buildsrc-kotlin-parent tech.coner.trailer 0.1.0-SNAPSHOT + ../buildsrc/buildsrc-kotlin-parent/pom.xml 4.0.0 diff --git a/io/pom.xml b/io/pom.xml index 6d447f27c..3fe395aac 100644 --- a/io/pom.xml +++ b/io/pom.xml @@ -3,10 +3,10 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> - parent tech.coner.trailer + buildsrc-kotlin-parent 0.1.0-SNAPSHOT - ../pom.xml + ../buildsrc/buildsrc-kotlin-parent/pom.xml 4.0.0 diff --git a/pom.xml b/pom.xml index 12162456b..9e07e7ad1 100644 --- a/pom.xml +++ b/pom.xml @@ -10,50 +10,23 @@ 0.1.0-SNAPSHOT - core - core-test - datasource-crispy-fish - datasource-crispy-fish-test - datasource-snoozle - api-client-motorsportreg - api-client-motorsportreg-test - datasource-motorsportreg - io - io-kodein-di - admin - io-test - presentation/presentation - presentation/presentation-text - presentation/presentation-all-kodein-di - presentation/presentation-json - io-kodein-di-test - io-testsupport - core-kodein-di - core-kodein-di-test - webapp-competition - testutil/assertk-ktor - presentation/presentation-all-testsupport-mockk - toolkit/konstraints - toolkit/presentation/presentation - toolkit/presentation/presentation-test - toolkit/presentation/presentation-testsupport - toolkit/samples/samples-common - toolkit/validation/validation - toolkit/validation/validation-testsupport - toolkit/util + buildsrc/buildsrc-kotlin-parent + toolkit/samples/dmvapp/dmvapp-gui + ${project.version} UTF-8 17 ${maven.compiler.source} VERBOSE + 3.7.1 3.12.1 3.3.1 3.2.3 3.6.0 1.2.2 - 1.9.22 + 2.0.20 2.0 true official @@ -69,6 +42,7 @@ 1.4.14 4.2.1 2.4.0 + 1.2.4 5.10.1 0.28.0 @@ -79,39 +53,11 @@ 1.0.0 - - - org.jetbrains.kotlin - kotlin-stdlib-jdk8 - - - org.jetbrains.kotlinx - kotlinx-coroutines-core - - - - org.jetbrains.kotlinx - kotlinx-coroutines-test-jvm - - - org.junit.jupiter - junit-jupiter - - - com.willowtreeapps.assertk - assertk-jvm - - - io.mockk - mockk-jvm - - - org.jetbrains.kotlin - kotlin-stdlib-jdk8 + kotlin-stdlib ${kotlin.version} @@ -209,6 +155,16 @@ mordant-jvm ${mordant.version} + + io.arrow-kt + arrow-core-jvm + ${arrow.version} + + + io.arrow-kt + arrow-fx-coroutines-jvm + ${arrow.version} + org.junit.jupiter @@ -274,26 +230,6 @@ - ${project.basedir}/src/main/kotlin - ${project.basedir}/src/test/kotlin - - - - org.jetbrains.kotlin - kotlin-maven-plugin - - - maven-surefire-plugin - - - maven-failsafe-plugin - - - org.codehaus.mojo - exec-maven-plugin - - - @@ -343,6 +279,11 @@ maven-resources-plugin ${maven-resources-plugin.version} + + org.apache.maven.plugins + maven-dependency-plugin + ${maven-dependency-plugin.version} + org.apache.maven.plugins maven-compiler-plugin diff --git a/presentation/presentation-all-kodein-di/pom.xml b/presentation/presentation-all-kodein-di/pom.xml index 82daec4d0..86f075999 100644 --- a/presentation/presentation-all-kodein-di/pom.xml +++ b/presentation/presentation-all-kodein-di/pom.xml @@ -3,10 +3,10 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> - parent + buildsrc-kotlin-parent tech.coner.trailer 0.1.0-SNAPSHOT - ../../pom.xml + ../../buildsrc/buildsrc-kotlin-parent/pom.xml 4.0.0 diff --git a/presentation/presentation-all-testsupport-mockk/pom.xml b/presentation/presentation-all-testsupport-mockk/pom.xml index 890bd23e8..7840672e9 100644 --- a/presentation/presentation-all-testsupport-mockk/pom.xml +++ b/presentation/presentation-all-testsupport-mockk/pom.xml @@ -5,9 +5,9 @@ 4.0.0 tech.coner.trailer - parent + buildsrc-kotlin-parent 0.1.0-SNAPSHOT - ../../pom.xml + ../../buildsrc/buildsrc-kotlin-parent/pom.xml presentation-all-testsupport-mockk diff --git a/presentation/presentation-json/pom.xml b/presentation/presentation-json/pom.xml index 5947e1a9d..58afb75b0 100644 --- a/presentation/presentation-json/pom.xml +++ b/presentation/presentation-json/pom.xml @@ -3,10 +3,10 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> - parent + buildsrc-kotlin-parent tech.coner.trailer 0.1.0-SNAPSHOT - ../../pom.xml + ../../buildsrc/buildsrc-kotlin-parent/pom.xml 4.0.0 diff --git a/presentation/presentation-text/pom.xml b/presentation/presentation-text/pom.xml index 0eb9938fd..7ab27caff 100644 --- a/presentation/presentation-text/pom.xml +++ b/presentation/presentation-text/pom.xml @@ -3,10 +3,10 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> - parent + buildsrc-kotlin-parent tech.coner.trailer 0.1.0-SNAPSHOT - ../../pom.xml + ../../buildsrc/buildsrc-kotlin-parent/pom.xml 4.0.0 diff --git a/presentation/presentation/pom.xml b/presentation/presentation/pom.xml index e967d4ba9..89306fab7 100644 --- a/presentation/presentation/pom.xml +++ b/presentation/presentation/pom.xml @@ -3,10 +3,10 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> - parent + buildsrc-kotlin-parent tech.coner.trailer 0.1.0-SNAPSHOT - ../../pom.xml + ../../buildsrc/buildsrc-kotlin-parent/pom.xml 4.0.0 diff --git a/render-html/pom.xml b/render-html/pom.xml index 31ec9fe1b..9282dbbf5 100644 --- a/render-html/pom.xml +++ b/render-html/pom.xml @@ -3,7 +3,7 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> - parent + buildsrc-kotlin-parent tech.coner.trailer 0.1.0-SNAPSHOT diff --git a/testutil/assertk-arrowkt/pom.xml b/testutil/assertk-arrowkt/pom.xml new file mode 100644 index 000000000..c78b08379 --- /dev/null +++ b/testutil/assertk-arrowkt/pom.xml @@ -0,0 +1,26 @@ + + + 4.0.0 + + tech.coner.trailer + buildsrc-kotlin-parent + 0.1.0-SNAPSHOT + ../../buildsrc/buildsrc-kotlin-parent/pom.xml + + + assertk-arrowkt + + + + com.willowtreeapps.assertk + assertk-jvm + compile + + + io.arrow-kt + arrow-core-jvm + + + \ No newline at end of file diff --git a/testutil/assertk-arrowkt/src/main/kotlin/tech/coner/trailer/assertk/arrowkt/ArrowktAssertk.kt b/testutil/assertk-arrowkt/src/main/kotlin/tech/coner/trailer/assertk/arrowkt/ArrowktAssertk.kt new file mode 100644 index 000000000..cc93594e9 --- /dev/null +++ b/testutil/assertk-arrowkt/src/main/kotlin/tech/coner/trailer/assertk/arrowkt/ArrowktAssertk.kt @@ -0,0 +1,30 @@ +package tech.coner.trailer.assertk.arrowkt + +import arrow.core.Either +import arrow.core.getOrElse +import assertk.Assert +import assertk.assertions.support.expected + +/** + * Asserts the Either value is Either.Right. You can pass in an optional lambda to run additional assertions on the right value + * + * ``` + * val name = Either.Right("...") + * assertThat(name).isRight().hasLength(3) + * ``` + */ +fun Assert>.isRight(): Assert = transform { actual -> + actual.getOrElse { expected("to be right") } +} + +/** + * Asserts the Either value is Either.Left. You can pass in an optional lambda to run additional assertions on the left value + * + * ``` + * val name = Either.Left("...") + * assertThat(name).isLeft().hasLength(3) + * ``` + */ +fun Assert>.isLeft(): Assert = transform { actual -> + actual.swap().getOrElse { expected("to be left") } +} \ No newline at end of file diff --git a/testutil/assertk-ktor/pom.xml b/testutil/assertk-ktor/pom.xml index bfcefa303..4d17d351c 100644 --- a/testutil/assertk-ktor/pom.xml +++ b/testutil/assertk-ktor/pom.xml @@ -3,10 +3,10 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> - parent + buildsrc-kotlin-parent tech.coner.trailer 0.1.0-SNAPSHOT - ../../pom.xml + ../../buildsrc/buildsrc-kotlin-parent/pom.xml 4.0.0 diff --git a/toolkit/konstraints/pom.xml b/toolkit/konstraints/pom.xml index dfe701a17..4b77739c1 100644 --- a/toolkit/konstraints/pom.xml +++ b/toolkit/konstraints/pom.xml @@ -5,9 +5,9 @@ 4.0.0 tech.coner.trailer - parent + buildsrc-kotlin-parent 0.1.0-SNAPSHOT - ../../pom.xml + ../../buildsrc/buildsrc-kotlin-parent/pom.xml toolkit-konstraints diff --git a/toolkit/presentation/presentation-test/src/main/kotlin/tech/coner/trailer/toolkit/presentation/testsupport/fooapp/data/service/FooService.kt b/toolkit/presentation/presentation-test/src/main/kotlin/tech/coner/trailer/toolkit/presentation/testsupport/fooapp/data/service/FooService.kt deleted file mode 100644 index 7246d371c..000000000 --- a/toolkit/presentation/presentation-test/src/main/kotlin/tech/coner/trailer/toolkit/presentation/testsupport/fooapp/data/service/FooService.kt +++ /dev/null @@ -1,11 +0,0 @@ -package tech.coner.trailer.toolkit.presentation.testsupport.fooapp.data.service - -import tech.coner.trailer.toolkit.presentation.testsupport.fooapp.domain.entity.Foo - -interface FooService { - - suspend fun create(create: Foo): Result - suspend fun findById(id: Foo.Id): Result - suspend fun update(update: Foo): Result - suspend fun deleteById(id: Foo.Id): Result -} \ No newline at end of file diff --git a/toolkit/presentation/presentation-test/src/main/kotlin/tech/coner/trailer/toolkit/presentation/testsupport/fooapp/domain/constraint/FooConstraint.kt b/toolkit/presentation/presentation-test/src/main/kotlin/tech/coner/trailer/toolkit/presentation/testsupport/fooapp/domain/constraint/FooConstraint.kt deleted file mode 100644 index a1f1868b8..000000000 --- a/toolkit/presentation/presentation-test/src/main/kotlin/tech/coner/trailer/toolkit/presentation/testsupport/fooapp/domain/constraint/FooConstraint.kt +++ /dev/null @@ -1,46 +0,0 @@ -package tech.coner.trailer.toolkit.presentation.testsupport.fooapp.domain.constraint - -import tech.coner.trailer.toolkit.konstraints.CompositeConstraint -import tech.coner.trailer.toolkit.presentation.testsupport.fooapp.domain.entity.Foo - -class FooConstraint : CompositeConstraint() { - - val valueIsInRange = propertyConstraint( - property = Foo::id, - assessFn = { - when (it.id.value) { - in 0..4 -> true - else -> false - } - }, - violationMessageFn = { "ID value must be in 0 to 4" } - ) - - - val nameIsLettersOnly = propertyConstraint( - property = Foo::name, - assessFn = { foo -> foo.name.all { it.isLetter() } }, - violationMessageFn = { "Name must be only letters" } - ) - - val nameMustBeThreeCharacters = propertyConstraint( - property = Foo::name, - assessFn = { foo -> foo.name.length == 3 }, - violationMessageFn = { "Name must be three letters long" } - ) - - private val vowels = "aeiouy" - private val consonants = "bcdfghjklmnpqrstvwxz" - private val namesOtherThanFooPattern = Regex("[$consonants][$vowels][$consonants]") - - val namesOtherThanFooMustFollowPatternOfConsonantVowelConsonant = propertyConstraint( - property = Foo::name, - assessFn = { foo -> - when (foo.name) { - "foo" -> true - else -> namesOtherThanFooPattern.matches(foo.name) - } - }, - violationMessageFn = { "Names other than foo must start with a consonant, followed by a vowel, and end with a consonant" } - ) -} \ No newline at end of file diff --git a/toolkit/presentation/presentation-test/src/main/kotlin/tech/coner/trailer/toolkit/presentation/testsupport/fooapp/domain/service/TestableFooService.kt b/toolkit/presentation/presentation-test/src/main/kotlin/tech/coner/trailer/toolkit/presentation/testsupport/fooapp/domain/service/TestableFooService.kt deleted file mode 100644 index 32ad0fc2a..000000000 --- a/toolkit/presentation/presentation-test/src/main/kotlin/tech/coner/trailer/toolkit/presentation/testsupport/fooapp/domain/service/TestableFooService.kt +++ /dev/null @@ -1,48 +0,0 @@ -package tech.coner.trailer.toolkit.presentation.testsupport.fooapp.domain.service - -import tech.coner.trailer.toolkit.konstraints.Constraint -import tech.coner.trailer.toolkit.presentation.testsupport.fooapp.domain.entity.FOO_ID_BAR -import tech.coner.trailer.toolkit.presentation.testsupport.fooapp.domain.entity.FOO_ID_BAT -import tech.coner.trailer.toolkit.presentation.testsupport.fooapp.domain.entity.FOO_ID_BAZ -import tech.coner.trailer.toolkit.presentation.testsupport.fooapp.domain.entity.FOO_ID_FOO -import tech.coner.trailer.toolkit.presentation.testsupport.fooapp.domain.entity.Foo -import tech.coner.trailer.toolkit.presentation.testsupport.fooapp.domain.exception.AlreadyExistsException -import tech.coner.trailer.toolkit.presentation.testsupport.fooapp.domain.exception.NotFoundException - -class TestableFooService(private val constraint: Constraint) : - tech.coner.trailer.toolkit.presentation.testsupport.fooapp.data.service.FooService { - - private val map: MutableMap = arrayOf( - FOO_ID_FOO to "foo", - FOO_ID_BAR to "bar", - FOO_ID_BAZ to "baz", - FOO_ID_BAT to "bat", - ) - .associate { (idValue, name) -> Foo.Id(idValue).let { it to Foo(it, name) } } - .toMutableMap() - - override suspend fun create(create: Foo): Result { - if (map.containsKey(create.id)) { - return Result.failure(AlreadyExistsException()) - } - return constraint(create) - .map { map[create.id] = create; create } - } - - override suspend fun findById(id: Foo.Id): Result { - return map[id]?.let { Result.success(it) } - ?: Result.failure(NotFoundException()) - } - - override suspend fun update(update: Foo): Result { - return findById(update.id) - .mapCatching { constraint(update).getOrThrow() } - .map { map[update.id] = update; update } - } - - override suspend fun deleteById(id: Foo.Id): Result { - return map.remove(id) - ?.let { Result.success(it) } - ?: Result.failure(NotFoundException()) - } -} diff --git a/toolkit/presentation/presentation-test/src/main/kotlin/tech/coner/trailer/toolkit/presentation/testsupport/fooapp/presentation/adapter/FooAdapter.kt b/toolkit/presentation/presentation-test/src/main/kotlin/tech/coner/trailer/toolkit/presentation/testsupport/fooapp/presentation/adapter/FooAdapter.kt deleted file mode 100644 index 601646546..000000000 --- a/toolkit/presentation/presentation-test/src/main/kotlin/tech/coner/trailer/toolkit/presentation/testsupport/fooapp/presentation/adapter/FooAdapter.kt +++ /dev/null @@ -1,32 +0,0 @@ -package tech.coner.trailer.toolkit.presentation.testsupport.fooapp.presentation.adapter - -import tech.coner.trailer.toolkit.presentation.adapter.LoadableItemAdapter -import tech.coner.trailer.toolkit.presentation.testsupport.fooapp.domain.entity.Foo -import tech.coner.trailer.toolkit.presentation.testsupport.fooapp.presentation.model.FooItemModel - -class FooAdapter : LoadableItemAdapter< - Foo.Id, - Foo, - Unit, - FooItemModel - >() { - override val argumentToModelAdapter = null - override val itemToModelAdapter: (Foo) -> FooItemModel = { FooItemModel(it, this) } - override val modelToItemAdapter: (FooItemModel) -> Foo = { - Foo( - id = it.item.id, - name = it.name.lowercase() - ) - } - - val modelNameProperty: (Foo) -> String = { it.name.capitalizeFirstChar() } - val entityNameProperty: (String) -> String = { it.lowercase() } - - private fun String.capitalizeFirstChar(): String { // TODO: adapter - return when (length) { - 0 -> this - 1 -> uppercase() - else -> let { "${it[0].uppercaseChar()}${it.substring(1)}" } - } - } -} \ No newline at end of file diff --git a/toolkit/presentation/presentation-test/src/main/kotlin/tech/coner/trailer/toolkit/presentation/testsupport/fooapp/presentation/model/FooItemModel.kt b/toolkit/presentation/presentation-test/src/main/kotlin/tech/coner/trailer/toolkit/presentation/testsupport/fooapp/presentation/model/FooItemModel.kt deleted file mode 100644 index ed1aa7d76..000000000 --- a/toolkit/presentation/presentation-test/src/main/kotlin/tech/coner/trailer/toolkit/presentation/testsupport/fooapp/presentation/model/FooItemModel.kt +++ /dev/null @@ -1,21 +0,0 @@ -package tech.coner.trailer.toolkit.presentation.testsupport.fooapp.presentation.model - -import kotlinx.coroutines.runBlocking -import tech.coner.trailer.toolkit.presentation.model.BaseItemModel -import tech.coner.trailer.toolkit.presentation.testsupport.fooapp.domain.constraint.FooConstraint -import tech.coner.trailer.toolkit.presentation.testsupport.fooapp.domain.entity.Foo -import tech.coner.trailer.toolkit.presentation.testsupport.fooapp.presentation.adapter.FooAdapter - -class FooItemModel( - override val initialItem: Foo, - private val adapter: FooAdapter, -) : BaseItemModel() { - override val constraints = - FooConstraint() - - var name: String - get() = adapter.modelNameProperty(pendingItem) - set(value) = runBlocking { mutatePendingItem { it.copy(name = adapter.entityNameProperty(value)) } } - -} - diff --git a/toolkit/presentation/presentation-test/src/main/kotlin/tech/coner/trailer/toolkit/presentation/testsupport/fooapp/presentation/presenter/FooDetailPresenter.kt b/toolkit/presentation/presentation-test/src/main/kotlin/tech/coner/trailer/toolkit/presentation/testsupport/fooapp/presentation/presenter/FooDetailPresenter.kt deleted file mode 100644 index 664a1376c..000000000 --- a/toolkit/presentation/presentation-test/src/main/kotlin/tech/coner/trailer/toolkit/presentation/testsupport/fooapp/presentation/presenter/FooDetailPresenter.kt +++ /dev/null @@ -1,41 +0,0 @@ -package tech.coner.trailer.toolkit.presentation.testsupport.fooapp.presentation.presenter - -import kotlin.coroutines.CoroutineContext -import kotlin.time.Duration.Companion.seconds -import kotlinx.coroutines.delay -import tech.coner.trailer.toolkit.presentation.model.LoadableModel -import tech.coner.trailer.toolkit.presentation.presenter.LoadableItemPresenter -import tech.coner.trailer.toolkit.presentation.state.LoadableItemState -import tech.coner.trailer.toolkit.presentation.testsupport.fooapp.domain.entity.Foo -import tech.coner.trailer.toolkit.presentation.testsupport.fooapp.presentation.adapter.FooAdapter -import tech.coner.trailer.toolkit.presentation.testsupport.fooapp.presentation.model.FooItemModel - -class FooDetailPresenter( - override val argument: Foo.Id, - private val service: tech.coner.trailer.toolkit.presentation.testsupport.fooapp.data.service.FooService, - override val coroutineContext: CoroutineContext -) : LoadableItemPresenter< - Foo.Id, - Foo, - Unit, - FooItemModel - >() { - override val adapter = FooAdapter() - - override suspend fun performLoad(): Result { - return service.findById(argument) - .onSuccess { foo -> - // faking a partial load - update { - LoadableItemState( - LoadableModel.Loading( - partial = adapter.itemToModelAdapter( - foo.copy(name = "${foo.name[0]}") - ) - ) - ) - } - delay(1.seconds) - } - } -} \ No newline at end of file diff --git a/toolkit/presentation/presentation-test/src/test/kotlin/tech/coner/trailer/toolkit/presentation/presenter/FooDetailPresenterTest.kt b/toolkit/presentation/presentation-test/src/test/kotlin/tech/coner/trailer/toolkit/presentation/presenter/FooDetailPresenterTest.kt deleted file mode 100644 index 73e653c7b..000000000 --- a/toolkit/presentation/presentation-test/src/test/kotlin/tech/coner/trailer/toolkit/presentation/presenter/FooDetailPresenterTest.kt +++ /dev/null @@ -1,176 +0,0 @@ -package tech.coner.trailer.toolkit.presentation.presenter - -import app.cash.turbine.test -import assertk.all -import assertk.assertThat -import assertk.assertions.isEmpty -import assertk.assertions.isEqualTo -import assertk.assertions.isFalse -import assertk.assertions.isInstanceOf -import assertk.assertions.isNotNull -import assertk.assertions.isNull -import assertk.assertions.isTrue -import assertk.assertions.length -import kotlinx.coroutines.CoroutineName -import kotlinx.coroutines.Job -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest -import org.junit.jupiter.api.Test -import org.junit.jupiter.params.ParameterizedTest -import org.junit.jupiter.params.provider.ValueSource -import tech.coner.trailer.toolkit.presentation.model.cause -import tech.coner.trailer.toolkit.presentation.model.isDirty -import tech.coner.trailer.toolkit.presentation.model.isEmpty -import tech.coner.trailer.toolkit.presentation.model.isLoadFailed -import tech.coner.trailer.toolkit.presentation.model.isLoaded -import tech.coner.trailer.toolkit.presentation.model.isLoading -import tech.coner.trailer.toolkit.presentation.model.isValid -import tech.coner.trailer.toolkit.presentation.model.item -import tech.coner.trailer.toolkit.presentation.model.partial -import tech.coner.trailer.toolkit.presentation.model.violations -import tech.coner.trailer.toolkit.presentation.state.loadable -import tech.coner.trailer.toolkit.presentation.testsupport.fooapp.domain.constraint.FooConstraint -import tech.coner.trailer.toolkit.presentation.testsupport.fooapp.domain.entity.FOO_ID_FOO -import tech.coner.trailer.toolkit.presentation.testsupport.fooapp.domain.entity.Foo -import tech.coner.trailer.toolkit.presentation.testsupport.fooapp.domain.entity.name -import tech.coner.trailer.toolkit.presentation.testsupport.fooapp.domain.exception.NotFoundException -import tech.coner.trailer.toolkit.presentation.testsupport.fooapp.domain.service.TestableFooService -import tech.coner.trailer.toolkit.presentation.testsupport.fooapp.presentation.model.name -import tech.coner.trailer.toolkit.presentation.testsupport.fooapp.presentation.presenter.FooDetailPresenter - -class FooDetailPresenterTest { - - @Test - fun itsModelFlowShouldBeAdaptedFromInitialState() = runTest { - val id = Foo.Id(FOO_ID_FOO) - val presenter = createPresenter(id) - - presenter.stateFlow.test { - assertThat(expectMostRecentItem()) - .loadable() - .isEmpty() - } - } - - @Test - fun itsModelFlowShouldEmitWhenLoadingAndLoaded() = runTest { - val id = Foo.Id(FOO_ID_FOO) - val presenter = createPresenter(id) - - presenter.stateFlow.test { - skipItems(1) - - presenter.load() - - assertThat(awaitItem()) - .loadable() - .isLoading() - .partial() - .isNull() - assertThat(awaitItem()) - .loadable() - .isLoading() - .partial() - .isNotNull() - .item() - .name() - .length() - .isEqualTo(1) - assertThat(awaitItem()) - .loadable() - .isLoaded() - .item() - .item() - .name() - .isEqualTo("foo") - } - } - - @Test - fun itsModelFlowShouldEmitWhenLoadingAndLoadFailed() = runTest { - val id = Foo.Id(Int.MAX_VALUE) - val presenter = createPresenter(id) - - presenter.stateFlow.test { - skipItems(1) - - presenter.load() - - assertThat(awaitItem()) - .loadable() - .isLoading() - assertThat(awaitItem()) - .loadable() - .isLoadFailed() - .cause() - .isNotNull() - .isInstanceOf() - } - } - - @Test - fun whenItsModelNameChangedValidItsItemModelShouldBeValid() = runTest { - val id = Foo.Id(FOO_ID_FOO) - val presenter = createPresenter(id) - launch { presenter.load() } - val model = presenter.awaitModelLoadedOrThrow() - - model.item.name = "bax" - model.item.validate() - - assertThat(model.item).all { - name().isEqualTo("Bax") - isValid().isTrue() - isDirty().isTrue() - } - } - - @Test - fun whenItsModelNameChangedInvalidItsItemModelShouldBeInvalid() = runTest { - val id = Foo.Id(FOO_ID_FOO) - val presenter = createPresenter(id) - launch { presenter.load() } - val model = presenter.awaitModelLoadedOrThrow() - - model.item.name = "boo" - model.item.validate() - - assertThat(model.item).all { - name().isEqualTo("Boo") - isValid().isFalse() - isDirty().isTrue() - } - } - - @ParameterizedTest - @ValueSource(strings = ["Bar", "Baz", "Bat", "Cat", "Dat", "Far", "Ber", "Fir", "Nor", "Dur", "Xyr"]) - fun whenItsModelNameChangedValidItsItemModelShouldValidateValid(newName: String) = runTest { - val id = Foo.Id(FOO_ID_FOO) - val presenter = createPresenter(id) - launch { presenter.load() } - val model = presenter.awaitModelLoadedOrThrow() - - with (model.item) { - name = newName - validate() - } - - assertThat(model.item).all { - name().isEqualTo(newName) - isValid().isTrue() - violations().isEmpty() - isDirty().isTrue() - } - } -} - -private fun TestScope.createPresenter(argument: Foo.Id): FooDetailPresenter { - return FooDetailPresenter( - argument = argument, - service = TestableFooService( - constraint = FooConstraint() - ), - coroutineContext = coroutineContext + Job() + CoroutineName("FooDetailPresenter") - ) -} diff --git a/toolkit/presentation/presentation-test/src/test/kotlin/tech/coner/trailer/toolkit/presentation/testsupport/fooapp/presentation/model/FooModelAssertk.kt b/toolkit/presentation/presentation-test/src/test/kotlin/tech/coner/trailer/toolkit/presentation/testsupport/fooapp/presentation/model/FooModelAssertk.kt deleted file mode 100644 index 45f4d110f..000000000 --- a/toolkit/presentation/presentation-test/src/test/kotlin/tech/coner/trailer/toolkit/presentation/testsupport/fooapp/presentation/model/FooModelAssertk.kt +++ /dev/null @@ -1,6 +0,0 @@ -package tech.coner.trailer.toolkit.presentation.testsupport.fooapp.presentation.model - -import assertk.Assert -import assertk.assertions.prop - -fun Assert.name() = prop(FooItemModel::name) \ No newline at end of file diff --git a/toolkit/presentation/presentation-testsupport/pom.xml b/toolkit/presentation/presentation-testsupport/pom.xml index 2419cb405..90670303e 100644 --- a/toolkit/presentation/presentation-testsupport/pom.xml +++ b/toolkit/presentation/presentation-testsupport/pom.xml @@ -5,9 +5,9 @@ 4.0.0 tech.coner.trailer - parent + buildsrc-kotlin-parent 0.1.0-SNAPSHOT - ../../../pom.xml + ../../../buildsrc/buildsrc-kotlin-parent/pom.xml toolkit-presentation-testsupport diff --git a/toolkit/presentation/presentation-testsupport/src/main/kotlin/tech/coner/trailer/toolkit/presentation/model/ItemModelAssertk.kt b/toolkit/presentation/presentation-testsupport/src/main/kotlin/tech/coner/trailer/toolkit/presentation/model/ItemModelAssertk.kt index 50bc6549f..a7d2ecde2 100644 --- a/toolkit/presentation/presentation-testsupport/src/main/kotlin/tech/coner/trailer/toolkit/presentation/model/ItemModelAssertk.kt +++ b/toolkit/presentation/presentation-testsupport/src/main/kotlin/tech/coner/trailer/toolkit/presentation/model/ItemModelAssertk.kt @@ -2,13 +2,14 @@ package tech.coner.trailer.toolkit.presentation.model import assertk.Assert import assertk.assertions.prop +import tech.coner.trailer.toolkit.validation.Feedback -fun Assert>.item() = prop(ItemModel::item) +fun > Assert>.item() = prop(ItemModel::item) -fun Assert>.pendingItem() = prop(ItemModel::pendingItem) +fun > Assert>.pendingItem() = prop(ItemModel::pendingItem) -fun Assert>.violations() = prop(ItemModel::pendingItemValidation) +fun > Assert>.pendingItemValidation() = prop(ItemModel::pendingItemValidation) -fun Assert>.isValid() = prop(ItemModel::isPendingItemValid) +fun > Assert>.isValid() = prop(ItemModel::isPendingItemValid) -fun Assert>.isDirty() = prop(ItemModel::isPendingItemDirty) \ No newline at end of file +fun > Assert>.isDirty() = prop(ItemModel::isPendingItemDirty) \ No newline at end of file diff --git a/toolkit/presentation/presentation-testsupport/src/main/kotlin/tech/coner/trailer/toolkit/presentation/model/LoadableModelAssertk.kt b/toolkit/presentation/presentation-testsupport/src/main/kotlin/tech/coner/trailer/toolkit/presentation/model/LoadableModelAssertk.kt index 9642f6798..9e499872b 100644 --- a/toolkit/presentation/presentation-testsupport/src/main/kotlin/tech/coner/trailer/toolkit/presentation/model/LoadableModelAssertk.kt +++ b/toolkit/presentation/presentation-testsupport/src/main/kotlin/tech/coner/trailer/toolkit/presentation/model/LoadableModelAssertk.kt @@ -3,14 +3,14 @@ package tech.coner.trailer.toolkit.presentation.model import assertk.Assert import assertk.assertions.isInstanceOf import assertk.assertions.prop +import tech.coner.trailer.toolkit.validation.Feedback -fun > Assert>.isEmpty() = isInstanceOf>() +fun , FEEDBACK : Feedback> Assert>.isEmpty() = isInstanceOf>() -fun > Assert>.isLoading() = isInstanceOf>() -fun > Assert>.partial() = prop(LoadableModel.Loading::partial) +fun , FEEDBACK : Feedback> Assert>.isLoading() = isInstanceOf>() +fun , FEEDBACK : Feedback> Assert>.partial() = prop(Loadable.Loading::partial) -fun > Assert>.isLoaded() = isInstanceOf>() -fun > Assert>.item() = prop(LoadableModel.Loaded::item) +fun , FEEDBACK : Feedback> Assert>.isLoaded() = isInstanceOf>() +fun , FEEDBACK : Feedback> Assert>.either() = prop(Loadable.Loaded::either) -fun > Assert>.isLoadFailed() = isInstanceOf>() -fun > Assert>.cause() = prop(LoadableModel.LoadFailed::cause) +// TODO: convenient API for asserting loaded either success or failure diff --git a/toolkit/presentation/presentation-testsupport/src/main/kotlin/tech/coner/trailer/toolkit/presentation/state/ItemModelStateAssertk.kt b/toolkit/presentation/presentation-testsupport/src/main/kotlin/tech/coner/trailer/toolkit/presentation/state/ItemModelStateAssertk.kt new file mode 100644 index 000000000..5ef47079c --- /dev/null +++ b/toolkit/presentation/presentation-testsupport/src/main/kotlin/tech/coner/trailer/toolkit/presentation/state/ItemModelStateAssertk.kt @@ -0,0 +1,7 @@ +package tech.coner.trailer.toolkit.presentation.state + +import assertk.Assert +import assertk.assertions.prop +import tech.coner.trailer.toolkit.validation.Feedback + +fun > Assert>.itemModel() = prop(ItemModelState::itemModel) \ No newline at end of file diff --git a/toolkit/presentation/presentation-testsupport/src/main/kotlin/tech/coner/trailer/toolkit/presentation/state/LoadableItemStateAssertk.kt b/toolkit/presentation/presentation-testsupport/src/main/kotlin/tech/coner/trailer/toolkit/presentation/state/LoadableItemStateAssertk.kt index 52ff64963..528e2a6ff 100644 --- a/toolkit/presentation/presentation-testsupport/src/main/kotlin/tech/coner/trailer/toolkit/presentation/state/LoadableItemStateAssertk.kt +++ b/toolkit/presentation/presentation-testsupport/src/main/kotlin/tech/coner/trailer/toolkit/presentation/state/LoadableItemStateAssertk.kt @@ -3,5 +3,11 @@ package tech.coner.trailer.toolkit.presentation.state import assertk.Assert import assertk.assertions.prop import tech.coner.trailer.toolkit.presentation.model.ItemModel +import tech.coner.trailer.toolkit.validation.Feedback -fun > Assert>.loadable() = prop(LoadableItemState::loadable) \ No newline at end of file +fun , FAILURE, ITEM, ITEM_MODEL : ItemModel, FEEDBACK : Feedback> + Assert.loadable() = prop(LoadableState::loadable) + +fun , FAILURE, ITEM, ITEM_MODEL : ItemModel, FEEDBACK : Feedback, PROPERTY> + Assert>.value() + = prop(MutableLoadedPropertyDelegate::value) \ No newline at end of file diff --git a/toolkit/presentation/presentation-testsupport/src/main/kotlin/tech/coner/trailer/toolkit/presentation/state/StatefulPresenterAssertk.kt b/toolkit/presentation/presentation-testsupport/src/main/kotlin/tech/coner/trailer/toolkit/presentation/state/StatefulPresenterAssertk.kt new file mode 100644 index 000000000..9aa96a22a --- /dev/null +++ b/toolkit/presentation/presentation-testsupport/src/main/kotlin/tech/coner/trailer/toolkit/presentation/state/StatefulPresenterAssertk.kt @@ -0,0 +1,7 @@ +package tech.coner.trailer.toolkit.presentation.state + +import assertk.Assert +import assertk.assertions.prop +import tech.coner.trailer.toolkit.presentation.presenter.StatefulPresenter + +fun Assert>.state() = prop(StatefulPresenter::state) \ No newline at end of file diff --git a/toolkit/presentation/presentation-testsupport/src/main/kotlin/tech/coner/trailer/toolkit/presentation/testsupport/LoadableItemPresenterAssertk.kt b/toolkit/presentation/presentation-testsupport/src/main/kotlin/tech/coner/trailer/toolkit/presentation/testsupport/LoadableItemPresenterAssertk.kt deleted file mode 100644 index 9dd5acf0f..000000000 --- a/toolkit/presentation/presentation-testsupport/src/main/kotlin/tech/coner/trailer/toolkit/presentation/testsupport/LoadableItemPresenterAssertk.kt +++ /dev/null @@ -1,2 +0,0 @@ -package tech.coner.trailer.toolkit.presentation.testsupport - diff --git a/toolkit/presentation/presentation/pom.xml b/toolkit/presentation/presentation/pom.xml index 6c412d6d5..b845a5201 100644 --- a/toolkit/presentation/presentation/pom.xml +++ b/toolkit/presentation/presentation/pom.xml @@ -5,15 +5,20 @@ 4.0.0 tech.coner.trailer - parent + buildsrc-kotlin-parent 0.1.0-SNAPSHOT - ../../../pom.xml + ../../../buildsrc/buildsrc-kotlin-parent/pom.xml toolkit-presentation + + tech.coner.trailer + toolkit + 0.1.0-SNAPSHOT + tech.coner.trailer toolkit-validation diff --git a/toolkit/presentation/presentation/src/main/kotlin/tech/coner/trailer/toolkit/presentation/adapter/EntityModelAdapter.kt b/toolkit/presentation/presentation/src/main/kotlin/tech/coner/trailer/toolkit/presentation/adapter/EntityModelAdapter.kt new file mode 100644 index 000000000..296c650f6 --- /dev/null +++ b/toolkit/presentation/presentation/src/main/kotlin/tech/coner/trailer/toolkit/presentation/adapter/EntityModelAdapter.kt @@ -0,0 +1,8 @@ +package tech.coner.trailer.toolkit.presentation.adapter + +abstract class EntityModelAdapter { + + abstract val entityToModelAdapter: (ITEM) -> MODEL + abstract val modelToEntityAdapter: (MODEL) -> ITEM +} + diff --git a/toolkit/presentation/presentation/src/main/kotlin/tech/coner/trailer/toolkit/presentation/adapter/LoadableItemAdapter.kt b/toolkit/presentation/presentation/src/main/kotlin/tech/coner/trailer/toolkit/presentation/adapter/LoadableItemAdapter.kt deleted file mode 100644 index fb77dc83f..000000000 --- a/toolkit/presentation/presentation/src/main/kotlin/tech/coner/trailer/toolkit/presentation/adapter/LoadableItemAdapter.kt +++ /dev/null @@ -1,12 +0,0 @@ -package tech.coner.trailer.toolkit.presentation.adapter - -import tech.coner.trailer.toolkit.presentation.model.ItemModel -import tech.coner.trailer.toolkit.validation.Feedback - -abstract class LoadableItemAdapter - where ITEM_MODEL : ItemModel { - - abstract val itemToModelAdapter: (ITEM) -> ITEM_MODEL - abstract val modelToItemAdapter: (ITEM_MODEL) -> ITEM -} - diff --git a/toolkit/presentation/presentation/src/main/kotlin/tech/coner/trailer/toolkit/presentation/model/BaseItemModel.kt b/toolkit/presentation/presentation/src/main/kotlin/tech/coner/trailer/toolkit/presentation/model/BaseItemModel.kt index 8f011ba30..584cf5c71 100644 --- a/toolkit/presentation/presentation/src/main/kotlin/tech/coner/trailer/toolkit/presentation/model/BaseItemModel.kt +++ b/toolkit/presentation/presentation/src/main/kotlin/tech/coner/trailer/toolkit/presentation/model/BaseItemModel.kt @@ -1,15 +1,22 @@ package tech.coner.trailer.toolkit.presentation.model +import arrow.core.Either +import arrow.core.raise.either +import arrow.core.raise.ensure +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import tech.coner.trailer.toolkit.validation.Feedback -import tech.coner.trailer.toolkit.validation.ValidationResult +import tech.coner.trailer.toolkit.validation.ValidationOutcome import tech.coner.trailer.toolkit.validation.Validator abstract class BaseItemModel : ItemModel - where VALIDATOR_FEEDBACK : Feedback { + where VALIDATOR_FEEDBACK : Feedback { abstract val validator: Validator abstract val validatorContext: VALIDATOR_CONTEXT @@ -24,43 +31,70 @@ abstract class BaseItemModel final override val pendingItemFlow by lazy { _pendingItemFlow.asStateFlow() } final override var pendingItem: ITEM get() = pendingItemFlow.value - set(value) = mutatePendingItem { value } + set(value) = updatePendingItem { value } private val _pendingItemValidationFlow by lazy { - MutableStateFlow>( - ValidationResult(emptyMap()) - ) + MutableStateFlow>(ValidationOutcome(emptyList())) + } + final override val pendingItemValidationFlow: StateFlow> by lazy { + _pendingItemValidationFlow.asStateFlow() } - final override val pendingItemValidationFlow by lazy { _pendingItemValidationFlow.asStateFlow() } final override val pendingItemValidation get() = _pendingItemValidationFlow.value - final override fun mutatePendingItem(forceValidate: Boolean?, mutatePendingItemFn: (ITEM) -> ITEM) { - _pendingItemFlow.update { pending -> mutatePendingItemFn(pending) } - if (forceValidate == true) { + final override fun updatePendingItem(forceValidate: Boolean?, updateFn: (ITEM) -> ITEM) { + val pendingItemHadFeedback = _pendingItemValidationFlow.value.feedback.isNotEmpty() + _pendingItemFlow.update { pending -> + updateFn(pending) + } + if (forceValidate == true || pendingItemHadFeedback) { validate() } } final override val isPendingItemValid get() = _pendingItemValidationFlow.value.isValid + final override val isPendingItemValidFlow by lazy { + _pendingItemValidationFlow.map { it.isValid } + } final override val isPendingItemDirty get() = item != pendingItem + final override val isPendingItemDirtyFlow: Flow by lazy { + pendingItemFlow + .map { item != it } + } - final override fun validate(): ValidationResult { + override val canReset: Boolean get() = pendingItem != item || _pendingItemValidationFlow.value.feedback.isNotEmpty() + override val canResetFlow: Flow by lazy { + combine( + _pendingItemFlow, + _pendingItemValidationFlow, + ) { pendingItem, pendingItemValidation -> + pendingItem != item || pendingItemValidation.feedback.isNotEmpty() || item != initialItem + } + } + + final override fun validate(): ValidationOutcome { return validator(validatorContext, pendingItem) .also { _pendingItemValidationFlow.value = it } } - override fun commit(requireValid: Boolean): CommitOutcome { + + + override fun commit(requireValid: Boolean): Either, ITEM> = either { val validationResult = validate() if (requireValid) { - if (!validationResult.isValid) { - return CommitOutcome.Failure(pendingItem, validationResult) + ensure(validationResult.isValid) { + validationResult } } - return pendingItem + pendingItem .also { _itemFlow.value = it } - .let { CommitOutcome.Success(it, validationResult) } } -} \ No newline at end of file + + override fun reset() { + _itemFlow.value = initialItem + _pendingItemFlow.value = initialItem + _pendingItemValidationFlow.value = ValidationOutcome(emptyList()) + } +} diff --git a/toolkit/presentation/presentation/src/main/kotlin/tech/coner/trailer/toolkit/presentation/model/CommitOutcome.kt b/toolkit/presentation/presentation/src/main/kotlin/tech/coner/trailer/toolkit/presentation/model/CommitOutcome.kt deleted file mode 100644 index 3d86761b4..000000000 --- a/toolkit/presentation/presentation/src/main/kotlin/tech/coner/trailer/toolkit/presentation/model/CommitOutcome.kt +++ /dev/null @@ -1,30 +0,0 @@ -package tech.coner.trailer.toolkit.presentation.model - -import tech.coner.trailer.toolkit.validation.Feedback -import tech.coner.trailer.toolkit.validation.ValidationResult - -sealed class CommitOutcome { - - abstract val item: ITEM - abstract val feedback: ValidationResult? - - data class Success( - override val item: ITEM, - override val feedback: ValidationResult? - ) : CommitOutcome() - - data class Failure( - override val item: ITEM, - override val feedback: ValidationResult - ) : CommitOutcome() - - fun onSuccess(fn: (Success) -> Unit): CommitOutcome { - if (this is Success) fn(this) - return this - } - - fun onFailure(fn: (Failure) -> Unit): CommitOutcome { - if (this is Failure) fn(this) - return this - } -} \ No newline at end of file diff --git a/toolkit/presentation/presentation/src/main/kotlin/tech/coner/trailer/toolkit/presentation/model/ItemModel.kt b/toolkit/presentation/presentation/src/main/kotlin/tech/coner/trailer/toolkit/presentation/model/ItemModel.kt index a3b75ec46..4673fec38 100644 --- a/toolkit/presentation/presentation/src/main/kotlin/tech/coner/trailer/toolkit/presentation/model/ItemModel.kt +++ b/toolkit/presentation/presentation/src/main/kotlin/tech/coner/trailer/toolkit/presentation/model/ItemModel.kt @@ -1,27 +1,35 @@ package tech.coner.trailer.toolkit.presentation.model +import arrow.core.Either import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow import tech.coner.trailer.toolkit.validation.Feedback -import tech.coner.trailer.toolkit.validation.ValidationResult +import tech.coner.trailer.toolkit.validation.ValidationOutcome -interface ItemModel : Model { - val itemFlow: StateFlow +interface ItemModel> : Model { val item: ITEM + val itemFlow: StateFlow - val pendingItemFlow: StateFlow var pendingItem: ITEM + val pendingItemFlow: StateFlow val isPendingItemDirty: Boolean + val isPendingItemDirtyFlow: Flow - val pendingItemValidationFlow: Flow> - val pendingItemValidation: ValidationResult + val pendingItemValidation: ValidationOutcome + val pendingItemValidationFlow: StateFlow> val isPendingItemValid: Boolean + val isPendingItemValidFlow: Flow + + val canReset: Boolean + val canResetFlow: Flow + + fun updatePendingItem(forceValidate: Boolean? = null, updateFn: (ITEM) -> ITEM) - fun mutatePendingItem(forceValidate: Boolean? = null, mutatePendingItemFn: (ITEM) -> ITEM) + fun validate(): ValidationOutcome - fun validate(): ValidationResult + fun commit(requireValid: Boolean = true): Either, ITEM> - fun commit(requireValid: Boolean = true): CommitOutcome + fun reset() } \ No newline at end of file diff --git a/toolkit/presentation/presentation/src/main/kotlin/tech/coner/trailer/toolkit/presentation/model/Loadable.kt b/toolkit/presentation/presentation/src/main/kotlin/tech/coner/trailer/toolkit/presentation/model/Loadable.kt new file mode 100644 index 000000000..6d666a7eb --- /dev/null +++ b/toolkit/presentation/presentation/src/main/kotlin/tech/coner/trailer/toolkit/presentation/model/Loadable.kt @@ -0,0 +1,73 @@ +package tech.coner.trailer.toolkit.presentation.model + +import arrow.core.Either +import tech.coner.trailer.toolkit.validation.Feedback + +sealed interface Loadable, FEEDBACK : Feedback> + : Model { + + /** + * Initial model corresponding to the presenter having initial state, + * prior to starting to load anything, or if it was fully reset. + */ + class Empty, FEEDBACK : Feedback> + : Loadable { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + return true + } + + override fun hashCode(): Int { + return javaClass.hashCode() + } + } + + /** + * Model indicates the item is loading. + * + * @property partial Partial model representing incomplete loading + * state. For long or complex load operations, use to convey details + * of loading in progress. Adapters may optionally specify this value. + * It may be helpful to implement the partial model with a different + * type than the loaded item. + */ + data class Loading, FEEDBACK : Feedback>( + val partial: ITEM_MODEL? = null + ) : Loadable + + /** + * Indicates a load operation completed, either in failure or success. + */ + data class Loaded, FEEDBACK : Feedback>( + val either: Either + ) + : Loadable + + data class FailedExceptionally, FEEDBACK : Feedback>( + val throwable: Throwable + ) + : Loadable +} + +inline fun , FEEDBACK : Feedback> Loadable.letLoadedSuccess( + block: (ITEM_MODEL) -> R +): R? { + return when (this) { + is Loadable.Loaded -> either.getOrNull()?.let(block) + is Loadable.Empty, + is Loadable.Loading, + is Loadable.FailedExceptionally -> null + } +} + +inline fun , FEEDBACK : Feedback> Loadable.whenLoadedSuccess( + block: (ITEM_MODEL) -> Unit +) { + when (this) { + is Loadable.Loaded -> either.getOrNull()?.also(block) + is Loadable.Empty, + is Loadable.Loading, + is Loadable.FailedExceptionally -> { /* no-op */ } + } +} \ No newline at end of file diff --git a/toolkit/presentation/presentation/src/main/kotlin/tech/coner/trailer/toolkit/presentation/model/LoadableModel.kt b/toolkit/presentation/presentation/src/main/kotlin/tech/coner/trailer/toolkit/presentation/model/LoadableModel.kt deleted file mode 100644 index 2262b47e2..000000000 --- a/toolkit/presentation/presentation/src/main/kotlin/tech/coner/trailer/toolkit/presentation/model/LoadableModel.kt +++ /dev/null @@ -1,55 +0,0 @@ -package tech.coner.trailer.toolkit.presentation.model - -import tech.coner.trailer.toolkit.validation.Feedback - -sealed class LoadableModel, FEEDBACK : Feedback> - : Model { - - /** - * Initial model corresponding to the presenter having initial state, - * prior to starting to load anything, or if it was fully reset. - */ - class Empty, FEEDBACK : Feedback> : LoadableModel() { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - return true - } - - override fun hashCode(): Int { - return javaClass.hashCode() - } - } - - /** - * Model indicates the item is loading. - * - * @property partial Partial model representing incomplete loading - * state. For long or complex load operations, use to convey details - * of loading in progress. Adapters may optionally specify this value. - * It may be helpful to implement the partial model with a different - * type than the loaded item. - */ - data class Loading, FEEDBACK : Feedback>( - val partial: ITEM_MODEL? = null - ) : LoadableModel() - - /** - * Model indicates the item has loaded. - * - * @property item the item resulting from the load operation. - */ - data class Loaded, FEEDBACK : Feedback>( - val item: ITEM_MODEL - ) : LoadableModel() - - /** - * Model indicates the load operation failed. - * - * @property cause the cause of the failure, if known - */ - data class LoadFailed, FEEDBACK : Feedback>( - val cause: Throwable - ) : LoadableModel() - -} \ No newline at end of file diff --git a/toolkit/presentation/presentation/src/main/kotlin/tech/coner/trailer/toolkit/presentation/presenter/ItemModelPresenter.kt b/toolkit/presentation/presentation/src/main/kotlin/tech/coner/trailer/toolkit/presentation/presenter/ItemModelPresenter.kt new file mode 100644 index 000000000..222d6cd0c --- /dev/null +++ b/toolkit/presentation/presentation/src/main/kotlin/tech/coner/trailer/toolkit/presentation/presenter/ItemModelPresenter.kt @@ -0,0 +1,10 @@ +package tech.coner.trailer.toolkit.presentation.presenter + +interface ItemModelPresenter { + + suspend fun reset() + + suspend fun commit() + + suspend fun validate() +} \ No newline at end of file diff --git a/toolkit/presentation/presentation/src/main/kotlin/tech/coner/trailer/toolkit/presentation/presenter/LoadableItemPresenter.kt b/toolkit/presentation/presentation/src/main/kotlin/tech/coner/trailer/toolkit/presentation/presenter/LoadableItemPresenter.kt deleted file mode 100644 index 0afe3cec3..000000000 --- a/toolkit/presentation/presentation/src/main/kotlin/tech/coner/trailer/toolkit/presentation/presenter/LoadableItemPresenter.kt +++ /dev/null @@ -1,54 +0,0 @@ -package tech.coner.trailer.toolkit.presentation.presenter - -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.map -import tech.coner.trailer.toolkit.presentation.adapter.LoadableItemAdapter -import tech.coner.trailer.toolkit.presentation.model.ItemModel -import tech.coner.trailer.toolkit.presentation.model.LoadableModel -import tech.coner.trailer.toolkit.presentation.model.Model -import tech.coner.trailer.toolkit.presentation.state.LoadableItemState -import tech.coner.trailer.toolkit.validation.Feedback - -abstract class LoadableItemPresenter( - override val initialState: LoadableItemState = LoadableItemState(LoadableModel.Empty()) -) - : SecondDraftPresenter< - LoadableItemState - >(), CoroutineScope - where ITEM_MODEL : ItemModel { - - protected abstract val adapter: LoadableItemAdapter - - suspend fun load() { - update { old -> old.copy(LoadableModel.Loading()) } - performLoad() - .onSuccess { loaded -> update { old -> old.copy(LoadableModel.Loaded(adapter.itemToModelAdapter(loaded))) } } - .onFailure { failed -> update { old -> old.copy(LoadableModel.LoadFailed(failed)) } } - } - - protected abstract suspend fun performLoad(): Result - - suspend fun awaitModelLoadedOrFailed(): LoadableModel { - return stateFlow - .map { it.loadable } - .first { - when (it) { - is LoadableModel.Loaded, is LoadableModel.LoadFailed -> true - else -> false - } - } - } - - suspend fun awaitModelLoadedOrThrow(): LoadableModel.Loaded { - return stateFlow - .map { it.loadable } - .first { - when (it) { - is LoadableModel.Loaded -> true - is LoadableModel.LoadFailed -> throw it.cause - else -> false - } - } as LoadableModel.Loaded - } -} \ No newline at end of file diff --git a/toolkit/presentation/presentation/src/main/kotlin/tech/coner/trailer/toolkit/presentation/presenter/LoadablePresenter.kt b/toolkit/presentation/presentation/src/main/kotlin/tech/coner/trailer/toolkit/presentation/presenter/LoadablePresenter.kt new file mode 100644 index 000000000..46550e701 --- /dev/null +++ b/toolkit/presentation/presentation/src/main/kotlin/tech/coner/trailer/toolkit/presentation/presenter/LoadablePresenter.kt @@ -0,0 +1,9 @@ +package tech.coner.trailer.toolkit.presentation.presenter + +import arrow.core.Either +import kotlinx.coroutines.Deferred + +interface LoadablePresenter { + + suspend fun load(): Deferred>> +} \ No newline at end of file diff --git a/toolkit/presentation/presentation/src/main/kotlin/tech/coner/trailer/toolkit/presentation/presenter/PresenterCoroutineScope.kt b/toolkit/presentation/presentation/src/main/kotlin/tech/coner/trailer/toolkit/presentation/presenter/PresenterCoroutineScope.kt index 18107f767..6853ff59b 100644 --- a/toolkit/presentation/presentation/src/main/kotlin/tech/coner/trailer/toolkit/presentation/presenter/PresenterCoroutineScope.kt +++ b/toolkit/presentation/presentation/src/main/kotlin/tech/coner/trailer/toolkit/presentation/presenter/PresenterCoroutineScope.kt @@ -1,5 +1,9 @@ package tech.coner.trailer.toolkit.presentation.presenter import kotlinx.coroutines.CoroutineScope +import kotlin.coroutines.CoroutineContext -class PresenterCoroutineScope(coroutineScope: CoroutineScope) : CoroutineScope by coroutineScope +class PresenterCoroutineScope(coroutineScope: CoroutineScope) : CoroutineScope by coroutineScope { + + constructor(coroutineContext: CoroutineContext) : this(CoroutineScope(coroutineContext)) +} diff --git a/toolkit/presentation/presentation/src/main/kotlin/tech/coner/trailer/toolkit/presentation/presenter/SecondDraftPresenter.kt b/toolkit/presentation/presentation/src/main/kotlin/tech/coner/trailer/toolkit/presentation/presenter/SecondDraftPresenter.kt deleted file mode 100644 index f161a3a88..000000000 --- a/toolkit/presentation/presentation/src/main/kotlin/tech/coner/trailer/toolkit/presentation/presenter/SecondDraftPresenter.kt +++ /dev/null @@ -1,39 +0,0 @@ -package tech.coner.trailer.toolkit.presentation.presenter - -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import tech.coner.trailer.toolkit.presentation.model.Model -import tech.coner.trailer.toolkit.presentation.state.State - -abstract class SecondDraftPresenter - where STATE : State { - - abstract val initialState: STATE - private val _stateMutex = Mutex() - private val _stateFlow: MutableStateFlow by lazy { MutableStateFlow(initialState) } - val stateFlow: StateFlow by lazy { _stateFlow.asStateFlow() } - val state: STATE - get() = runBlocking { - _stateMutex.withLock { - _stateFlow.value - } - } - - protected suspend fun update(reduceFn: (old: STATE) -> STATE) { - _stateMutex.withLock { - _stateFlow.update(reduceFn) - } - } - - interface WithArgument { - - val argument: ARGUMENT - val argumentModel: ARGUMENT_MODEL - - } -} \ No newline at end of file diff --git a/toolkit/presentation/presentation/src/main/kotlin/tech/coner/trailer/toolkit/presentation/presenter/StatefulPresenter.kt b/toolkit/presentation/presentation/src/main/kotlin/tech/coner/trailer/toolkit/presentation/presenter/StatefulPresenter.kt new file mode 100644 index 000000000..93f7420f7 --- /dev/null +++ b/toolkit/presentation/presentation/src/main/kotlin/tech/coner/trailer/toolkit/presentation/presenter/StatefulPresenter.kt @@ -0,0 +1,9 @@ +package tech.coner.trailer.toolkit.presentation.presenter + +import kotlinx.coroutines.flow.StateFlow + +interface StatefulPresenter { + + val state: S + val stateFlow: StateFlow +} \ No newline at end of file diff --git a/toolkit/presentation/presentation/src/main/kotlin/tech/coner/trailer/toolkit/presentation/state/ItemModelState.kt b/toolkit/presentation/presentation/src/main/kotlin/tech/coner/trailer/toolkit/presentation/state/ItemModelState.kt new file mode 100644 index 000000000..ba9966cd6 --- /dev/null +++ b/toolkit/presentation/presentation/src/main/kotlin/tech/coner/trailer/toolkit/presentation/state/ItemModelState.kt @@ -0,0 +1,52 @@ +package tech.coner.trailer.toolkit.presentation.state + +import kotlin.reflect.KProperty1 +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import tech.coner.trailer.toolkit.presentation.model.ItemModel +import tech.coner.trailer.toolkit.validation.Feedback +import tech.coner.trailer.toolkit.validation.ValidationOutcome + +interface ItemModelState> : State { + val itemModel: ItemModel +} + +class MutableItemModelPropertyDelegate +, ITEM, FEEDBACK : Feedback, PROPERTY>( + private val stateContainer: StateContainer, + private val getFn: ITEM.() -> PROPERTY, + private val updateFn: ITEM.(PROPERTY) -> ITEM, + private val property: KProperty1 +) { + var value: PROPERTY + get() = stateContainer.state.itemModel.pendingItem.let(getFn) + set(value) { + stateContainer.state.itemModel.updatePendingItem { updateFn(it, value) } + } + val valueFlow: Flow = stateContainer.stateFlow + .flatMapLatest { it.itemModel.pendingItemFlow } + .map(getFn) + + val feedback: List + get() = stateContainer.state.itemModel.pendingItemValidation.feedbackByProperty[property] + ?: emptyList() + val feedbackFlow: Flow> = stateContainer.stateFlow + .flatMapLatest { + flow { + emit(ValidationOutcome(emptyList())) + it.itemModel.pendingItemValidationFlow.collect(this) + } + } + .map { it.feedbackByProperty[property] ?: emptyList() } +} + +fun , ITEM, FEEDBACK : Feedback, PROPERTY> + StateContainer.mutableItemModelProperty( + getFn: ITEM.() -> PROPERTY, + updateFn: ITEM.(PROPERTY) -> ITEM, + property: KProperty1 +): MutableItemModelPropertyDelegate { + return MutableItemModelPropertyDelegate(this, getFn, updateFn, property) +} diff --git a/toolkit/presentation/presentation/src/main/kotlin/tech/coner/trailer/toolkit/presentation/state/LoadableItemState.kt b/toolkit/presentation/presentation/src/main/kotlin/tech/coner/trailer/toolkit/presentation/state/LoadableItemState.kt deleted file mode 100644 index f9a9b8b81..000000000 --- a/toolkit/presentation/presentation/src/main/kotlin/tech/coner/trailer/toolkit/presentation/state/LoadableItemState.kt +++ /dev/null @@ -1,9 +0,0 @@ -package tech.coner.trailer.toolkit.presentation.state - -import tech.coner.trailer.toolkit.presentation.model.ItemModel -import tech.coner.trailer.toolkit.presentation.model.LoadableModel -import tech.coner.trailer.toolkit.validation.Feedback - -data class LoadableItemState, FEEDBACK : Feedback>( - val loadable: LoadableModel -) : State diff --git a/toolkit/presentation/presentation/src/main/kotlin/tech/coner/trailer/toolkit/presentation/state/LoadableState.kt b/toolkit/presentation/presentation/src/main/kotlin/tech/coner/trailer/toolkit/presentation/state/LoadableState.kt new file mode 100644 index 000000000..050e370b1 --- /dev/null +++ b/toolkit/presentation/presentation/src/main/kotlin/tech/coner/trailer/toolkit/presentation/state/LoadableState.kt @@ -0,0 +1,56 @@ +package tech.coner.trailer.toolkit.presentation.state + +import kotlinx.coroutines.flow.* +import tech.coner.trailer.toolkit.presentation.model.ItemModel +import tech.coner.trailer.toolkit.presentation.model.Loadable +import tech.coner.trailer.toolkit.presentation.model.letLoadedSuccess +import tech.coner.trailer.toolkit.presentation.model.whenLoadedSuccess +import tech.coner.trailer.toolkit.validation.Feedback +import kotlin.reflect.KProperty1 + +interface LoadableState, FEEDBACK : Feedback>: State { + val loadable: Loadable +} + +class MutableLoadedPropertyDelegate + , FAILURE, ITEM, ITEM_MODEL : ItemModel, FEEDBACK : Feedback, PROPERTY>( + private val stateContainer: StateContainer, + private val getFn: ITEM.() -> PROPERTY, + private val updateFn: ITEM.(PROPERTY) -> ITEM, + private val property: KProperty1 +) { + var value: PROPERTY + get() = stateContainer.state.loadable.letLoadedSuccess { it.pendingItem?.let(getFn) } + // let's see if this causes race conditions in the sample app + // might need to add an extra property for null->default value + ?: throw IllegalStateException("Accessing successfully loaded state but not successfully loaded") + set(value) { + stateContainer.state.loadable.whenLoadedSuccess { loaded -> + loaded.updatePendingItem { updateFn(it, value) } + } + } + val valueFlow: Flow = stateContainer.stateFlow + .map { if (it.loadable is Loadable.Loaded) it.loadable as Loadable.Loaded else null } + .flatMapLatest { it?.either?.getOrNull()?.pendingItemFlow ?: flowOf(null) } + .map { it?.let(getFn) } + + val feedback: List + get() = stateContainer.state.loadable.letLoadedSuccess { it.pendingItemValidation.feedbackByProperty[property] } + ?: emptyList() + val feedbackFlow: Flow> = stateContainer.stateFlow + .map { if (it.loadable is Loadable.Loaded) it.loadable as Loadable.Loaded else null } + .flatMapLatest { + it?.letLoadedSuccess { itemModel -> flowOf(itemModel.pendingItemValidation.feedbackByProperty[property]) } + ?: flowOf(emptyList()) + } + .map { it ?: emptyList() } +} + +fun , FAILURE, ITEM, ITEM_MODEL : ItemModel, FEEDBACK : Feedback, PROPERTY> + StateContainer.mutableLoadedProperty( + getFn: ITEM.() -> PROPERTY, + updateFn: ITEM.(PROPERTY) -> ITEM, + property: KProperty1 +): MutableLoadedPropertyDelegate { + return MutableLoadedPropertyDelegate(this, getFn, updateFn, property) +} diff --git a/toolkit/presentation/presentation/src/main/kotlin/tech/coner/trailer/toolkit/presentation/state/StateContainer.kt b/toolkit/presentation/presentation/src/main/kotlin/tech/coner/trailer/toolkit/presentation/state/StateContainer.kt new file mode 100644 index 000000000..8fb0bfb4d --- /dev/null +++ b/toolkit/presentation/presentation/src/main/kotlin/tech/coner/trailer/toolkit/presentation/state/StateContainer.kt @@ -0,0 +1,84 @@ +package tech.coner.trailer.toolkit.presentation.state + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import tech.coner.trailer.toolkit.presentation.state.StateContainer.ReducedStateInspector + +class StateContainer( + val initialState: STATE, + private val isReducedStateLegal: ReducedStateInspector = ReducedStateInspector { _, _ -> true } +) { + + private val _stateMutex = Mutex() + private val _stateFlow: MutableStateFlow by lazy { MutableStateFlow(initialState) } + val stateFlow: StateFlow by lazy { _stateFlow.asStateFlow() } + val state: STATE + get() = runBlocking { + _stateMutex.withLock { + _stateFlow.value + } + } + + suspend fun update(reduceFn: (old: STATE) -> STATE) { + _stateMutex.withLock { + val oldState = _stateFlow.value + val reducedState = reduceFn(oldState) + check(isReducedStateLegal(oldState = oldState, reducedState = reducedState)) + _stateFlow.emit(reducedState) + } + } + + /** + * Inspects reduced state for legality. + * + * StateContainer will invoke this interface with the old and reduced state when updating to check it for legality. + * + * Use this as a last-ditch safety measure to prevent your app entering an illegal state. Be prepared to handle + * the IllegalStateException or else your app may crash. + * + * This is not the place to perform expensive validations. This will be invoked on every call to + * `StateContainer.update` with a mutex lock. Keep it simple, light, fast, and safe. + */ + fun interface ReducedStateInspector { + + operator fun invoke(oldState: STATE, reducedState: STATE): Boolean + } + + open inner class StatePropertyContainer( + private val getValue: STATE.() -> PROPERTY + ) { + val immutableValue: PROPERTY + get() = state.getValue() + val flow: Flow = stateFlow.map(getValue) + } + + fun stateProperty( + getValue: STATE.() -> PROPERTY + ): StatePropertyContainer { + return StatePropertyContainer(getValue) + } + + + inner class MutableStatePropertyContainer( + getValue: STATE.() -> PROPERTY, + private val updateState: STATE.(PROPERTY) -> STATE + ) : StatePropertyContainer(getValue) { + + var value: PROPERTY + get() = immutableValue + set(value) { runBlocking { update { it.updateState(value) } } } + } + + fun mutableStateProperty( + getValue: STATE.() -> PROPERTY, + updateState: STATE.(newValue: PROPERTY) -> STATE + ): MutableStatePropertyContainer { + return MutableStatePropertyContainer(getValue, updateState) + } +} \ No newline at end of file diff --git a/toolkit/samples/dmvapp/dmvapp-cli/pom.xml b/toolkit/samples/dmvapp/dmvapp-cli/pom.xml index d41b54f06..ddf59acf6 100644 --- a/toolkit/samples/dmvapp/dmvapp-cli/pom.xml +++ b/toolkit/samples/dmvapp/dmvapp-cli/pom.xml @@ -18,11 +18,6 @@ toolkit-sample-dmvapp-common 0.1.0-SNAPSHOT - - tech.coner.trailer - toolkit-util - 0.1.0-SNAPSHOT - com.github.ajalt.clikt clikt-jvm diff --git a/toolkit/samples/dmvapp/dmvapp-cli/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/cli/DmvAppCli.kt b/toolkit/samples/dmvapp/dmvapp-cli/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/cli/DmvAppCli.kt index 74f5c392c..afc9b8248 100644 --- a/toolkit/samples/dmvapp/dmvapp-cli/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/cli/DmvAppCli.kt +++ b/toolkit/samples/dmvapp/dmvapp-cli/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/cli/DmvAppCli.kt @@ -7,6 +7,7 @@ import tech.coner.trailer.toolkit.sample.dmvapp.cli.command.cliCommandModule import tech.coner.trailer.toolkit.sample.dmvapp.cli.view.cliViewModule import tech.coner.trailer.toolkit.sample.dmvapp.domain.service.impl.domainServiceModule import tech.coner.trailer.toolkit.sample.dmvapp.domain.validation.domainValidationModule +import tech.coner.trailer.toolkit.sample.dmvapp.presentation.adapter.presentationAdapterModule import tech.coner.trailer.toolkit.sample.dmvapp.presentation.localization.presentationLocalizationModule import tech.coner.trailer.toolkit.sample.dmvapp.presentation.presenter.presentationPresenterModule import tech.coner.trailer.toolkit.sample.dmvapp.presentation.validation.presentationValidationModule @@ -24,6 +25,7 @@ private val di = DI { domainValidationModule, presentationLocalizationModule, presentationPresenterModule, + presentationAdapterModule, presentationValidationModule ) } \ No newline at end of file diff --git a/toolkit/samples/dmvapp/dmvapp-cli/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/cli/command/DriversLicenseApplicationCommand.kt b/toolkit/samples/dmvapp/dmvapp-cli/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/cli/command/DriversLicenseApplicationCommand.kt index 4f069b78a..684943b19 100644 --- a/toolkit/samples/dmvapp/dmvapp-cli/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/cli/command/DriversLicenseApplicationCommand.kt +++ b/toolkit/samples/dmvapp/dmvapp-cli/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/cli/command/DriversLicenseApplicationCommand.kt @@ -1,28 +1,39 @@ package tech.coner.trailer.toolkit.sample.dmvapp.cli.command -import com.github.ajalt.clikt.core.Abort import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.core.terminal import com.github.ajalt.clikt.parameters.options.option import com.github.ajalt.clikt.parameters.options.required import com.github.ajalt.clikt.parameters.types.choice +import com.github.ajalt.clikt.parameters.types.float import com.github.ajalt.clikt.parameters.types.int +import kotlin.random.Random +import kotlin.time.Duration.Companion.milliseconds import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import tech.coner.trailer.toolkit.dashify import tech.coner.trailer.toolkit.sample.dmvapp.cli.view.DriversLicenseView +import tech.coner.trailer.toolkit.sample.dmvapp.cli.view.ValidationResultView import tech.coner.trailer.toolkit.sample.dmvapp.domain.entity.LicenseType -import tech.coner.trailer.toolkit.sample.dmvapp.presentation.localization.Localization +import tech.coner.trailer.toolkit.sample.dmvapp.domain.service.impl.DriversLicenseApplicationServiceImpl +import tech.coner.trailer.toolkit.sample.dmvapp.presentation.localization.Strings import tech.coner.trailer.toolkit.sample.dmvapp.presentation.model.DriversLicenseApplicationModel +import tech.coner.trailer.toolkit.sample.dmvapp.presentation.model.DriversLicenseApplicationRejectionModel import tech.coner.trailer.toolkit.sample.dmvapp.presentation.presenter.DriversLicenseApplicationPresenter import tech.coner.trailer.toolkit.sample.dmvapp.presentation.validation.DriversLicenseApplicationModelFeedback -import tech.coner.trailer.toolkit.util.dashify -import tech.coner.trailer.toolkit.validation.ValidationResult +import tech.coner.trailer.toolkit.validation.ValidationOutcome class DriversLicenseApplicationCommand( private val presenter: DriversLicenseApplicationPresenter, - private val localization: Localization, + private val strings: Strings, private val driversLicenseView: DriversLicenseView, + private val service: DriversLicenseApplicationServiceImpl, ) : CliktCommand( name = "drivers-license-application" ), CoroutineScope by CoroutineScope(Dispatchers.Default + CoroutineName("DriversLicenseApplicationCommand")) { @@ -33,32 +44,62 @@ class DriversLicenseApplicationCommand( .int() .required() private val licenseType: LicenseType by option() - .choice(localization.licenseTypeLabels.associate { it.second.dashify() to it.first }) + .choice(strings.licenseTypeLabels.associate { it.second.dashify() to it.first }) .required() - override fun run() { - val model = presenter.state.model - model.name = name - model.age = age - model.licenseType = licenseType - model.commit() - .onFailure { - it.feedback.echo() - throw Abort() - } - runBlocking { presenter.processApplication() } - ?.also { - echo(localization.driversLicenseGranted) - echo(driversLicenseView(it.driversLicense!!)) + private val buildingOnFireChance: Float? by option() + .float() + private val sassChance: Float? by option() + .float() + private val legallyProhibitedChance: Float? by option() + .float() + + override fun run(): Unit = runBlocking { + presenter.name.value = name + presenter.age.value = age + presenter.licenseType.value = licenseType + + buildingOnFireChance?.also { service.buildingOnFireChance = it } + sassChance?.also { service.sassChance = it } + legallyProhibitedChance?.also { service.legallyProhibitedChance = it } + + val processing = async { + val progress = launch { + echo("Processing...", trailingNewline = false) + while (isActive) { + echo('.', trailingNewline = false) + delay(Random.nextInt(100, 1000).milliseconds) + } } - } + presenter.submitApplication() + .also { progress.cancel() } + } - private fun ValidationResult.echo() { - if (feedback.isEmpty()) return - feedback - .map { (_, feedbacks) -> - feedbacks.joinToString(separator = System.lineSeparator()) { "[${it.severity.name.lowercase()}]: ${localization.label(it)}" } + processing.await().getOrThrow() + .also { echo() } + .onLeft { rejection -> + when (rejection) { + is DriversLicenseApplicationRejectionModel.Invalid -> rejection.validationOutcome.widget() + is DriversLicenseApplicationRejectionModel.Sassed -> strings[rejection.sass] + is DriversLicenseApplicationRejectionModel.LegallyProhibited -> strings.driversLicenseApplicationRejectionLegallyProhibited + } + .also { echo(terminal.render(it), err = true) } + } + .onRight { + echo(strings.driversLicenseGranted) + echo(driversLicenseView(it)) } - .forEach { echo(it, err = true) } } + + private fun ValidationOutcome.widget() = + ValidationResultView( + terminal = terminal, + strings = strings, + fieldStringsMap = mapOf( + DriversLicenseApplicationModel::name to strings.driversLicenseNameField, + DriversLicenseApplicationModel::age to strings.driversLicenseAgeField, + DriversLicenseApplicationModel::licenseType to strings.driversLicenseLicenseTypeField, + ), + messageFn = { strings[it] } + ).invoke(this) } \ No newline at end of file diff --git a/toolkit/samples/dmvapp/dmvapp-cli/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/cli/view/DriversLicenseView.kt b/toolkit/samples/dmvapp/dmvapp-cli/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/cli/view/DriversLicenseView.kt index 248552d4c..3c768afb5 100644 --- a/toolkit/samples/dmvapp/dmvapp-cli/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/cli/view/DriversLicenseView.kt +++ b/toolkit/samples/dmvapp/dmvapp-cli/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/cli/view/DriversLicenseView.kt @@ -5,26 +5,26 @@ import com.github.ajalt.mordant.rendering.VerticalAlign import com.github.ajalt.mordant.table.Borders import com.github.ajalt.mordant.table.table import tech.coner.trailer.toolkit.sample.dmvapp.domain.entity.DriversLicense -import tech.coner.trailer.toolkit.sample.dmvapp.presentation.localization.Localization +import tech.coner.trailer.toolkit.sample.dmvapp.presentation.localization.Strings class DriversLicenseView( - private val localization: Localization + private val strings: Strings ) { operator fun invoke(driversLicense: DriversLicense) = table { header { row { - cell(localization.dmvLabel) { + cell(strings.dmvLabel) { cellBorders = Borders.LEFT_TOP columnSpan = 2 } - cell(localization.driversLicenseHeading) { + cell(strings.driversLicenseHeading) { align = TextAlign.RIGHT cellBorders = Borders.TOP_RIGHT } } row { - cell(localization.dmvMotto) { + cell(strings.dmvMotto) { cellBorders = Borders.LEFT_RIGHT_BOTTOM columnSpan = 3 } @@ -32,18 +32,18 @@ class DriversLicenseView( } body { row { - cell(localization.driversLicensePhotoPlaceholder) { + cell(strings.driversLicensePhotoPlaceholder) { rowSpan = 3 align = TextAlign.CENTER verticalAlign = VerticalAlign.MIDDLE } - cells(localization.driversLicenseNameField, driversLicense.name) + cells(strings.driversLicenseNameField, driversLicense.name) } row { - cells(localization.driversLicenseAgeWhenAppliedField, driversLicense.ageWhenApplied) + cells(strings.driversLicenseAgeField, driversLicense.age) } row { - cells(localization.driversLicenseLicenseTypeField, localization.licenseTypesByObject[driversLicense.licenseType]) + cells(strings.driversLicenseLicenseTypeField, strings.licenseTypesByObject[driversLicense.licenseType]) } } } diff --git a/toolkit/samples/dmvapp/dmvapp-cli/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/cli/view/ValidationResultView.kt b/toolkit/samples/dmvapp/dmvapp-cli/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/cli/view/ValidationResultView.kt new file mode 100644 index 000000000..b93930e3c --- /dev/null +++ b/toolkit/samples/dmvapp/dmvapp-cli/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/cli/view/ValidationResultView.kt @@ -0,0 +1,49 @@ +package tech.coner.trailer.toolkit.sample.dmvapp.cli.view + +import com.github.ajalt.mordant.rendering.TextColors +import com.github.ajalt.mordant.table.Table +import com.github.ajalt.mordant.table.table +import com.github.ajalt.mordant.terminal.Terminal +import tech.coner.trailer.toolkit.sample.dmvapp.presentation.localization.Strings +import tech.coner.trailer.toolkit.validation.Feedback +import tech.coner.trailer.toolkit.validation.Severity +import tech.coner.trailer.toolkit.validation.ValidationOutcome +import kotlin.reflect.KProperty1 + +class ValidationResultView> ( + private val terminal: Terminal, + private val strings: Strings, + private val fieldStringsMap: Map, String>, + private val messageFn: (FEEDBACK) -> String, +) { + operator fun invoke( + validationOutcome: ValidationOutcome, + ): Table { + return table { + body { + validationOutcome.feedbackByProperty.forEach { (property, feedbacks) -> + feedbacks.forEachIndexed { index, feedback -> + row { + if (index == 0) { + cell(fieldStringsMap[property]) { + columnSpan = feedbacks.size + } + } + cell(strings.validation[feedback.severity]) { + style( + color = when (feedback.severity) { + Severity.Error -> TextColors.red + Severity.Warning -> TextColors.yellow + Severity.Success -> TextColors.green + Severity.Info -> TextColors.blue + } + ) + } + cell(messageFn(feedback)) + } + } + } + } + } + } +} \ No newline at end of file diff --git a/toolkit/samples/dmvapp/dmvapp-common/pom.xml b/toolkit/samples/dmvapp/dmvapp-common/pom.xml index cdbc2b07f..243eef151 100644 --- a/toolkit/samples/dmvapp/dmvapp-common/pom.xml +++ b/toolkit/samples/dmvapp/dmvapp-common/pom.xml @@ -12,4 +12,12 @@ toolkit-sample-dmvapp-common + + + tech.coner.trailer + assertk-arrowkt + 0.1.0-SNAPSHOT + + + \ No newline at end of file diff --git a/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech.coner.trailer.toolkit.sample.dmvapp/domain/entity/DriversLicense.kt b/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech.coner.trailer.toolkit.sample.dmvapp/domain/entity/DriversLicense.kt deleted file mode 100644 index c6934a177..000000000 --- a/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech.coner.trailer.toolkit.sample.dmvapp/domain/entity/DriversLicense.kt +++ /dev/null @@ -1,7 +0,0 @@ -package tech.coner.trailer.toolkit.sample.dmvapp.domain.entity - -data class DriversLicense( - val name: String, - val ageWhenApplied: Int, - val licenseType: tech.coner.trailer.toolkit.sample.dmvapp.domain.entity.LicenseType -) \ No newline at end of file diff --git a/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech.coner.trailer.toolkit.sample.dmvapp/domain/entity/DriversLicenseApplication.kt b/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech.coner.trailer.toolkit.sample.dmvapp/domain/entity/DriversLicenseApplication.kt deleted file mode 100644 index 8b3024ebe..000000000 --- a/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech.coner.trailer.toolkit.sample.dmvapp/domain/entity/DriversLicenseApplication.kt +++ /dev/null @@ -1,16 +0,0 @@ -package tech.coner.trailer.toolkit.sample.dmvapp.domain.entity - -import kotlin.reflect.KProperty1 -import tech.coner.trailer.toolkit.sample.dmvapp.domain.validation.DriversLicenseApplicationFeedback - -data class DriversLicenseApplication( - val name: String, - val age: Int, - val licenseType: tech.coner.trailer.toolkit.sample.dmvapp.domain.entity.LicenseType, -) { - - data class Outcome( - val driversLicense: tech.coner.trailer.toolkit.sample.dmvapp.domain.entity.DriversLicense?, - val feedback: Map?, List> - ) -} diff --git a/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech.coner.trailer.toolkit.sample.dmvapp/domain/entity/DriversLicenseApplicationOutcome.kt b/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech.coner.trailer.toolkit.sample.dmvapp/domain/entity/DriversLicenseApplicationOutcome.kt deleted file mode 100644 index 8f95e8b8e..000000000 --- a/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech.coner.trailer.toolkit.sample.dmvapp/domain/entity/DriversLicenseApplicationOutcome.kt +++ /dev/null @@ -1,6 +0,0 @@ -package tech.coner.trailer.toolkit.sample.dmvapp.domain.entity - -class DriversLicenseApplicationOutcome( - -) { -} \ No newline at end of file diff --git a/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech.coner.trailer.toolkit.sample.dmvapp/domain/entity/LicenseType.kt b/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech.coner.trailer.toolkit.sample.dmvapp/domain/entity/LicenseType.kt deleted file mode 100644 index 8eed554fb..000000000 --- a/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech.coner.trailer.toolkit.sample.dmvapp/domain/entity/LicenseType.kt +++ /dev/null @@ -1,15 +0,0 @@ -package tech.coner.trailer.toolkit.sample.dmvapp.domain.entity - -sealed class LicenseType { - data object GraduatedLearnerPermit : tech.coner.trailer.toolkit.sample.dmvapp.domain.entity.LicenseType() { - const val MIN_AGE = 15 - const val MAX_AGE_INCLUSIVE = 17 - val AGE_RANGE = tech.coner.trailer.toolkit.sample.dmvapp.domain.entity.LicenseType.GraduatedLearnerPermit.MIN_AGE..tech.coner.trailer.toolkit.sample.dmvapp.domain.entity.LicenseType.GraduatedLearnerPermit.MAX_AGE_INCLUSIVE - } - data object LearnerPermit : tech.coner.trailer.toolkit.sample.dmvapp.domain.entity.LicenseType() { - const val MIN_AGE = 18 - } - data object FullLicense : tech.coner.trailer.toolkit.sample.dmvapp.domain.entity.LicenseType() { - const val MIN_AGE = 18 - } -} \ No newline at end of file diff --git a/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech.coner.trailer.toolkit.sample.dmvapp/domain/service/DriversLicenseApplicationService.kt b/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech.coner.trailer.toolkit.sample.dmvapp/domain/service/DriversLicenseApplicationService.kt deleted file mode 100644 index e0dbdf492..000000000 --- a/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech.coner.trailer.toolkit.sample.dmvapp/domain/service/DriversLicenseApplicationService.kt +++ /dev/null @@ -1,9 +0,0 @@ -package tech.coner.trailer.toolkit.sample.dmvapp.domain.service - -import tech.coner.trailer.toolkit.sample.dmvapp.domain.entity.DriversLicenseApplication - -interface DriversLicenseApplicationService { - - suspend fun process(application: DriversLicenseApplication): DriversLicenseApplication.Outcome - -} \ No newline at end of file diff --git a/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech.coner.trailer.toolkit.sample.dmvapp/domain/service/impl/DriversLicenseApplicationServiceImpl.kt b/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech.coner.trailer.toolkit.sample.dmvapp/domain/service/impl/DriversLicenseApplicationServiceImpl.kt deleted file mode 100644 index 7c2df64a9..000000000 --- a/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech.coner.trailer.toolkit.sample.dmvapp/domain/service/impl/DriversLicenseApplicationServiceImpl.kt +++ /dev/null @@ -1,31 +0,0 @@ -package tech.coner.trailer.toolkit.sample.dmvapp.domain.service.impl - -import kotlin.time.Duration.Companion.seconds -import kotlinx.coroutines.delay -import tech.coner.trailer.toolkit.sample.dmvapp.domain.entity.DriversLicense -import tech.coner.trailer.toolkit.sample.dmvapp.domain.entity.DriversLicenseApplication -import tech.coner.trailer.toolkit.sample.dmvapp.domain.service.DriversLicenseApplicationService -import tech.coner.trailer.toolkit.sample.dmvapp.domain.validation.DriversLicenseClerk -import tech.coner.trailer.toolkit.validation.invoke - -internal class DriversLicenseApplicationServiceImpl( - private val clerk: DriversLicenseClerk -) : DriversLicenseApplicationService { - - override suspend fun process(application: DriversLicenseApplication): DriversLicenseApplication.Outcome { - return clerk(application) - .also { if (it.isInvalid) delay(3.seconds) } - .let { - DriversLicenseApplication.Outcome( - driversLicense = it.whenValid { - DriversLicense( - name = application.name, - ageWhenApplied = application.age, - licenseType = application.licenseType - ) - }, - feedback = it.feedback - ) - } - } -} \ No newline at end of file diff --git a/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech.coner.trailer.toolkit.sample.dmvapp/presentation/adapter/PresentationModelToDomainEntityAdapters.kt b/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech.coner.trailer.toolkit.sample.dmvapp/presentation/adapter/PresentationModelToDomainEntityAdapters.kt deleted file mode 100644 index 8e665c734..000000000 --- a/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech.coner.trailer.toolkit.sample.dmvapp/presentation/adapter/PresentationModelToDomainEntityAdapters.kt +++ /dev/null @@ -1,15 +0,0 @@ -package tech.coner.trailer.toolkit.sample.dmvapp.presentation.adapter - -import tech.coner.trailer.toolkit.sample.dmvapp.domain.entity.DriversLicenseApplication -import tech.coner.trailer.toolkit.sample.dmvapp.presentation.model.DriversLicenseApplicationModel - -fun DriversLicenseApplicationModel.toDomainEntity(): DriversLicenseApplication? { - return when { - name != null && age != null && licenseType != null -> DriversLicenseApplication( - name = name, - age = age, - licenseType = licenseType - ) - else -> null - } -} \ No newline at end of file diff --git a/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech.coner.trailer.toolkit.sample.dmvapp/presentation/localization/EnglishUsLocalization.kt b/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech.coner.trailer.toolkit.sample.dmvapp/presentation/localization/EnglishUsLocalization.kt deleted file mode 100644 index 6f1b7dc94..000000000 --- a/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech.coner.trailer.toolkit.sample.dmvapp/presentation/localization/EnglishUsLocalization.kt +++ /dev/null @@ -1,66 +0,0 @@ -package tech.coner.trailer.toolkit.sample.dmvapp.presentation.localization - -import tech.coner.trailer.toolkit.sample.dmvapp.domain.entity.LicenseType.FullLicense -import tech.coner.trailer.toolkit.sample.dmvapp.domain.entity.LicenseType.GraduatedLearnerPermit -import tech.coner.trailer.toolkit.sample.dmvapp.domain.entity.LicenseType.LearnerPermit -import tech.coner.trailer.toolkit.sample.dmvapp.domain.validation.DriversLicenseApplicationFeedback -import tech.coner.trailer.toolkit.sample.dmvapp.presentation.validation.DriversLicenseApplicationModelFeedback -import tech.coner.trailer.toolkit.sample.dmvapp.presentation.validation.DriversLicenseApplicationModelFeedback.AgeIsRequired -import tech.coner.trailer.toolkit.sample.dmvapp.presentation.validation.DriversLicenseApplicationModelFeedback.DelegatedFeedback -import tech.coner.trailer.toolkit.sample.dmvapp.presentation.validation.DriversLicenseApplicationModelFeedback.LicenseTypeIsRequired -import tech.coner.trailer.toolkit.sample.dmvapp.presentation.validation.DriversLicenseApplicationModelFeedback.NameIsRequired - -class EnglishUsLocalization : Localization { - - override val dmvLabel: String get() = "Coner Trailer DMV" - override val dmvMotto: String get() = "Officiale sine auctoritate" - override val licenseTypeLabels = listOf( - GraduatedLearnerPermit to "Graduated Learner Permit" , - LearnerPermit to "Learner Permit", - FullLicense to "Full License" - ) - override val licenseTypesByObject = licenseTypeLabels.toMap() - - override fun label(model: DriversLicenseApplicationModelFeedback) = model.label - - private val DriversLicenseApplicationModelFeedback.label: String - get() = when (this) { - NameIsRequired -> "Name is required" - AgeIsRequired -> "Age is required" - LicenseTypeIsRequired -> "License Type is required" - is DelegatedFeedback -> feedback.label - } - - private val DriversLicenseApplicationFeedback.label: String - get() = when (this) { - DriversLicenseApplicationFeedback.NameMustNotBeBlank -> "Name must not be blank" - is DriversLicenseApplicationFeedback.TooYoung -> { - val suggestion = when { - reapplyWhenAge != null && suggestOtherLicenseType != null -> "Apply for ${licenseTypesByObject[suggestOtherLicenseType]} when you turn $reapplyWhenAge." - reapplyWhenAge != null -> "Reapply when you turn $reapplyWhenAge." - suggestOtherLicenseType != null -> "Reapply for ${licenseTypesByObject[suggestOtherLicenseType]}." - else -> null - } - val tooYoung = "You are too young." - when { - suggestion != null -> "$tooYoung $suggestion" - else -> tooYoung - } - } - is DriversLicenseApplicationFeedback.TooOld -> { - val tooOld = "You are too old." - when { - suggestOtherLicenseType != null -> "$tooOld Reapply for ${licenseTypesByObject[suggestOtherLicenseType]}." - else -> tooOld - } - } - } - - override val driversLicenseGranted: String get() = "Granted drivers license!" - override val driversLicenseHeading: String get() = "Drivers License" - override val driversLicensePhotoPlaceholder: String get() = "[photo]" - override val driversLicenseNameField: String get() = "Name" - override val driversLicenseAgeWhenAppliedField: String get() = "Age" - override val driversLicenseLicenseTypeField: String get() = "License Type" - -} \ No newline at end of file diff --git a/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech.coner.trailer.toolkit.sample.dmvapp/presentation/localization/Localization.kt b/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech.coner.trailer.toolkit.sample.dmvapp/presentation/localization/Localization.kt deleted file mode 100644 index 45654db6f..000000000 --- a/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech.coner.trailer.toolkit.sample.dmvapp/presentation/localization/Localization.kt +++ /dev/null @@ -1,19 +0,0 @@ -package tech.coner.trailer.toolkit.sample.dmvapp.presentation.localization - -import tech.coner.trailer.toolkit.sample.dmvapp.domain.entity.LicenseType -import tech.coner.trailer.toolkit.sample.dmvapp.presentation.validation.DriversLicenseApplicationModelFeedback - -interface Localization { - - fun label(model: DriversLicenseApplicationModelFeedback): String - val dmvLabel: String - val dmvMotto: String - val licenseTypeLabels: List> - val licenseTypesByObject: Map - val driversLicenseGranted: String - val driversLicenseHeading: String - val driversLicensePhotoPlaceholder: String - val driversLicenseNameField: String - val driversLicenseAgeWhenAppliedField: String - val driversLicenseLicenseTypeField: String -} \ No newline at end of file diff --git a/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech.coner.trailer.toolkit.sample.dmvapp/presentation/presenter/DriversLicenseApplicationPresenter.kt b/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech.coner.trailer.toolkit.sample.dmvapp/presentation/presenter/DriversLicenseApplicationPresenter.kt deleted file mode 100644 index d5a4d2351..000000000 --- a/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech.coner.trailer.toolkit.sample.dmvapp/presentation/presenter/DriversLicenseApplicationPresenter.kt +++ /dev/null @@ -1,34 +0,0 @@ -package tech.coner.trailer.toolkit.sample.dmvapp.presentation.presenter - -import org.kodein.di.DI -import org.kodein.di.DIAware -import org.kodein.di.instance -import tech.coner.trailer.toolkit.presentation.presenter.SecondDraftPresenter -import tech.coner.trailer.toolkit.sample.dmvapp.domain.entity.DriversLicenseApplication -import tech.coner.trailer.toolkit.sample.dmvapp.domain.service.DriversLicenseApplicationService -import tech.coner.trailer.toolkit.sample.dmvapp.presentation.adapter.toDomainEntity -import tech.coner.trailer.toolkit.sample.dmvapp.presentation.model.DriversLicenseApplicationItemModel -import tech.coner.trailer.toolkit.sample.dmvapp.presentation.state.DriversLicenseApplicationState -import tech.coner.trailer.toolkit.sample.dmvapp.presentation.validation.DriversLicenseApplicationModelValidator - -class DriversLicenseApplicationPresenter( - override val di: DI, - initialState: DriversLicenseApplicationState? = null, -) : SecondDraftPresenter(), DIAware { - - private val service: DriversLicenseApplicationService by instance() - private val validator: DriversLicenseApplicationModelValidator by instance() - - override val initialState = initialState - ?: DriversLicenseApplicationState( - model = DriversLicenseApplicationItemModel( - validator = validator - ) - ) - - suspend fun processApplication(): DriversLicenseApplication.Outcome? { - return state.model.item.toDomainEntity() - ?.let { service.process(it) } - ?.also { outcome -> update { it.copy(outcome = outcome) } } - } -} \ No newline at end of file diff --git a/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech.coner.trailer.toolkit.sample.dmvapp/presentation/presenter/PresentationPresenterModule.kt b/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech.coner.trailer.toolkit.sample.dmvapp/presentation/presenter/PresentationPresenterModule.kt deleted file mode 100644 index 460a7e45c..000000000 --- a/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech.coner.trailer.toolkit.sample.dmvapp/presentation/presenter/PresentationPresenterModule.kt +++ /dev/null @@ -1,8 +0,0 @@ -package tech.coner.trailer.toolkit.sample.dmvapp.presentation.presenter - -import org.kodein.di.DI -import org.kodein.di.bindSingleton - -val presentationPresenterModule by DI.Module { - bindSingleton { DriversLicenseApplicationPresenter(di) } -} \ No newline at end of file diff --git a/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech.coner.trailer.toolkit.sample.dmvapp/presentation/state/DriversLicenseApplicationState.kt b/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech.coner.trailer.toolkit.sample.dmvapp/presentation/state/DriversLicenseApplicationState.kt deleted file mode 100644 index fe5a987ef..000000000 --- a/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech.coner.trailer.toolkit.sample.dmvapp/presentation/state/DriversLicenseApplicationState.kt +++ /dev/null @@ -1,10 +0,0 @@ -package tech.coner.trailer.toolkit.sample.dmvapp.presentation.state - -import tech.coner.trailer.toolkit.presentation.state.State -import tech.coner.trailer.toolkit.sample.dmvapp.domain.entity.DriversLicenseApplication -import tech.coner.trailer.toolkit.sample.dmvapp.presentation.model.DriversLicenseApplicationItemModel - -data class DriversLicenseApplicationState( - val model: DriversLicenseApplicationItemModel, - val outcome: DriversLicenseApplication.Outcome? = null -) : State diff --git a/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech.coner.trailer.toolkit.sample.dmvapp/presentation/validation/DriversLicenseApplicationModelFeedback.kt b/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech.coner.trailer.toolkit.sample.dmvapp/presentation/validation/DriversLicenseApplicationModelFeedback.kt deleted file mode 100644 index 18f05f13f..000000000 --- a/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech.coner.trailer.toolkit.sample.dmvapp/presentation/validation/DriversLicenseApplicationModelFeedback.kt +++ /dev/null @@ -1,26 +0,0 @@ -package tech.coner.trailer.toolkit.sample.dmvapp.presentation.validation - -import tech.coner.trailer.toolkit.sample.dmvapp.domain.validation.DriversLicenseApplicationFeedback -import tech.coner.trailer.toolkit.validation.Feedback -import tech.coner.trailer.toolkit.validation.Severity -import tech.coner.trailer.toolkit.validation.Severity.Error - -sealed class DriversLicenseApplicationModelFeedback : Feedback { - - data object NameIsRequired : DriversLicenseApplicationModelFeedback() { - override val severity = Error - } - data object AgeIsRequired : DriversLicenseApplicationModelFeedback() { - override val severity = Error - } - data object LicenseTypeIsRequired : DriversLicenseApplicationModelFeedback() { - override val severity = Error - } - - data class DelegatedFeedback( - val feedback: DriversLicenseApplicationFeedback - ) : DriversLicenseApplicationModelFeedback() { - override val severity: Severity - get() = feedback.severity - } -} diff --git a/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/domain/entity/DriversLicense.kt b/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/domain/entity/DriversLicense.kt new file mode 100644 index 000000000..757e48d1d --- /dev/null +++ b/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/domain/entity/DriversLicense.kt @@ -0,0 +1,7 @@ +package tech.coner.trailer.toolkit.sample.dmvapp.domain.entity + +data class DriversLicense( + val name: String, + val age: Int, + val licenseType: LicenseType +) \ No newline at end of file diff --git a/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/domain/entity/DriversLicenseApplication.kt b/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/domain/entity/DriversLicenseApplication.kt new file mode 100644 index 000000000..14d393386 --- /dev/null +++ b/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/domain/entity/DriversLicenseApplication.kt @@ -0,0 +1,7 @@ +package tech.coner.trailer.toolkit.sample.dmvapp.domain.entity + +data class DriversLicenseApplication( + val name: String, + val age: Int, + val licenseType: LicenseType, +) diff --git a/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/domain/entity/DriversLicenseApplicationRejection.kt b/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/domain/entity/DriversLicenseApplicationRejection.kt new file mode 100644 index 000000000..939aab1b5 --- /dev/null +++ b/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/domain/entity/DriversLicenseApplicationRejection.kt @@ -0,0 +1,17 @@ +package tech.coner.trailer.toolkit.sample.dmvapp.domain.entity + +import tech.coner.trailer.toolkit.sample.dmvapp.domain.validation.DriversLicenseApplicationFeedback +import tech.coner.trailer.toolkit.validation.ValidationOutcome + +sealed interface DriversLicenseApplicationRejection { + + data class Invalid( + val validationOutcome: ValidationOutcome + ) : DriversLicenseApplicationRejection + + data class Sassed( + val sass: Sass + ) : DriversLicenseApplicationRejection + + data object LegallyProhibited : DriversLicenseApplicationRejection +} \ No newline at end of file diff --git a/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/domain/entity/LicenseType.kt b/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/domain/entity/LicenseType.kt new file mode 100644 index 000000000..469d5a2df --- /dev/null +++ b/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/domain/entity/LicenseType.kt @@ -0,0 +1,15 @@ +package tech.coner.trailer.toolkit.sample.dmvapp.domain.entity + +sealed class LicenseType { + data object GraduatedLearnerPermit : LicenseType() { + const val MIN_AGE = 15 + const val MAX_AGE_INCLUSIVE = 17 + val AGE_RANGE = MIN_AGE..MAX_AGE_INCLUSIVE + } + data object LearnerPermit : LicenseType() { + const val MIN_AGE = 18 + } + data object FullLicense : LicenseType() { + const val MIN_AGE = 18 + } +} \ No newline at end of file diff --git a/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/domain/entity/Sass.kt b/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/domain/entity/Sass.kt new file mode 100644 index 000000000..bb20e2b3e --- /dev/null +++ b/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/domain/entity/Sass.kt @@ -0,0 +1,8 @@ +package tech.coner.trailer.toolkit.sample.dmvapp.domain.entity + +enum class Sass { + + LEAVING_FOR_BREAK, + LOOKS_AT_BREAK_SIGN_TAPS_READS_MAGAZINE, + MEAL_DELIVERY_JUST_ARRIVED, +} diff --git a/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/domain/exception/BuildingOnFireException.kt b/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/domain/exception/BuildingOnFireException.kt new file mode 100644 index 000000000..98f9d02cd --- /dev/null +++ b/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/domain/exception/BuildingOnFireException.kt @@ -0,0 +1,3 @@ +package tech.coner.trailer.toolkit.sample.dmvapp.domain.exception + +class BuildingOnFireException : Throwable() diff --git a/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/domain/exception/Throwable.kt b/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/domain/exception/Throwable.kt new file mode 100644 index 000000000..db2f21a03 --- /dev/null +++ b/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/domain/exception/Throwable.kt @@ -0,0 +1,8 @@ +package tech.coner.trailer.toolkit.sample.dmvapp.domain.exception + +import kotlin.Throwable + +abstract class Throwable( + message: String? = null, + cause: Throwable? = null +) : Throwable(message, cause) diff --git a/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/domain/service/DriversLicenseApplicationService.kt b/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/domain/service/DriversLicenseApplicationService.kt new file mode 100644 index 000000000..7ff85f35d --- /dev/null +++ b/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/domain/service/DriversLicenseApplicationService.kt @@ -0,0 +1,12 @@ +package tech.coner.trailer.toolkit.sample.dmvapp.domain.service + +import arrow.core.Either +import tech.coner.trailer.toolkit.sample.dmvapp.domain.entity.DriversLicense +import tech.coner.trailer.toolkit.sample.dmvapp.domain.entity.DriversLicenseApplication +import tech.coner.trailer.toolkit.sample.dmvapp.domain.entity.DriversLicenseApplicationRejection + +interface DriversLicenseApplicationService { + + suspend fun submit(application: DriversLicenseApplication): Result> + +} \ No newline at end of file diff --git a/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech.coner.trailer.toolkit.sample.dmvapp/domain/service/impl/DomainServiceModule.kt b/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/domain/service/impl/DomainServiceModule.kt similarity index 59% rename from toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech.coner.trailer.toolkit.sample.dmvapp/domain/service/impl/DomainServiceModule.kt rename to toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/domain/service/impl/DomainServiceModule.kt index ee7ae356d..864802d77 100644 --- a/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech.coner.trailer.toolkit.sample.dmvapp/domain/service/impl/DomainServiceModule.kt +++ b/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/domain/service/impl/DomainServiceModule.kt @@ -2,9 +2,11 @@ package tech.coner.trailer.toolkit.sample.dmvapp.domain.service.impl import org.kodein.di.DI import org.kodein.di.bindSingleton +import org.kodein.di.instance import org.kodein.di.new import tech.coner.trailer.toolkit.sample.dmvapp.domain.service.DriversLicenseApplicationService val domainServiceModule by DI.Module { - bindSingleton { new(::DriversLicenseApplicationServiceImpl) } + bindSingleton { new(::DriversLicenseApplicationServiceImpl) } + bindSingleton { instance() } } diff --git a/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/domain/service/impl/DriversLicenseApplicationServiceImpl.kt b/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/domain/service/impl/DriversLicenseApplicationServiceImpl.kt new file mode 100644 index 000000000..2e1e3a077 --- /dev/null +++ b/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/domain/service/impl/DriversLicenseApplicationServiceImpl.kt @@ -0,0 +1,73 @@ +package tech.coner.trailer.toolkit.sample.dmvapp.domain.service.impl + +import arrow.core.Either +import arrow.core.raise.either +import arrow.core.raise.ensure +import kotlin.random.Random +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import tech.coner.trailer.toolkit.sample.dmvapp.domain.entity.DriversLicense +import tech.coner.trailer.toolkit.sample.dmvapp.domain.entity.DriversLicenseApplication +import tech.coner.trailer.toolkit.sample.dmvapp.domain.entity.DriversLicenseApplicationRejection +import tech.coner.trailer.toolkit.sample.dmvapp.domain.entity.Sass +import tech.coner.trailer.toolkit.sample.dmvapp.domain.exception.BuildingOnFireException +import tech.coner.trailer.toolkit.sample.dmvapp.domain.service.DriversLicenseApplicationService +import tech.coner.trailer.toolkit.sample.dmvapp.domain.validation.DriversLicenseClerk +import tech.coner.trailer.toolkit.validation.invoke + +class DriversLicenseApplicationServiceImpl( + private val clerk: DriversLicenseClerk +) : DriversLicenseApplicationService { + + var buildingOnFireChance: Float = 0f + set(value) { + require(value in 0f..1f) + field = value + } + var sassChance: Float = 0f + set(value) { + require(value in 0f..1f) + field = value + } + var legallyProhibitedChance: Float = 0f + set(value) { + require(value in 0f..1f) + field = value + } + + override suspend fun submit(application: DriversLicenseApplication): Result> = coroutineScope { + runCatching { + either { + if (Random.nextFloat() <= buildingOnFireChance) { + throw BuildingOnFireException() + } + delay(Random.nextDouble(100.0, 10000.0).milliseconds) + ensure(Random.nextFloat() >= sassChance) { + DriversLicenseApplicationRejection.Sassed(Sass.values().random()) + } + clerk(application) + .also { if (it.isInvalid) delay(5.seconds) } + .also { + ensure(it.isValid) { + DriversLicenseApplicationRejection.Invalid(it) + } + } + .also { + ensure(Random.nextFloat() >= legallyProhibitedChance) { + DriversLicenseApplicationRejection.LegallyProhibited + } + } + .let { + DriversLicense( + name = application.name, + age = application.age, + licenseType = application.licenseType + ) + } + } + } + } + +} \ No newline at end of file diff --git a/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech.coner.trailer.toolkit.sample.dmvapp/domain/validation/DomainValidationModule.kt b/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/domain/validation/DomainValidationModule.kt similarity index 100% rename from toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech.coner.trailer.toolkit.sample.dmvapp/domain/validation/DomainValidationModule.kt rename to toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/domain/validation/DomainValidationModule.kt diff --git a/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech.coner.trailer.toolkit.sample.dmvapp/domain/validation/DriversLicenseApplicationFeedback.kt b/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/domain/validation/DriversLicenseApplicationFeedback.kt similarity index 66% rename from toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech.coner.trailer.toolkit.sample.dmvapp/domain/validation/DriversLicenseApplicationFeedback.kt rename to toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/domain/validation/DriversLicenseApplicationFeedback.kt index da51431ff..2105a38fd 100644 --- a/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech.coner.trailer.toolkit.sample.dmvapp/domain/validation/DriversLicenseApplicationFeedback.kt +++ b/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/domain/validation/DriversLicenseApplicationFeedback.kt @@ -1,23 +1,28 @@ package tech.coner.trailer.toolkit.sample.dmvapp.domain.validation +import tech.coner.trailer.toolkit.sample.dmvapp.domain.entity.DriversLicenseApplication import tech.coner.trailer.toolkit.sample.dmvapp.domain.entity.LicenseType import tech.coner.trailer.toolkit.validation.Feedback import tech.coner.trailer.toolkit.validation.Severity +import kotlin.reflect.KProperty1 -sealed class DriversLicenseApplicationFeedback : Feedback { +sealed class DriversLicenseApplicationFeedback : Feedback { data object NameMustNotBeBlank : DriversLicenseApplicationFeedback() { + override val property = DriversLicenseApplication::name override val severity = Severity.Error } data class TooYoung( val suggestOtherLicenseType: LicenseType? = null, val reapplyWhenAge: Int? = null ) : DriversLicenseApplicationFeedback() { + override val property = DriversLicenseApplication::age override val severity = Severity.Error } data class TooOld( val suggestOtherLicenseType: LicenseType? ) : DriversLicenseApplicationFeedback() { + override val property = DriversLicenseApplication::age override val severity = Severity.Error } } \ No newline at end of file diff --git a/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech.coner.trailer.toolkit.sample.dmvapp/domain/validation/DriversLicenseApplicationValidator.kt b/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/domain/validation/DriversLicenseApplicationValidator.kt similarity index 100% rename from toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech.coner.trailer.toolkit.sample.dmvapp/domain/validation/DriversLicenseApplicationValidator.kt rename to toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/domain/validation/DriversLicenseApplicationValidator.kt diff --git a/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/presentation/adapter/DriversLicenseApplicationModelAdapters.kt b/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/presentation/adapter/DriversLicenseApplicationModelAdapters.kt new file mode 100644 index 000000000..9d2dd5377 --- /dev/null +++ b/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/presentation/adapter/DriversLicenseApplicationModelAdapters.kt @@ -0,0 +1,30 @@ +package tech.coner.trailer.toolkit.sample.dmvapp.presentation.adapter + +import tech.coner.trailer.toolkit.sample.dmvapp.domain.entity.DriversLicenseApplication +import tech.coner.trailer.toolkit.sample.dmvapp.domain.validation.DriversLicenseApplicationFeedback +import tech.coner.trailer.toolkit.sample.dmvapp.presentation.model.DriversLicenseApplicationModel +import tech.coner.trailer.toolkit.sample.dmvapp.presentation.validation.DriversLicenseApplicationModelFeedback +import tech.coner.trailer.toolkit.sample.dmvapp.presentation.validation.DriversLicenseApplicationModelFeedback.DelegatedFeedback +import tech.coner.trailer.toolkit.validation.ValidationOutcome +import tech.coner.trailer.toolkit.validation.adapter.map + + +class DriversLicenseApplicationModelAdapters { + + /** + * Adapter function converts presentation model to domain entity + * + * The conversion can fail if the presentation model is invalid. Validate first. + */ + fun toDomainEntity(model: DriversLicenseApplicationModel): Result = runCatching { + DriversLicenseApplication( + name = model.name!!, + age = model.age!!, + licenseType = model.licenseType!! + ) + } + + val domainEntityValidationAdapter: (ValidationOutcome) -> ValidationOutcome = { + it.map { feedback -> DelegatedFeedback(feedback) } + } +} \ No newline at end of file diff --git a/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/presentation/adapter/PresentationAdapterModule.kt b/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/presentation/adapter/PresentationAdapterModule.kt new file mode 100644 index 000000000..aee9b483f --- /dev/null +++ b/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/presentation/adapter/PresentationAdapterModule.kt @@ -0,0 +1,9 @@ +package tech.coner.trailer.toolkit.sample.dmvapp.presentation.adapter + +import org.kodein.di.DI +import org.kodein.di.bindSingleton +import org.kodein.di.new + +val presentationAdapterModule by DI.Module { + bindSingleton { new(::DriversLicenseApplicationModelAdapters) } +} \ No newline at end of file diff --git a/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/presentation/localization/EnglishUsTranslation.kt b/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/presentation/localization/EnglishUsTranslation.kt new file mode 100644 index 000000000..5cb511a29 --- /dev/null +++ b/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/presentation/localization/EnglishUsTranslation.kt @@ -0,0 +1,63 @@ +package tech.coner.trailer.toolkit.sample.dmvapp.presentation.localization + +import tech.coner.trailer.toolkit.validation.presentation.localization.EnglishUsValidationTranslation +import tech.coner.trailer.toolkit.validation.presentation.localization.ValidationStringsImpl + +class EnglishUsTranslation() : Translation { + + override val conerLogoContentDescription: String get() = "Coner Logo" + override val dmvLabel: String get() = "Division of Motor Vehicles" + override val dmvMotto: String get() = "Officiale sine auctoritate" + + override val settings get() = "Settings" + override val menuContentDescription get() = "Menu" + override val settingsThemeTitle: String get() = "Theme" + override val settingsThemeModeTitle: String get() = "Mode" + override val settingsThemeModeAuto: String get() = "Auto" + override val settingsThemeModeLight: String get() = "Use Light Theme" + override val settingsThemeModeDark: String get() = "Use Dark Theme" + + override val driversLicenseGranted: String get() = "Granted drivers license!" + override val driversLicenseHeading: String get() = "Drivers License" + override val driversLicenseNameField: String get() = "Name" + override val driversLicenseNameFeedbackRequired get() = "Name is required" + override val driversLicenseNameFeedbackNotBlank get() = "Name must not be blank" + override val driversLicenseAgeField: String get() = "Age" + override val driversLicenseAgeFeedbackRequired get() = "Age is required" + override val driversLicenseAgeFeedbackTooYoung get() = "You are too young." + override val driversLicenseAgeFeedbackTooYoungSuggestOtherLicenseTypeWhenAgedFormat + get() = "Apply for %1\$s when you turn %2\$d" + override val driversLicenseAgeFeedbackTooYoungSuggestReapplyWhenAgedFormat + get() = "Reapply when you turn %1\$s." + override val driversLicenseAgeFeedbackTooYoungSuggestOtherLicenseTypeFormat + get() = "Reapply for %1\$s." + override val driversLicenseAgeFeedbackTooYoungSuggestionFormat + get() = "%1\$s %2\$s" + override val driversLicenseAgeFeedbackTooOld get() = "You are too old." + override val driversLicenseAgeFeedbackTooOldSuggestOtherLicenseTypeFormat: String + get() = "%1\$s Reapply for %2\$s." + override val driversLicenseLicenseTypeField: String get() = "License Type" + override val driversLicenseLicenseTypeGraduatedLearnerPermit get() = "Graduated Learner Permit" + override val driversLicenseLicenseTypeLearnerPermit get() = "Learner Permit" + override val driversLicenseLicenseTypeFullLicense get() = "Full License" + override val driversLicenseLicenseTypeFeedbackRequired get() = "License Type is required" + override val driversLicensePhotoPlaceholder: String get() = "[photo]" + override val driversLicenseApplicationHeading: String get() = "Drivers License Application" + override val driversLicenseApplicationServiceSettings get() = "Drivers License Application Service Settings" + override val driversLicenseApplicationServiceBuildingOnFireChance get() = "Building On Fire Chance" + override val driversLicenseApplicationServiceSassChance get() = "Sass Chance" + override val driversLicenseApplicationServiceLegallyProhibitedChance get() = "Legally Prohibited Chance" + override val driversLicenseApplicationFormReset: String get() = "Reset" + override val driversLicenseApplicationFormApply: String get() = "Apply" + override val driversLicenseApplicationRejectionTitle: String get() = "Rejected" + override val sassFormat get() = "%1\$s Try again another time." + override val sassLeavingForBreak get() = "The clerk was just stepping out for a break." + override val sassLooksAtBreakSignTapsReadsMagazine get() = "The clerk taps the \"On Break\" sign and continues reading a magazine." + + override val sassMealDeliveryJustArrived: String get() = "The clerk's meal delivery just arrived and they're not about to let it get cold." + override val driversLicenseApplicationRejectionLegallyProhibited get() = "You are legally prohibited from obtaining a drivers license." + + override val validation = ValidationStringsImpl(EnglishUsValidationTranslation()) + + override val ok get() = "OK" +} \ No newline at end of file diff --git a/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech.coner.trailer.toolkit.sample.dmvapp/presentation/localization/PresentationLocalizationModule.kt b/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/presentation/localization/PresentationLocalizationModule.kt similarity index 75% rename from toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech.coner.trailer.toolkit.sample.dmvapp/presentation/localization/PresentationLocalizationModule.kt rename to toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/presentation/localization/PresentationLocalizationModule.kt index f773f1df1..1c691c829 100644 --- a/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech.coner.trailer.toolkit.sample.dmvapp/presentation/localization/PresentationLocalizationModule.kt +++ b/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/presentation/localization/PresentationLocalizationModule.kt @@ -5,5 +5,5 @@ import org.kodein.di.bindSingleton import org.kodein.di.new val presentationLocalizationModule by DI.Module { - bindSingleton { new(::EnglishUsLocalization) } -} \ No newline at end of file + bindSingleton { StringsImpl(EnglishUsTranslation()) } +} diff --git a/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/presentation/localization/Strings.kt b/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/presentation/localization/Strings.kt new file mode 100644 index 000000000..c7877651f --- /dev/null +++ b/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/presentation/localization/Strings.kt @@ -0,0 +1,18 @@ +package tech.coner.trailer.toolkit.sample.dmvapp.presentation.localization + +import tech.coner.trailer.toolkit.sample.dmvapp.domain.entity.LicenseType +import tech.coner.trailer.toolkit.sample.dmvapp.domain.entity.Sass +import tech.coner.trailer.toolkit.sample.dmvapp.domain.validation.DriversLicenseApplicationFeedback +import tech.coner.trailer.toolkit.sample.dmvapp.presentation.validation.DriversLicenseApplicationModelFeedback + +interface Strings : Translation { + + val licenseTypeLabels: List> + val licenseTypesByObject: Map + operator fun get(licenseType: LicenseType): String + fun getNullable(licenseType: LicenseType?): String + + operator fun get(feedback: DriversLicenseApplicationModelFeedback): String + operator fun get(feedback: DriversLicenseApplicationFeedback): String + operator fun get(sass: Sass): String +} \ No newline at end of file diff --git a/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/presentation/localization/StringsImpl.kt b/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/presentation/localization/StringsImpl.kt new file mode 100644 index 000000000..da1632269 --- /dev/null +++ b/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/presentation/localization/StringsImpl.kt @@ -0,0 +1,81 @@ +package tech.coner.trailer.toolkit.sample.dmvapp.presentation.localization + +import tech.coner.trailer.toolkit.sample.dmvapp.domain.entity.LicenseType +import tech.coner.trailer.toolkit.sample.dmvapp.domain.entity.LicenseType.FullLicense +import tech.coner.trailer.toolkit.sample.dmvapp.domain.entity.LicenseType.GraduatedLearnerPermit +import tech.coner.trailer.toolkit.sample.dmvapp.domain.entity.LicenseType.LearnerPermit +import tech.coner.trailer.toolkit.sample.dmvapp.domain.entity.Sass +import tech.coner.trailer.toolkit.sample.dmvapp.domain.validation.DriversLicenseApplicationFeedback +import tech.coner.trailer.toolkit.sample.dmvapp.presentation.validation.DriversLicenseApplicationModelFeedback +import tech.coner.trailer.toolkit.sample.dmvapp.presentation.validation.DriversLicenseApplicationModelFeedback.AgeIsRequired +import tech.coner.trailer.toolkit.sample.dmvapp.presentation.validation.DriversLicenseApplicationModelFeedback.DelegatedFeedback +import tech.coner.trailer.toolkit.sample.dmvapp.presentation.validation.DriversLicenseApplicationModelFeedback.LicenseTypeIsRequired +import tech.coner.trailer.toolkit.sample.dmvapp.presentation.validation.DriversLicenseApplicationModelFeedback.NameIsRequired + +class StringsImpl(translation: Translation) : Strings, + Translation by translation { + + override val licenseTypeLabels = listOf( + GraduatedLearnerPermit to driversLicenseLicenseTypeGraduatedLearnerPermit, + LearnerPermit to driversLicenseLicenseTypeLearnerPermit, + FullLicense to driversLicenseLicenseTypeFullLicense + ) + override val licenseTypesByObject = licenseTypeLabels.toMap() + + override fun get(licenseType: LicenseType) = licenseTypesByObject[licenseType] + ?: throw Exception("No string defined for $licenseType") + + override fun getNullable(licenseType: LicenseType?) = licenseType?.let { get(it) } ?: "" + + override fun get(feedback: DriversLicenseApplicationModelFeedback) = when (feedback) { + NameIsRequired -> driversLicenseNameFeedbackRequired + AgeIsRequired -> driversLicenseAgeFeedbackRequired + LicenseTypeIsRequired -> driversLicenseLicenseTypeFeedbackRequired + is DelegatedFeedback -> get(feedback.feedback) + } + + override fun get(feedback: DriversLicenseApplicationFeedback) = with (feedback) { + when (this) { + DriversLicenseApplicationFeedback.NameMustNotBeBlank -> driversLicenseNameFeedbackNotBlank + is DriversLicenseApplicationFeedback.TooYoung -> { + val suggestion = when { + reapplyWhenAge != null && suggestOtherLicenseType != null -> + driversLicenseAgeFeedbackTooYoungSuggestOtherLicenseTypeWhenAgedFormat.format( + get(suggestOtherLicenseType), + reapplyWhenAge + ) + reapplyWhenAge != null -> + driversLicenseAgeFeedbackTooYoungSuggestReapplyWhenAgedFormat.format( + reapplyWhenAge + ) + suggestOtherLicenseType != null -> + driversLicenseAgeFeedbackTooYoungSuggestOtherLicenseTypeFormat.format( + get(suggestOtherLicenseType) + ) + else -> null + } + val tooYoung = driversLicenseAgeFeedbackTooYoung + suggestion + ?.let { driversLicenseAgeFeedbackTooYoungSuggestionFormat.format(tooYoung, suggestion) } + ?: tooYoung + } + + is DriversLicenseApplicationFeedback.TooOld -> { + val tooOld = driversLicenseAgeFeedbackTooOld + suggestOtherLicenseType + ?.let { + driversLicenseAgeFeedbackTooOldSuggestOtherLicenseTypeFormat.format(tooOld, get(suggestOtherLicenseType)) + } + ?: tooOld + } + } + } + + override fun get(sass: Sass): String = sassFormat.format( + when (sass) { + Sass.LEAVING_FOR_BREAK -> sassLeavingForBreak + Sass.LOOKS_AT_BREAK_SIGN_TAPS_READS_MAGAZINE -> sassLooksAtBreakSignTapsReadsMagazine + Sass.MEAL_DELIVERY_JUST_ARRIVED -> sassMealDeliveryJustArrived + } + ) +} diff --git a/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/presentation/localization/Translation.kt b/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/presentation/localization/Translation.kt new file mode 100644 index 000000000..b99f9d25a --- /dev/null +++ b/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/presentation/localization/Translation.kt @@ -0,0 +1,60 @@ +package tech.coner.trailer.toolkit.sample.dmvapp.presentation.localization + +import tech.coner.trailer.toolkit.validation.presentation.localization.ValidationStrings + +interface Translation { + + val conerLogoContentDescription: String + val dmvLabel: String + val dmvMotto: String + + val driversLicenseHeading: String + val driversLicenseNameField: String + val driversLicenseNameFeedbackRequired: String + val driversLicenseNameFeedbackNotBlank: String + val driversLicenseAgeField: String + val driversLicenseAgeFeedbackRequired: String + val driversLicenseAgeFeedbackTooYoung: String + val driversLicenseAgeFeedbackTooYoungSuggestOtherLicenseTypeWhenAgedFormat: String + val driversLicenseAgeFeedbackTooYoungSuggestReapplyWhenAgedFormat: String + val driversLicenseAgeFeedbackTooYoungSuggestOtherLicenseTypeFormat: String + val driversLicenseAgeFeedbackTooYoungSuggestionFormat: String + val driversLicenseAgeFeedbackTooOld: String + val driversLicenseAgeFeedbackTooOldSuggestOtherLicenseTypeFormat: String + val driversLicenseLicenseTypeField: String + val driversLicenseLicenseTypeGraduatedLearnerPermit: String + val driversLicenseLicenseTypeLearnerPermit: String + val driversLicenseLicenseTypeFullLicense: String + val driversLicenseLicenseTypeFeedbackRequired: String + val driversLicensePhotoPlaceholder: String + val driversLicenseApplicationHeading: String + val driversLicenseApplicationServiceSettings: String + val driversLicenseApplicationServiceBuildingOnFireChance: String + val driversLicenseApplicationServiceSassChance: String + val driversLicenseApplicationServiceLegallyProhibitedChance: String + val driversLicenseApplicationFormReset: String + val driversLicenseApplicationFormApply: String + + val driversLicenseApplicationRejectionTitle: String + + val sassFormat: String + val sassLeavingForBreak: String + val sassLooksAtBreakSignTapsReadsMagazine: String + val sassMealDeliveryJustArrived: String + + val driversLicenseApplicationRejectionLegallyProhibited: String + + val driversLicenseGranted: String + + val menuContentDescription: String + val settings: String + val settingsThemeTitle: String + val settingsThemeModeTitle: String + val settingsThemeModeAuto: String + val settingsThemeModeLight: String + val settingsThemeModeDark: String + + val validation: ValidationStrings + + val ok: String +} \ No newline at end of file diff --git a/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech.coner.trailer.toolkit.sample.dmvapp/presentation/model/DriversLicenseApplicationItemModel.kt b/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/presentation/model/DriversLicenseApplicationItemModel.kt similarity index 51% rename from toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech.coner.trailer.toolkit.sample.dmvapp/presentation/model/DriversLicenseApplicationItemModel.kt rename to toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/presentation/model/DriversLicenseApplicationItemModel.kt index aa10659ff..cb331416c 100644 --- a/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech.coner.trailer.toolkit.sample.dmvapp/presentation/model/DriversLicenseApplicationItemModel.kt +++ b/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/presentation/model/DriversLicenseApplicationItemModel.kt @@ -1,8 +1,6 @@ package tech.coner.trailer.toolkit.sample.dmvapp.presentation.model -import kotlinx.coroutines.flow.map import tech.coner.trailer.toolkit.presentation.model.BaseItemModel -import tech.coner.trailer.toolkit.sample.dmvapp.domain.entity.LicenseType import tech.coner.trailer.toolkit.sample.dmvapp.presentation.validation.DriversLicenseApplicationModelFeedback import tech.coner.trailer.toolkit.validation.Validator @@ -13,19 +11,4 @@ class DriversLicenseApplicationItemModel( override val initialItem: DriversLicenseApplicationModel = DriversLicenseApplicationModel() override val validatorContext = Unit - var name: String? - get() = pendingItem.name - set(value) = mutatePendingItem { it.copy(name = value) } - val nameFlow = pendingItemFlow.map { it.name } - - var age: Int? - get() = pendingItem.age - set(value) = mutatePendingItem { it.copy(age = value) } - val ageFlow = pendingItemFlow.map { it.age } - - var licenseType: LicenseType? - get() = pendingItem.licenseType - set(value) = mutatePendingItem { it.copy(licenseType = value) } - val licenseTypeFlow = pendingItemFlow.map { it.licenseType } - } diff --git a/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech.coner.trailer.toolkit.sample.dmvapp/presentation/model/DriversLicenseApplicationModel.kt b/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/presentation/model/DriversLicenseApplicationModel.kt similarity index 100% rename from toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech.coner.trailer.toolkit.sample.dmvapp/presentation/model/DriversLicenseApplicationModel.kt rename to toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/presentation/model/DriversLicenseApplicationModel.kt diff --git a/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/presentation/model/DriversLicenseApplicationRejectionModel.kt b/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/presentation/model/DriversLicenseApplicationRejectionModel.kt new file mode 100644 index 000000000..fcc38dfc0 --- /dev/null +++ b/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/presentation/model/DriversLicenseApplicationRejectionModel.kt @@ -0,0 +1,18 @@ +package tech.coner.trailer.toolkit.sample.dmvapp.presentation.model + +import tech.coner.trailer.toolkit.sample.dmvapp.domain.entity.Sass +import tech.coner.trailer.toolkit.sample.dmvapp.presentation.validation.DriversLicenseApplicationModelFeedback +import tech.coner.trailer.toolkit.validation.ValidationOutcome + +sealed interface DriversLicenseApplicationRejectionModel { + + data class Invalid( + val validationOutcome: ValidationOutcome, + ) : DriversLicenseApplicationRejectionModel + + data class Sassed( + val sass: Sass + ) : DriversLicenseApplicationRejectionModel + + data object LegallyProhibited : DriversLicenseApplicationRejectionModel +} \ No newline at end of file diff --git a/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/presentation/presenter/DriversLicenseApplicationPresenter.kt b/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/presentation/presenter/DriversLicenseApplicationPresenter.kt new file mode 100644 index 000000000..d4206cab3 --- /dev/null +++ b/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/presentation/presenter/DriversLicenseApplicationPresenter.kt @@ -0,0 +1,148 @@ +package tech.coner.trailer.toolkit.sample.dmvapp.presentation.presenter + +import arrow.core.Either +import arrow.core.raise.either +import kotlinx.coroutines.cancel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import tech.coner.trailer.toolkit.presentation.presenter.ItemModelPresenter +import tech.coner.trailer.toolkit.presentation.presenter.StatefulPresenter +import tech.coner.trailer.toolkit.presentation.state.StateContainer +import tech.coner.trailer.toolkit.presentation.state.mutableItemModelProperty +import tech.coner.trailer.toolkit.sample.dmvapp.domain.entity.DriversLicense +import tech.coner.trailer.toolkit.sample.dmvapp.domain.entity.DriversLicenseApplicationRejection +import tech.coner.trailer.toolkit.sample.dmvapp.domain.service.DriversLicenseApplicationService +import tech.coner.trailer.toolkit.sample.dmvapp.presentation.adapter.DriversLicenseApplicationModelAdapters +import tech.coner.trailer.toolkit.sample.dmvapp.presentation.localization.Strings +import tech.coner.trailer.toolkit.sample.dmvapp.presentation.model.DriversLicenseApplicationItemModel +import tech.coner.trailer.toolkit.sample.dmvapp.presentation.model.DriversLicenseApplicationModel +import tech.coner.trailer.toolkit.sample.dmvapp.presentation.model.DriversLicenseApplicationRejectionModel +import tech.coner.trailer.toolkit.sample.dmvapp.presentation.state.DriversLicenseApplicationState +import tech.coner.trailer.toolkit.sample.dmvapp.presentation.validation.DriversLicenseApplicationModelFeedback +import tech.coner.trailer.toolkit.sample.dmvapp.presentation.validation.DriversLicenseApplicationModelValidator +import tech.coner.trailer.toolkit.validation.ValidationOutcome + +class DriversLicenseApplicationPresenter( + private val initialState: DriversLicenseApplicationState? = null, + private val adapters: DriversLicenseApplicationModelAdapters, + private val service: DriversLicenseApplicationService, + private val validator: DriversLicenseApplicationModelValidator, + private val strings: Strings, +) : StatefulPresenter, + ItemModelPresenter { + + private val stateContainer = StateContainer( + initialState = initialState ?: DriversLicenseApplicationState( + itemModel = DriversLicenseApplicationItemModel( + validator = validator + ) + ), + ) + override val state: DriversLicenseApplicationState get() = stateContainer.state + override val stateFlow: StateFlow get() = stateContainer.stateFlow + + suspend fun submitApplication(): Result> = coroutineScope { + runCatching { + either { + if (state.processing) cancel() + stateContainer.update { + it.copy(processing = true) + } + either { + state.itemModel.commit() + .mapLeft { DriversLicenseApplicationRejectionModel.Invalid(it) } + .bind() + } + .map { + service.submit(adapters.toDomainEntity(it).getOrThrow()).getOrThrow() + .mapLeft { rejection -> + when (rejection) { + is DriversLicenseApplicationRejection.Invalid -> DriversLicenseApplicationRejectionModel.Invalid( + adapters.domainEntityValidationAdapter(rejection.validationOutcome) + ) + is DriversLicenseApplicationRejection.Sassed -> DriversLicenseApplicationRejectionModel.Sassed( + rejection.sass + ) + is DriversLicenseApplicationRejection.LegallyProhibited -> DriversLicenseApplicationRejectionModel.LegallyProhibited + } + + } + .bind() + } + .bind() + } + } + .also { result -> stateContainer.update { it.copy(processing = false, outcome = result) } } + } + + suspend fun clearOutcome() { + stateContainer.update { it.copy(outcome = null) } + } + + override suspend fun reset() { + stateContainer.update { + initialState + ?: DriversLicenseApplicationState( + itemModel = DriversLicenseApplicationItemModel( + validator = validator + ) + ) + } + } + + override suspend fun commit() { + state.itemModel.commit() + } + + override suspend fun validate() { + state.itemModel.validate() + } + + val validationResult get() = state.itemModel.pendingItemValidation + val validationOutcomeFlow: Flow> get() = stateFlow + .flatMapLatest { it.itemModel.pendingItemValidationFlow } + + val name = stateContainer.mutableItemModelProperty( + getFn = { name }, + updateFn = { copy(name = it) }, + property = DriversLicenseApplicationModel::name + ) + + val age = stateContainer.mutableItemModelProperty( + getFn = { age }, + updateFn = { copy(age = it) }, + property = DriversLicenseApplicationModel::age + ) + + val licenseType = stateContainer.mutableItemModelProperty( + getFn = { licenseType }, + updateFn = { copy(licenseType = it) }, + property = DriversLicenseApplicationModel::licenseType + ) + + val processingApplication = stateContainer.mutableStateProperty( + getValue = { processing }, + updateState = { copy(processing = it) } + ) + val fieldsEditable = stateContainer.stateProperty { fieldsEditable } + + val canResetFlow: Flow by lazy { + stateFlow + .flatMapLatest { it.itemModel.canResetFlow } + .combine(processingApplication.flow) { itemModelCanReset, processing -> + itemModelCanReset && !processing + } + .distinctUntilChanged() + } + + val canApplyFlow: Flow = stateFlow + .combine( + stateFlow.flatMapLatest { it.itemModel.pendingItemValidationFlow } + ) { state, _ -> + state.canApply + } +} \ No newline at end of file diff --git a/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/presentation/presenter/PresentationPresenterModule.kt b/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/presentation/presenter/PresentationPresenterModule.kt new file mode 100644 index 000000000..81984d880 --- /dev/null +++ b/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/presentation/presenter/PresentationPresenterModule.kt @@ -0,0 +1,16 @@ +package tech.coner.trailer.toolkit.sample.dmvapp.presentation.presenter + +import org.kodein.di.* +import tech.coner.trailer.toolkit.sample.dmvapp.presentation.adapter.DriversLicenseApplicationModelAdapters + +val presentationPresenterModule by DI.Module { + bindSingleton { + DriversLicenseApplicationPresenter( + initialState = null, + adapters = DriversLicenseApplicationModelAdapters(), + service = instance(), + validator = instance(), + strings = instance() + ) + } +} \ No newline at end of file diff --git a/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/presentation/state/DriversLicenseApplicationState.kt b/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/presentation/state/DriversLicenseApplicationState.kt new file mode 100644 index 000000000..2626063a7 --- /dev/null +++ b/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/presentation/state/DriversLicenseApplicationState.kt @@ -0,0 +1,22 @@ +package tech.coner.trailer.toolkit.sample.dmvapp.presentation.state + +import arrow.core.Either +import tech.coner.trailer.toolkit.presentation.state.ItemModelState +import tech.coner.trailer.toolkit.presentation.state.State +import tech.coner.trailer.toolkit.sample.dmvapp.domain.entity.DriversLicense +import tech.coner.trailer.toolkit.sample.dmvapp.presentation.model.DriversLicenseApplicationItemModel +import tech.coner.trailer.toolkit.sample.dmvapp.presentation.model.DriversLicenseApplicationModel +import tech.coner.trailer.toolkit.sample.dmvapp.presentation.model.DriversLicenseApplicationRejectionModel +import tech.coner.trailer.toolkit.sample.dmvapp.presentation.validation.DriversLicenseApplicationModelFeedback + +data class DriversLicenseApplicationState( + override val itemModel: DriversLicenseApplicationItemModel, + val processing: Boolean = false, + val outcome: Result>? = null +) : State, + ItemModelState { + + val fieldsEditable: Boolean get() = !processing && (outcome == null || outcome.isFailure || outcome.getOrNull()?.isLeft() == true) + + val canApply: Boolean get() = !processing && (outcome == null || outcome.isFailure || outcome.getOrNull()?.isLeft() == true) && itemModel.pendingItemValidation.isValid +} diff --git a/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/presentation/validation/DriversLicenseApplicationModelFeedback.kt b/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/presentation/validation/DriversLicenseApplicationModelFeedback.kt new file mode 100644 index 000000000..5e828cba0 --- /dev/null +++ b/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/presentation/validation/DriversLicenseApplicationModelFeedback.kt @@ -0,0 +1,40 @@ +package tech.coner.trailer.toolkit.sample.dmvapp.presentation.validation + +import tech.coner.trailer.toolkit.sample.dmvapp.domain.entity.DriversLicenseApplication +import tech.coner.trailer.toolkit.sample.dmvapp.domain.validation.DriversLicenseApplicationFeedback +import tech.coner.trailer.toolkit.sample.dmvapp.presentation.model.DriversLicenseApplicationModel +import tech.coner.trailer.toolkit.validation.Feedback +import tech.coner.trailer.toolkit.validation.FeedbackDelegate +import tech.coner.trailer.toolkit.validation.Severity +import tech.coner.trailer.toolkit.validation.Severity.Error +import tech.coner.trailer.toolkit.validation.adapter.propertyAdapterOf +import kotlin.reflect.KProperty1 + +sealed class DriversLicenseApplicationModelFeedback : Feedback { + + data object NameIsRequired : DriversLicenseApplicationModelFeedback() { + override val property = DriversLicenseApplicationModel::name + override val severity = Error + } + data object AgeIsRequired : DriversLicenseApplicationModelFeedback() { + override val property = DriversLicenseApplicationModel::age + override val severity = Error + } + data object LicenseTypeIsRequired : DriversLicenseApplicationModelFeedback() { + override val property = DriversLicenseApplicationModel::licenseType + override val severity = Error + } + + data class DelegatedFeedback( + val feedback: DriversLicenseApplicationFeedback + ) : DriversLicenseApplicationModelFeedback(), + Feedback by FeedbackDelegate( + feedback, + propertyAdapterOf( + DriversLicenseApplication::name to DriversLicenseApplicationModel::name, + DriversLicenseApplication::age to DriversLicenseApplicationModel::age, + DriversLicenseApplication::licenseType to DriversLicenseApplicationModel::licenseType, + null to null + ) + ) +} diff --git a/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech.coner.trailer.toolkit.sample.dmvapp/presentation/validation/DriversLicenseApplicationModelValidator.kt b/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/presentation/validation/DriversLicenseApplicationModelValidator.kt similarity index 52% rename from toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech.coner.trailer.toolkit.sample.dmvapp/presentation/validation/DriversLicenseApplicationModelValidator.kt rename to toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/presentation/validation/DriversLicenseApplicationModelValidator.kt index bb13edd92..38c49c135 100644 --- a/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech.coner.trailer.toolkit.sample.dmvapp/presentation/validation/DriversLicenseApplicationModelValidator.kt +++ b/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/presentation/validation/DriversLicenseApplicationModelValidator.kt @@ -1,13 +1,9 @@ package tech.coner.trailer.toolkit.sample.dmvapp.presentation.validation -import tech.coner.trailer.toolkit.sample.dmvapp.domain.entity.DriversLicenseApplication import tech.coner.trailer.toolkit.sample.dmvapp.domain.validation.DriversLicenseClerk -import tech.coner.trailer.toolkit.sample.dmvapp.presentation.adapter.toDomainEntity +import tech.coner.trailer.toolkit.sample.dmvapp.presentation.adapter.DriversLicenseApplicationModelAdapters import tech.coner.trailer.toolkit.sample.dmvapp.presentation.model.DriversLicenseApplicationModel -import tech.coner.trailer.toolkit.sample.dmvapp.presentation.validation.DriversLicenseApplicationModelFeedback.AgeIsRequired -import tech.coner.trailer.toolkit.sample.dmvapp.presentation.validation.DriversLicenseApplicationModelFeedback.DelegatedFeedback -import tech.coner.trailer.toolkit.sample.dmvapp.presentation.validation.DriversLicenseApplicationModelFeedback.LicenseTypeIsRequired -import tech.coner.trailer.toolkit.sample.dmvapp.presentation.validation.DriversLicenseApplicationModelFeedback.NameIsRequired +import tech.coner.trailer.toolkit.sample.dmvapp.presentation.validation.DriversLicenseApplicationModelFeedback.* import tech.coner.trailer.toolkit.validation.Severity import tech.coner.trailer.toolkit.validation.Validator import tech.coner.trailer.toolkit.validation.input @@ -15,20 +11,17 @@ import tech.coner.trailer.toolkit.validation.input typealias DriversLicenseApplicationModelValidator = Validator fun DriversLicenseApplicationModelValidator( - driversLicenseClerk: DriversLicenseClerk + driversLicenseClerk: DriversLicenseClerk, + driversLicenseApplicationModelAdapters: DriversLicenseApplicationModelAdapters, ): DriversLicenseApplicationModelValidator = Validator { - DriversLicenseApplicationModel::name { if (it == null) NameIsRequired else null } + + DriversLicenseApplicationModel::name { if (it.isNullOrBlank()) NameIsRequired else null } DriversLicenseApplicationModel::age { if (it == null) AgeIsRequired else null } DriversLicenseApplicationModel::licenseType { licenseType -> LicenseTypeIsRequired.takeIf { licenseType == null } } returnEarlyIfAny { it.severity == Severity.Error } input( validator = driversLicenseClerk, - mapInputFn = { it.toDomainEntity()!! }, - mapFeedbackKeys = mapOf( - DriversLicenseApplication::name to DriversLicenseApplicationModel::name, - DriversLicenseApplication::age to DriversLicenseApplicationModel::age, - DriversLicenseApplication::licenseType to DriversLicenseApplicationModel::licenseType - ), + mapInputFn = { driversLicenseApplicationModelAdapters.toDomainEntity(it).getOrThrow() }, mapFeedbackObjectFn = { DelegatedFeedback(it) } ) } diff --git a/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech.coner.trailer.toolkit.sample.dmvapp/presentation/validation/PresentationValidationModule.kt b/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/presentation/validation/PresentationValidationModule.kt similarity index 69% rename from toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech.coner.trailer.toolkit.sample.dmvapp/presentation/validation/PresentationValidationModule.kt rename to toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/presentation/validation/PresentationValidationModule.kt index 975b84a63..021cbdd50 100644 --- a/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech.coner.trailer.toolkit.sample.dmvapp/presentation/validation/PresentationValidationModule.kt +++ b/toolkit/samples/dmvapp/dmvapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/presentation/validation/PresentationValidationModule.kt @@ -3,7 +3,8 @@ package tech.coner.trailer.toolkit.sample.dmvapp.presentation.validation import org.kodein.di.DI import org.kodein.di.bindSingleton import org.kodein.di.instance +import org.kodein.di.new val presentationValidationModule by DI.Module { - bindSingleton { DriversLicenseApplicationModelValidator(instance()) } + bindSingleton { new(::DriversLicenseApplicationModelValidator) } } \ No newline at end of file diff --git a/toolkit/samples/dmvapp/dmvapp-common/src/test/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/TestDI.kt b/toolkit/samples/dmvapp/dmvapp-common/src/test/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/TestDI.kt index 00548cd27..4143f5e6a 100644 --- a/toolkit/samples/dmvapp/dmvapp-common/src/test/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/TestDI.kt +++ b/toolkit/samples/dmvapp/dmvapp-common/src/test/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/TestDI.kt @@ -3,16 +3,18 @@ package tech.coner.trailer.toolkit.sample.dmvapp import org.kodein.di.DI import tech.coner.trailer.toolkit.sample.dmvapp.domain.service.impl.domainServiceModule import tech.coner.trailer.toolkit.sample.dmvapp.domain.validation.domainValidationModule +import tech.coner.trailer.toolkit.sample.dmvapp.presentation.adapter.presentationAdapterModule import tech.coner.trailer.toolkit.sample.dmvapp.presentation.localization.presentationLocalizationModule import tech.coner.trailer.toolkit.sample.dmvapp.presentation.presenter.presentationPresenterModule import tech.coner.trailer.toolkit.sample.dmvapp.presentation.validation.presentationValidationModule -val testDi = DI { +val testDi get() = DI { importAll( domainServiceModule, domainValidationModule, presentationLocalizationModule, presentationPresenterModule, + presentationAdapterModule, presentationValidationModule ) } \ No newline at end of file diff --git a/toolkit/samples/dmvapp/dmvapp-common/src/test/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/domain/entity/DriversLicenseApplicationAssertk.kt b/toolkit/samples/dmvapp/dmvapp-common/src/test/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/domain/entity/DriversLicenseApplicationAssertk.kt deleted file mode 100644 index 518de79e5..000000000 --- a/toolkit/samples/dmvapp/dmvapp-common/src/test/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/domain/entity/DriversLicenseApplicationAssertk.kt +++ /dev/null @@ -1,7 +0,0 @@ -package tech.coner.trailer.toolkit.sample.dmvapp.domain.entity - -import assertk.Assert -import assertk.assertions.prop - -fun Assert.driversLicense() = prop(DriversLicenseApplication.Outcome::driversLicense) -fun Assert.feedback() = prop(DriversLicenseApplication.Outcome::feedback) diff --git a/toolkit/samples/dmvapp/dmvapp-common/src/test/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/domain/service/impl/DriversLicenseApplicationServiceImplTest.kt b/toolkit/samples/dmvapp/dmvapp-common/src/test/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/domain/service/impl/DriversLicenseApplicationServiceImplTest.kt index ea0e662f3..e83c3199e 100644 --- a/toolkit/samples/dmvapp/dmvapp-common/src/test/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/domain/service/impl/DriversLicenseApplicationServiceImplTest.kt +++ b/toolkit/samples/dmvapp/dmvapp-common/src/test/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/domain/service/impl/DriversLicenseApplicationServiceImplTest.kt @@ -1,5 +1,6 @@ package tech.coner.trailer.toolkit.sample.dmvapp.domain.service.impl +import arrow.core.Either import assertk.assertThat import assertk.assertions.isEqualTo import kotlinx.coroutines.test.runTest @@ -7,10 +8,12 @@ import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.EnumSource import tech.coner.trailer.toolkit.sample.dmvapp.domain.entity.DriversLicense import tech.coner.trailer.toolkit.sample.dmvapp.domain.entity.DriversLicenseApplication +import tech.coner.trailer.toolkit.sample.dmvapp.domain.entity.DriversLicenseApplicationRejection import tech.coner.trailer.toolkit.sample.dmvapp.domain.entity.LicenseType.FullLicense import tech.coner.trailer.toolkit.sample.dmvapp.domain.service.DriversLicenseApplicationService import tech.coner.trailer.toolkit.sample.dmvapp.domain.validation.DriversLicenseApplicationFeedback.NameMustNotBeBlank import tech.coner.trailer.toolkit.sample.dmvapp.domain.validation.DriversLicenseClerk +import tech.coner.trailer.toolkit.validation.ValidationOutcome class DriversLicenseApplicationServiceImplTest { @@ -20,7 +23,7 @@ class DriversLicenseApplicationServiceImplTest { enum class ProcessScenario( val application: DriversLicenseApplication, - val expected: DriversLicenseApplication.Outcome + val expected: Result> ) { VALID( application = DriversLicenseApplication( @@ -28,13 +31,14 @@ class DriversLicenseApplicationServiceImplTest { age = 18, licenseType = FullLicense ), - expected = DriversLicenseApplication.Outcome( - driversLicense = DriversLicense( - name = "not blank", - ageWhenApplied = 18, - licenseType = FullLicense - ), - feedback = emptyMap() + expected = Result.success( + Either.Right( + DriversLicense( + name = "not blank", + age = 18, + licenseType = FullLicense + ), + ) ) ), INVALID( @@ -43,10 +47,13 @@ class DriversLicenseApplicationServiceImplTest { age = 18, licenseType = FullLicense ), - expected = DriversLicenseApplication.Outcome( - driversLicense = null, - feedback = mapOf( - DriversLicenseApplication::name to listOf(NameMustNotBeBlank) + expected = Result.success( + Either.Left( + DriversLicenseApplicationRejection.Invalid( + ValidationOutcome( + listOf(NameMustNotBeBlank) + ) + ) ) ) ) @@ -54,8 +61,8 @@ class DriversLicenseApplicationServiceImplTest { @ParameterizedTest @EnumSource(ProcessScenario::class) - fun itShouldProcessDriversLicenseApplication(scenario: ProcessScenario) = runTest { - val actual = service.process(scenario.application) + fun itShouldSubmitDriversLicenseApplication(scenario: ProcessScenario) = runTest { + val actual = service.submit(scenario.application) assertThat(actual).isEqualTo(scenario.expected) } diff --git a/toolkit/samples/dmvapp/dmvapp-common/src/test/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/domain/validation/DriversLicenseValidatorTest.kt b/toolkit/samples/dmvapp/dmvapp-common/src/test/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/domain/validation/DriversLicenseValidatorTest.kt index 3365c59ff..38fdae983 100644 --- a/toolkit/samples/dmvapp/dmvapp-common/src/test/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/domain/validation/DriversLicenseValidatorTest.kt +++ b/toolkit/samples/dmvapp/dmvapp-common/src/test/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/domain/validation/DriversLicenseValidatorTest.kt @@ -3,8 +3,7 @@ package tech.coner.trailer.toolkit.sample.dmvapp.domain.validation import assertk.all import assertk.assertThat -import assertk.assertions.hasSize -import assertk.assertions.isEqualTo +import assertk.assertions.* import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.EnumSource import tech.coner.trailer.toolkit.sample.dmvapp.domain.entity.DriversLicenseApplication @@ -14,6 +13,7 @@ import tech.coner.trailer.toolkit.sample.dmvapp.domain.entity.LicenseType.Learne import tech.coner.trailer.toolkit.sample.dmvapp.domain.validation.DriversLicenseApplicationFeedback.TooYoung import tech.coner.trailer.toolkit.validation.invoke import tech.coner.trailer.toolkit.validation.testsupport.feedback +import tech.coner.trailer.toolkit.validation.testsupport.feedbackByProperty import tech.coner.trailer.toolkit.validation.testsupport.isInvalid import tech.coner.trailer.toolkit.validation.testsupport.isValid @@ -136,8 +136,11 @@ class DriversLicenseValidatorTest { else -> 0 } ) - transform { it[DriversLicenseApplication::age] } - .isEqualTo(scenario.expectedAgeFeedback) + } + feedbackByProperty().all { + scenario.expectedAgeFeedback + ?.also { key(DriversLicenseApplication::age).isEqualTo(it) } + ?: doesNotContainKey(DriversLicenseApplication::age) } isValid().isEqualTo(scenario.expectedValid) diff --git a/toolkit/samples/dmvapp/dmvapp-common/src/test/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/presentation/presenter/DriversLicenseApplicationPresenterTest.kt b/toolkit/samples/dmvapp/dmvapp-common/src/test/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/presentation/presenter/DriversLicenseApplicationPresenterTest.kt index b502bf9f7..78ecf4d10 100644 --- a/toolkit/samples/dmvapp/dmvapp-common/src/test/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/presentation/presenter/DriversLicenseApplicationPresenterTest.kt +++ b/toolkit/samples/dmvapp/dmvapp-common/src/test/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/presentation/presenter/DriversLicenseApplicationPresenterTest.kt @@ -2,80 +2,72 @@ package tech.coner.trailer.toolkit.sample.dmvapp.presentation.presenter import assertk.all import assertk.assertThat -import assertk.assertions.isEmpty import assertk.assertions.isEqualTo -import assertk.assertions.isFalse import assertk.assertions.isNotNull -import kotlinx.coroutines.runBlocking +import assertk.assertions.isSuccess +import assertk.assertions.prop +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Test -import org.junit.jupiter.api.fail +import org.kodein.di.DIAware +import org.kodein.di.instance +import tech.coner.trailer.assertk.arrowkt.isRight import tech.coner.trailer.toolkit.sample.dmvapp.domain.entity.DriversLicense import tech.coner.trailer.toolkit.sample.dmvapp.domain.entity.LicenseType.FullLicense -import tech.coner.trailer.toolkit.sample.dmvapp.domain.entity.driversLicense -import tech.coner.trailer.toolkit.sample.dmvapp.domain.entity.feedback import tech.coner.trailer.toolkit.sample.dmvapp.presentation.model.DriversLicenseApplicationModel -import tech.coner.trailer.toolkit.sample.dmvapp.presentation.validation.DriversLicenseApplicationModelFeedback.AgeIsRequired -import tech.coner.trailer.toolkit.sample.dmvapp.presentation.validation.DriversLicenseApplicationModelFeedback.LicenseTypeIsRequired -import tech.coner.trailer.toolkit.sample.dmvapp.presentation.validation.DriversLicenseApplicationModelFeedback.NameIsRequired +import tech.coner.trailer.toolkit.sample.dmvapp.presentation.state.DriversLicenseApplicationState +import tech.coner.trailer.toolkit.sample.dmvapp.presentation.validation.DriversLicenseApplicationModelFeedback.* import tech.coner.trailer.toolkit.sample.dmvapp.testDi -import tech.coner.trailer.toolkit.validation.testsupport.feedback -import tech.coner.trailer.toolkit.validation.testsupport.isValid +import tech.coner.trailer.toolkit.validation.testsupport.feedbackByProperty +import tech.coner.trailer.toolkit.validation.testsupport.isInvalid -class DriversLicenseApplicationPresenterTest { +class DriversLicenseApplicationPresenterTest: DIAware by testDi { - private val presenter = DriversLicenseApplicationPresenter( - di = testDi - ) + private val presenter: DriversLicenseApplicationPresenter by instance() @Test fun itShouldProcessValidApplication() = runTest { val license = DriversLicense( name = "experienced driver", - ageWhenApplied = 42, + age = 42, licenseType = FullLicense ) - with(presenter.state.model) { - name = license.name - age = license.ageWhenApplied - licenseType = license.licenseType + with(presenter) { + name.value = license.name + age.value = license.age + licenseType.value = license.licenseType - commit() - .onSuccess { - runBlocking { - presenter.processApplication() - } - } + submitApplication() } - assertThat(presenter.state.outcome) + assertThat(presenter.state) + .prop(DriversLicenseApplicationState::outcome) .isNotNull() - .all { - driversLicense().isEqualTo(license) - feedback().isEmpty() - } + .isSuccess() + .isRight() + .isEqualTo(license) } @Test fun itShouldHandleInvalidApplication() = runTest { - with(presenter.state.model) { + with(presenter) { // model's initial values aren't valid - commit() - .onSuccess { - fail("unexpected: commit invoked successFn") - } + submitApplication() + runCurrent() } - assertThat(presenter.state.model.pendingItemValidation).all { - isValid().isFalse() - feedback().isEqualTo( - mapOf( - DriversLicenseApplicationModel::name to listOf(NameIsRequired), - DriversLicenseApplicationModel::age to listOf(AgeIsRequired), - DriversLicenseApplicationModel::licenseType to listOf(LicenseTypeIsRequired) + assertThat(presenter.validationResult) + .isNotNull() + .all { + isInvalid() + feedbackByProperty().isEqualTo( + mapOf( + DriversLicenseApplicationModel::name to listOf(NameIsRequired), + DriversLicenseApplicationModel::age to listOf(AgeIsRequired), + DriversLicenseApplicationModel::licenseType to listOf(LicenseTypeIsRequired) + ) ) - ) } } } \ No newline at end of file diff --git a/toolkit/samples/dmvapp/dmvapp-common/src/test/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/presentation/validation/DriversLicenseApplicationModelValidatorTest.kt b/toolkit/samples/dmvapp/dmvapp-common/src/test/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/presentation/validation/DriversLicenseApplicationModelValidatorTest.kt index 702c87090..36b62eb9d 100644 --- a/toolkit/samples/dmvapp/dmvapp-common/src/test/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/presentation/validation/DriversLicenseApplicationModelValidatorTest.kt +++ b/toolkit/samples/dmvapp/dmvapp-common/src/test/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/presentation/validation/DriversLicenseApplicationModelValidatorTest.kt @@ -10,11 +10,14 @@ import tech.coner.trailer.toolkit.sample.dmvapp.domain.entity.LicenseType import tech.coner.trailer.toolkit.sample.dmvapp.domain.entity.LicenseType.FullLicense import tech.coner.trailer.toolkit.sample.dmvapp.domain.validation.DriversLicenseApplicationFeedback.TooYoung import tech.coner.trailer.toolkit.sample.dmvapp.domain.validation.DriversLicenseClerk +import tech.coner.trailer.toolkit.sample.dmvapp.presentation.adapter.DriversLicenseApplicationModelAdapters import tech.coner.trailer.toolkit.sample.dmvapp.presentation.model.DriversLicenseApplicationModel +import tech.coner.trailer.toolkit.sample.dmvapp.presentation.validation.DriversLicenseApplicationModelFeedback import tech.coner.trailer.toolkit.sample.dmvapp.presentation.validation.DriversLicenseApplicationModelFeedback.AgeIsRequired import tech.coner.trailer.toolkit.sample.dmvapp.presentation.validation.DriversLicenseApplicationModelFeedback.DelegatedFeedback import tech.coner.trailer.toolkit.sample.dmvapp.presentation.validation.DriversLicenseApplicationModelFeedback.LicenseTypeIsRequired import tech.coner.trailer.toolkit.sample.dmvapp.presentation.validation.DriversLicenseApplicationModelFeedback.NameIsRequired +import tech.coner.trailer.toolkit.sample.dmvapp.presentation.validation.DriversLicenseApplicationModelValidator import tech.coner.trailer.toolkit.validation.invoke import tech.coner.trailer.toolkit.validation.testsupport.feedback import tech.coner.trailer.toolkit.validation.testsupport.isInvalid @@ -23,20 +26,21 @@ import tech.coner.trailer.toolkit.validation.testsupport.isValid class DriversLicenseApplicationModelValidatorTest { private val validator = DriversLicenseApplicationModelValidator( - driversLicenseClerk = DriversLicenseClerk() + driversLicenseClerk = DriversLicenseClerk(), + driversLicenseApplicationModelAdapters = DriversLicenseApplicationModelAdapters() ) enum class Scenario( val input: DriversLicenseApplicationModel, - val expectedFeedback: Map, List> = emptyMap(), + val expectedFeedback: List = emptyList(), val expectedIsValid: Boolean ) { FAIL_MODEL_FROM_DEFAULT_CONSTRUCTOR( input = DriversLicenseApplicationModel(), - expectedFeedback = mapOf( - DriversLicenseApplicationModel::name to listOf(NameIsRequired), - DriversLicenseApplicationModel::age to listOf(AgeIsRequired), - DriversLicenseApplicationModel::licenseType to listOf(LicenseTypeIsRequired) + expectedFeedback = listOf( + NameIsRequired, + AgeIsRequired, + LicenseTypeIsRequired, ), expectedIsValid = false ), @@ -47,8 +51,8 @@ class DriversLicenseApplicationModelValidatorTest { age = 42, licenseType = FullLicense ), - expectedFeedback = mapOf( - DriversLicenseApplicationModel::name to listOf(NameIsRequired) + expectedFeedback = listOf( + NameIsRequired ), expectedIsValid = false ), @@ -59,8 +63,8 @@ class DriversLicenseApplicationModelValidatorTest { age = null, licenseType = FullLicense ), - expectedFeedback = mapOf( - DriversLicenseApplicationModel::age to listOf(AgeIsRequired) + expectedFeedback = listOf( + AgeIsRequired ), expectedIsValid = false ), @@ -71,8 +75,8 @@ class DriversLicenseApplicationModelValidatorTest { age = 42, licenseType = null ), - expectedFeedback = mapOf( - DriversLicenseApplicationModel::licenseType to listOf(LicenseTypeIsRequired) + expectedFeedback = listOf( + LicenseTypeIsRequired ), expectedIsValid = false ), @@ -83,10 +87,8 @@ class DriversLicenseApplicationModelValidatorTest { age = 15, licenseType = FullLicense ), - expectedFeedback = mapOf( - DriversLicenseApplicationModel::age to listOf( - DelegatedFeedback(TooYoung(suggestOtherLicenseType = LicenseType.GraduatedLearnerPermit)) - ) + expectedFeedback = listOf( + DelegatedFeedback(TooYoung(suggestOtherLicenseType = LicenseType.GraduatedLearnerPermit)) ), expectedIsValid = false ), @@ -97,7 +99,7 @@ class DriversLicenseApplicationModelValidatorTest { age = 40, licenseType = FullLicense ), - expectedFeedback = emptyMap(), + expectedFeedback = emptyList(), expectedIsValid = true ) } diff --git a/toolkit/samples/dmvapp/dmvapp-gui/.run/desktop.run.xml b/toolkit/samples/dmvapp/dmvapp-gui/.run/desktop.run.xml new file mode 100644 index 000000000..c7e694b9b --- /dev/null +++ b/toolkit/samples/dmvapp/dmvapp-gui/.run/desktop.run.xml @@ -0,0 +1,24 @@ + + + + + + + true + true + false + false + + + \ No newline at end of file diff --git a/toolkit/samples/dmvapp/dmvapp-gui/build.gradle.kts b/toolkit/samples/dmvapp/dmvapp-gui/build.gradle.kts new file mode 100644 index 000000000..d57f7d523 --- /dev/null +++ b/toolkit/samples/dmvapp/dmvapp-gui/build.gradle.kts @@ -0,0 +1,69 @@ + +import java.nio.file.Paths +import java.util.Properties +import kotlin.io.path.inputStream +import org.jetbrains.compose.desktop.application.dsl.TargetFormat + +plugins { + kotlin("jvm") + id("org.jetbrains.compose") + id("org.jetbrains.kotlin.plugin.compose") +} + +val mavenProperties: Properties = file(Paths.get("target", "project.properties")) + .inputStream() + .buffered() + .use { + Properties() + .apply { load(it) } + } + +version = mavenProperties["coner-trailer.version"] + ?: throw IllegalStateException("coner-trailer.version property missing from mavenProperties") + +repositories { + maven("target/dependencies") + mavenCentral() + maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") + google() +} + +dependencies { + implementation("tech.coner.trailer:toolkit-sample-dmvapp-common:$version") + + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-swing:${mavenProperties["kotlinx-coroutines.version"]}") + implementation(compose.desktop.currentOs) + implementation(compose.material3) + implementation(compose.materialIconsExtended) + implementation("tech.annexflow.compose:constraintlayout-compose-multiplatform:0.4.0") + implementation("com.materialkolor:material-kolor:1.7.0") + implementation("org.kodein.di:kodein-di-framework-compose:${mavenProperties["kodein-di.version"]}") + implementation("com.bumble.appyx:appyx-navigation-desktop:2.0.0") + + testImplementation(kotlin("test")) + @OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class) + testImplementation(compose.uiTest) + testImplementation(compose.desktop.currentOs) +} + +kotlin { + compilerOptions { + optIn.addAll( + "androidx.compose.foundation.ExperimentalFoundationApi", + "androidx.compose.material3.ExperimentalMaterial3Api", + "androidx.compose.ui.test.ExperimentalTestApi" + ) + } +} + +compose.desktop { + application { + mainClass = "tech.coner.trailer.toolkit.sample.dmvapp.gui.DmvAppGuiKt" + + nativeDistributions { + targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) + packageName = "toolkit-sample-dmvapp-gui" + packageVersion = "1.0.0" + } + } +} diff --git a/toolkit/samples/dmvapp/dmvapp-gui/gradle.properties b/toolkit/samples/dmvapp/dmvapp-gui/gradle.properties new file mode 100644 index 000000000..1b291c2c4 --- /dev/null +++ b/toolkit/samples/dmvapp/dmvapp-gui/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +kotlin.code.style=official +kotlin.version=2.0.0 +compose.version=1.6.10 diff --git a/toolkit/samples/dmvapp/dmvapp-gui/gradle/wrapper/gradle-wrapper.jar b/toolkit/samples/dmvapp/dmvapp-gui/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 000000000..249e5832f Binary files /dev/null and b/toolkit/samples/dmvapp/dmvapp-gui/gradle/wrapper/gradle-wrapper.jar differ diff --git a/toolkit/samples/dmvapp/dmvapp-gui/gradle/wrapper/gradle-wrapper.properties b/toolkit/samples/dmvapp/dmvapp-gui/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..48c0a02ca --- /dev/null +++ b/toolkit/samples/dmvapp/dmvapp-gui/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/toolkit/samples/dmvapp/dmvapp-gui/gradlew b/toolkit/samples/dmvapp/dmvapp-gui/gradlew new file mode 100755 index 000000000..1b6c78733 --- /dev/null +++ b/toolkit/samples/dmvapp/dmvapp-gui/gradlew @@ -0,0 +1,234 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/toolkit/samples/dmvapp/dmvapp-gui/gradlew.bat b/toolkit/samples/dmvapp/dmvapp-gui/gradlew.bat new file mode 100644 index 000000000..ac1b06f93 --- /dev/null +++ b/toolkit/samples/dmvapp/dmvapp-gui/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/toolkit/samples/dmvapp/dmvapp-gui/pom.xml b/toolkit/samples/dmvapp/dmvapp-gui/pom.xml new file mode 100644 index 000000000..f62c1c160 --- /dev/null +++ b/toolkit/samples/dmvapp/dmvapp-gui/pom.xml @@ -0,0 +1,137 @@ + + + 4.0.0 + + tech.coner.trailer + parent + 0.1.0-SNAPSHOT + ../../../../pom.xml + + + toolkit-sample-dmvapp-gui + + + ./gradlew + + + + + tech.coner.trailer + toolkit-sample-dmvapp-common + 0.1.0-SNAPSHOT + + + + + + + org.codehaus.mojo + properties-maven-plugin + + + generate-resources + + write-project-properties + + + ${project.build.directory}/project.properties + + + + + + org.apache.maven.plugins + maven-dependency-plugin + + + copy-dependencies + generate-resources + + copy-dependencies + + false + + true + true + ${project.build.directory}/dependencies + true + true + + + + + + org.codehaus.mojo + exec-maven-plugin + + + gradle-build + prepare-package + + ${gradle.executable} + + ${gradle.argument.daemon} + build + + + + exec + + + + + + maven-resources-plugin + + + copy-gradle-jars + package + + copy-resources + + + + ${basedir}/target + + + build/libs/ + + ${project.artifactId}-${project.version}.jar + ${project.artifactId}-${project.version}-javadoc.jar + ${project.artifactId}-${project.version}-sources.jar + + + + + + + + + + + + + gradle-default + + true + + + --daemon + + + + gradle-windows + + + windows + + + + + --no-daemon + + + + diff --git a/toolkit/samples/dmvapp/dmvapp-gui/settings.gradle.kts b/toolkit/samples/dmvapp/dmvapp-gui/settings.gradle.kts new file mode 100644 index 000000000..2956757d3 --- /dev/null +++ b/toolkit/samples/dmvapp/dmvapp-gui/settings.gradle.kts @@ -0,0 +1,16 @@ +pluginManagement { + repositories { + maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") + google() + gradlePluginPortal() + mavenCentral() + } + + plugins { + kotlin("jvm").version(extra["kotlin.version"] as String) + id("org.jetbrains.compose").version(extra["compose.version"] as String) + id("org.jetbrains.kotlin.plugin.compose").version(extra["kotlin.version"] as String) + } +} + +rootProject.name = "toolkit-sample-dmvapp-gui" diff --git a/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/DmvAppGui.kt b/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/DmvAppGui.kt new file mode 100644 index 000000000..4691b62c5 --- /dev/null +++ b/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/DmvAppGui.kt @@ -0,0 +1,42 @@ +package tech.coner.trailer.toolkit.sample.dmvapp.gui + +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.window.application +import org.kodein.di.DI +import org.kodein.di.compose.rememberInstance +import org.kodein.di.compose.withDI +import tech.coner.trailer.toolkit.sample.dmvapp.domain.service.impl.domainServiceModule +import tech.coner.trailer.toolkit.sample.dmvapp.domain.validation.domainValidationModule +import tech.coner.trailer.toolkit.sample.dmvapp.gui.presentation.entity.ThemeModePreference +import tech.coner.trailer.toolkit.sample.dmvapp.gui.presentation.presenter.SettingsPresenter +import tech.coner.trailer.toolkit.sample.dmvapp.gui.presentation.presenter.guiPresentationPresenterModule +import tech.coner.trailer.toolkit.sample.dmvapp.gui.theme.ConerTheme +import tech.coner.trailer.toolkit.sample.dmvapp.gui.window.DmvAppMainWindow +import tech.coner.trailer.toolkit.sample.dmvapp.presentation.adapter.presentationAdapterModule +import tech.coner.trailer.toolkit.sample.dmvapp.presentation.localization.presentationLocalizationModule +import tech.coner.trailer.toolkit.sample.dmvapp.presentation.presenter.presentationPresenterModule +import tech.coner.trailer.toolkit.sample.dmvapp.presentation.validation.presentationValidationModule + +fun main() = application { + withDI(di) { + val settingsPresenter: SettingsPresenter by rememberInstance() + val themeModePreference = settingsPresenter.themeMode.flow.collectAsState(ThemeModePreference.AUTO).value + ConerTheme( + themeModePreference = themeModePreference + ) { + DmvAppMainWindow() + } + } +} + +val di = DI { + importAll( + domainServiceModule, + domainValidationModule, + presentationLocalizationModule, + presentationPresenterModule, + presentationValidationModule, + presentationAdapterModule, + guiPresentationPresenterModule, + ) +} diff --git a/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/composable/ConerTopLevelNavigationDrawer.kt b/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/composable/ConerTopLevelNavigationDrawer.kt new file mode 100644 index 000000000..719c3549d --- /dev/null +++ b/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/composable/ConerTopLevelNavigationDrawer.kt @@ -0,0 +1,213 @@ +package tech.coner.trailer.toolkit.sample.dmvapp.gui.composable + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountBox +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.DrawerState +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalDrawerSheet +import androidx.compose.material3.ModalNavigationDrawer +import androidx.compose.material3.NavigationDrawerItem +import androidx.compose.material3.PermanentNavigationDrawer +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import org.kodein.di.compose.rememberInstance +import org.kodein.di.compose.withDI +import tech.coner.trailer.toolkit.sample.dmvapp.gui.di +import tech.coner.trailer.toolkit.sample.dmvapp.gui.presentation.presenter.SettingsPresenter +import tech.coner.trailer.toolkit.sample.dmvapp.gui.theme.ConerBrandColors +import tech.coner.trailer.toolkit.sample.dmvapp.gui.theme.ConerTheme +import tech.coner.trailer.toolkit.sample.dmvapp.gui.util.LocalWindowState +import tech.coner.trailer.toolkit.sample.dmvapp.gui.util.strings + +@Composable +fun ConerTopLevelNavigationDrawer( + drawerState: DrawerState, + heroTitle: String, + heroSubtitle: String, + innerDrawerContent: @Composable ColumnScope.() -> Unit, + content: @Composable () -> Unit +) { + val settingsPresenter: SettingsPresenter by rememberInstance() + val materialWindowSizeClass = LocalWindowState.current.materialSizeClass + val drawerContent: @Composable () -> Unit = { + ConerTopLevelNavigationDrawerContent( + heroTitle = heroTitle, + heroSubtitle = heroSubtitle, + innerDrawerContent = innerDrawerContent + ) + } + when (materialWindowSizeClass) { + MaterialWindowSizeClass.Compact, + MaterialWindowSizeClass.Medium -> ModalNavigationDrawer( + drawerContent = drawerContent, + drawerState = drawerState, + content = content + ) + MaterialWindowSizeClass.Expanded, + MaterialWindowSizeClass.Large, + MaterialWindowSizeClass.ExtraLarge -> PermanentNavigationDrawer( + drawerContent = drawerContent, + content = content + ) + } +} + +@Composable +private fun ConerTopLevelNavigationDrawerContent( + heroTitle: String, + heroSubtitle: String, + innerDrawerContent: @Composable ColumnScope.() -> Unit +) { + ModalDrawerSheet { + ConerTopLevelNavigationDrawerHero( + title = heroTitle, + subtitle = heroSubtitle, + ) + Column( + modifier = Modifier + .padding(28.dp) + ) { + innerDrawerContent() + } + } +} + +@Composable +private fun ColumnScope.ConerTopLevelNavigationDrawerHero( + title: String, + subtitle: String +) { + val settingsPresenter: SettingsPresenter by rememberInstance() + val materialWindowSizeClass = LocalWindowState.current.materialSizeClass + Box( + modifier = Modifier + .background(ConerBrandColors.LogoGray) + .height( + when (materialWindowSizeClass) { + MaterialWindowSizeClass.Compact -> 64.dp + MaterialWindowSizeClass.Medium -> 112.dp + MaterialWindowSizeClass.Expanded, + MaterialWindowSizeClass.Large, + MaterialWindowSizeClass.ExtraLarge -> 152.dp + } + ) + .padding(horizontal = 28.dp) + ) { + when (materialWindowSizeClass) { + MaterialWindowSizeClass.Compact -> Column( + verticalArrangement = Arrangement.Center, + modifier = Modifier + .fillMaxSize() + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + ) { + Image( + painter = painterResource("coner-icon/coner-icon_48.png"), + contentDescription = strings.conerLogoContentDescription, + ) + Spacer(Modifier.weight(1.0f)) + Column( + horizontalAlignment = Alignment.End + ) { + Text( + text = title, + color = ConerBrandColors.LogoWhite, + fontFamily = FontFamily.Monospace, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + ) + Text( + text = subtitle, + color = ConerBrandColors.LogoWhite, + fontFamily = FontFamily.Monospace, + style = MaterialTheme.typography.titleSmall, + maxLines = 1 + ) + } + } + } + MaterialWindowSizeClass.Medium, + MaterialWindowSizeClass.Expanded, + MaterialWindowSizeClass.Large, + MaterialWindowSizeClass.ExtraLarge -> Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier + .fillMaxSize() + ) { + Image( + painter = painterResource("coner-logo/coner-logo_128.png"), + contentDescription = strings.conerLogoContentDescription, + ) + Spacer(Modifier.size(8.dp)) + Text( + text = title, + color = ConerBrandColors.LogoWhite, + fontFamily = FontFamily.Monospace, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + ) + Text( + text = subtitle, + color = ConerBrandColors.LogoWhite, + fontFamily = FontFamily.Monospace, + style = MaterialTheme.typography.titleSmall, + maxLines = 1 + ) + } + } + } +} + +@Composable +@Preview +private fun ConerTopLevelNavigationDrawerPreview() { + withDI(di) { + ConerTheme { + ConerTopLevelNavigationDrawerContent( + heroTitle = "Hero Title", + heroSubtitle = "Hero Subtitle", + innerDrawerContent = { + listOf( + Icons.Default.AccountBox, + Icons.Default.Settings, + Icons.Default.Favorite, + ) + .forEachIndexed { index, icon -> + NavigationDrawerItem( + label = { Text(icon.name) }, + selected = index == 0, + onClick = {}, + icon = { Icon(icon, contentDescription = icon.name) }, + ) + } + } + ) + } + } +} \ No newline at end of file diff --git a/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/composable/ConerTopLevelTopAppBar.kt b/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/composable/ConerTopLevelTopAppBar.kt new file mode 100644 index 000000000..ddb7c7352 --- /dev/null +++ b/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/composable/ConerTopLevelTopAppBar.kt @@ -0,0 +1,44 @@ +package tech.coner.trailer.toolkit.sample.dmvapp.gui.composable + +import androidx.compose.foundation.layout.RowScope +import androidx.compose.material3.LargeTopAppBar +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MediumTopAppBar +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import tech.coner.trailer.toolkit.sample.dmvapp.gui.util.LocalWindowState + +@Composable +fun ConerTopLevelTopAppBar( + title: @Composable () -> Unit, + navigationIcon: @Composable () -> Unit = {}, + actions: @Composable (RowScope.() -> Unit) +) { + val materialWindowSizeClass = LocalWindowState.current.materialSizeClass + val colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + ) + when(materialWindowSizeClass) { + MaterialWindowSizeClass.Compact -> TopAppBar( + title = title, + navigationIcon = navigationIcon, + colors = colors, + actions = actions + ) + MaterialWindowSizeClass.Medium -> MediumTopAppBar( + title = title, + navigationIcon = navigationIcon, + colors = colors, + actions = actions + ) + MaterialWindowSizeClass.Expanded, + MaterialWindowSizeClass.Large, + MaterialWindowSizeClass.ExtraLarge -> LargeTopAppBar( + title = title, + // navigationIcon omitted -- this size class uses a standard navigation drawer (always open) + colors = colors, + actions = actions + ) + } +} diff --git a/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/composable/MaterialWindowSizeClass.kt b/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/composable/MaterialWindowSizeClass.kt new file mode 100644 index 000000000..e00b68938 --- /dev/null +++ b/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/composable/MaterialWindowSizeClass.kt @@ -0,0 +1,20 @@ +package tech.coner.trailer.toolkit.sample.dmvapp.gui.composable + +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.WindowState + +enum class MaterialWindowSizeClass( + val widthRange: ClosedRange +) { + + Compact(Int.MIN_VALUE..600), + Medium(600..839), + Expanded(840..1199), + Large(1200..1599), + ExtraLarge(1600..Int.MAX_VALUE); + + internal val widthRangeDp = widthRange.let { it.start.dp..it.endInclusive.dp } +} + +val WindowState.materialSizeClass: MaterialWindowSizeClass + get() = MaterialWindowSizeClass.entries.first { size.width in it.widthRangeDp } \ No newline at end of file diff --git a/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/composable/ValidationFeedback.kt b/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/composable/ValidationFeedback.kt new file mode 100644 index 000000000..d91147f58 --- /dev/null +++ b/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/composable/ValidationFeedback.kt @@ -0,0 +1,48 @@ +package tech.coner.trailer.toolkit.sample.dmvapp.gui.composable + +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import tech.coner.trailer.toolkit.sample.dmvapp.gui.util.strings +import tech.coner.trailer.toolkit.sample.dmvapp.presentation.validation.DriversLicenseApplicationModelFeedback + +@Composable +fun ValidationFeedbackSupportingText( + feedback: List, + testTagPrefix: String +): @Composable (() -> Unit) { + return when { + feedback.isNotEmpty() -> { + { + ValidationFeedbackSupportingTextContent( + feedback = feedback + .map { strings[it] }, + testTagPrefix = testTagPrefix + ) + } + } + else -> { + { + ValidationFeedbackSupportingTextContent(listOf(""), testTagPrefix) + } + } + } +} + +@Composable +private fun ValidationFeedbackSupportingTextContent( + feedback: List, + testTagPrefix: String +) { + Column { + feedback.forEach { + Text( + text = it, + modifier = Modifier + .testTag("${testTagPrefix}ValidationFeedback") + ) + } + } +} \ No newline at end of file diff --git a/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/presentation/entity/ThemeModePreference.kt b/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/presentation/entity/ThemeModePreference.kt new file mode 100644 index 000000000..4eee11f8c --- /dev/null +++ b/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/presentation/entity/ThemeModePreference.kt @@ -0,0 +1,7 @@ +package tech.coner.trailer.toolkit.sample.dmvapp.gui.presentation.entity + +enum class ThemeModePreference { + AUTO, + LIGHT, + DARK +} \ No newline at end of file diff --git a/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/presentation/presenter/GuiPresentationPresenterModule.kt b/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/presentation/presenter/GuiPresentationPresenterModule.kt new file mode 100644 index 000000000..2620ed70e --- /dev/null +++ b/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/presentation/presenter/GuiPresentationPresenterModule.kt @@ -0,0 +1,8 @@ +package tech.coner.trailer.toolkit.sample.dmvapp.gui.presentation.presenter + +import org.kodein.di.DI +import org.kodein.di.bindSingleton + +val guiPresentationPresenterModule by DI.Module { + bindSingleton { SettingsPresenter() } +} \ No newline at end of file diff --git a/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/presentation/presenter/SettingsPresenter.kt b/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/presentation/presenter/SettingsPresenter.kt new file mode 100644 index 000000000..3a472b030 --- /dev/null +++ b/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/presentation/presenter/SettingsPresenter.kt @@ -0,0 +1,20 @@ +package tech.coner.trailer.toolkit.sample.dmvapp.gui.presentation.presenter + +import kotlinx.coroutines.flow.StateFlow +import tech.coner.trailer.toolkit.presentation.presenter.StatefulPresenter +import tech.coner.trailer.toolkit.presentation.state.StateContainer +import tech.coner.trailer.toolkit.sample.dmvapp.gui.presentation.state.SettingsState + +class SettingsPresenter( + initialState: SettingsState = SettingsState() +) : StatefulPresenter { + + private val stateContainer = StateContainer(initialState) + override val state: SettingsState get() = stateContainer.state + override val stateFlow: StateFlow get() = stateContainer.stateFlow + + val themeMode = stateContainer.mutableStateProperty( + { themeMode }, + { newValue -> copy(themeMode = newValue) } + ) +} diff --git a/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/presentation/state/SettingsState.kt b/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/presentation/state/SettingsState.kt new file mode 100644 index 000000000..aff1d8b79 --- /dev/null +++ b/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/presentation/state/SettingsState.kt @@ -0,0 +1,8 @@ +package tech.coner.trailer.toolkit.sample.dmvapp.gui.presentation.state + +import tech.coner.trailer.toolkit.presentation.state.State +import tech.coner.trailer.toolkit.sample.dmvapp.gui.presentation.entity.ThemeModePreference + +data class SettingsState( + val themeMode: ThemeModePreference = ThemeModePreference.AUTO, +) : State diff --git a/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/screen/DmvAppScreen.kt b/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/screen/DmvAppScreen.kt new file mode 100644 index 000000000..ec6d09c46 --- /dev/null +++ b/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/screen/DmvAppScreen.kt @@ -0,0 +1,8 @@ +package tech.coner.trailer.toolkit.sample.dmvapp.gui.screen + +import kotlin.random.Random + +sealed class DmvAppScreen { + data class DriversLicenseApplication(private val index: Int = Random.nextInt()) : DmvAppScreen() + data object Settings: DmvAppScreen() +} \ No newline at end of file diff --git a/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/screen/ExceptionScreen.kt b/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/screen/ExceptionScreen.kt new file mode 100644 index 000000000..436bdb86b --- /dev/null +++ b/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/screen/ExceptionScreen.kt @@ -0,0 +1,60 @@ +package tech.coner.trailer.toolkit.sample.dmvapp.gui.screen + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.VerticalScrollbar +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.rememberScrollableState +import androidx.compose.foundation.gestures.scrollable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.rememberScrollbarAdapter +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import tech.coner.trailer.toolkit.sample.dmvapp.gui.theme.ConerTheme + +@Composable +fun ExceptionScreen( + cause: Throwable, + onCloseRequest: () -> Unit +) { + Surface( + modifier = Modifier.fillMaxSize() + ) { + val scrollState = rememberScrollState() + Box { + Column( + modifier = Modifier + .padding(16.dp) + .verticalScroll(scrollState) + ) { + OutlinedTextField( + value = cause.stackTraceToString(), + onValueChange = {}, + readOnly = true, + ) + } + VerticalScrollbar( + adapter = rememberScrollbarAdapter(scrollState), + modifier = Modifier + .align(Alignment.CenterEnd) + .fillMaxHeight() + ) + } + } +} + +@Preview +@Composable +fun ExceptionScreenPreview() { + ConerTheme { + ExceptionScreen( + cause = Exception("Preview exception message"), + onCloseRequest = {} + ) + } +} \ No newline at end of file diff --git a/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/screen/driverslicenseapplication/DriversLicenseApplicationForm.kt b/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/screen/driverslicenseapplication/DriversLicenseApplicationForm.kt new file mode 100644 index 000000000..46e780336 --- /dev/null +++ b/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/screen/driverslicenseapplication/DriversLicenseApplicationForm.kt @@ -0,0 +1,425 @@ + +package tech.coner.trailer.toolkit.sample.dmvapp.gui.screen.driverslicenseapplication + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Undo +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import org.kodein.di.compose.rememberInstance +import org.kodein.di.compose.withDI +import tech.coner.trailer.toolkit.sample.dmvapp.domain.entity.LicenseType +import tech.coner.trailer.toolkit.sample.dmvapp.gui.composable.ConerTopLevelTopAppBar +import tech.coner.trailer.toolkit.sample.dmvapp.gui.composable.ValidationFeedbackSupportingText +import tech.coner.trailer.toolkit.sample.dmvapp.gui.di +import tech.coner.trailer.toolkit.sample.dmvapp.gui.screen.DmvAppScreen +import tech.coner.trailer.toolkit.sample.dmvapp.gui.theme.ConerTheme +import tech.coner.trailer.toolkit.sample.dmvapp.gui.util.* +import tech.coner.trailer.toolkit.sample.dmvapp.gui.util.FocusFirstFocusRequester.Companion.FocusFirstFocusRequester +import tech.coner.trailer.toolkit.sample.dmvapp.gui.window.DriversLicenseWindow +import tech.coner.trailer.toolkit.sample.dmvapp.gui.window.ExceptionWindow +import tech.coner.trailer.toolkit.sample.dmvapp.presentation.model.DriversLicenseApplicationModel +import tech.coner.trailer.toolkit.sample.dmvapp.presentation.model.DriversLicenseApplicationRejectionModel +import tech.coner.trailer.toolkit.sample.dmvapp.presentation.presenter.DriversLicenseApplicationPresenter +import tech.coner.trailer.toolkit.sample.dmvapp.presentation.validation.DriversLicenseApplicationModelFeedback +import tech.coner.trailer.toolkit.validation.Severity +import tech.coner.trailer.toolkit.validation.isInvalid + +@Composable +fun DriversLicenseApplicationFormScreen( + screen: DmvAppScreen.DriversLicenseApplication, + openNavigationDrawer: () -> Unit +) { + val presenter: DriversLicenseApplicationPresenter by rememberInstance() + + val coroutineScope = rememberCoroutineScope { Dispatchers.Main.immediate + CoroutineName("DriversLicenseApplicationFormScreen") } + + val focusFirstError = remember { + FocusFirstFocusRequester( + properties = listOf( + DriversLicenseApplicationModel::name, + DriversLicenseApplicationModel::age, + DriversLicenseApplicationModel::licenseType + ) + ) + } + + val name = remember { mutableStateOf("") } + val age = remember { mutableStateOf("") } + + val performReset: () -> Unit = { + coroutineScope.launch { + name.value = "" + age.value = "" + presenter.reset() + } + } + + presenter.stateFlow + .map { it.outcome } + .collectAsState(null).value + ?.onSuccess { either -> + either + .onLeft { rejection -> + val rejectionMessage = when (rejection) { + is DriversLicenseApplicationRejectionModel.Sassed -> strings[rejection.sass] + is DriversLicenseApplicationRejectionModel.LegallyProhibited -> strings.driversLicenseApplicationRejectionLegallyProhibited + is DriversLicenseApplicationRejectionModel.Invalid -> null + } + if (rejectionMessage != null) { + DriversLicenseApplicationRejectionDialog( + onDismissRequest = { + coroutineScope.launch { + presenter.clearOutcome() + } + }, + rejectionMessage = rejectionMessage + ) + } + } + .onRight { driversLicense -> + DriversLicenseWindow( + driversLicense = driversLicense, + onCloseRequest = performReset + ) + } + } + ?.onFailure { + ExceptionWindow( + cause = it, + onCloseRequest = { + coroutineScope.launch { + presenter.clearOutcome() + } + } + ) + } + + + + var serviceSettingsDisplayed by remember { mutableStateOf(false) } + if (serviceSettingsDisplayed) { + DriversLicenseApplicationServiceSettingsDialog( + onDismissRequest = { serviceSettingsDisplayed = false } + ) + } + + DisposableEffect(screen) { + with(coroutineScope) { + presenter.validationOutcomeFlow + .onEach { validationResult -> + validationResult.feedbackByProperty.entries + .firstOrNull { (_, feedbacks) -> + feedbacks.any { + it.severity == Severity.Error + } + } + ?.key + ?.also { property -> focusFirstError.requestFocus(property) } + } + .launchIn(this@with) + } + onDispose { + runBlocking { presenter.reset() } + coroutineScope.cancel() + } + } + + DriversLicenseApplicationFormContent( + openNavigationDrawer = openNavigationDrawer, + fieldsEditable = presenter.fieldsEditable.collectAsState().value, + name = name, + onNameChange = { presenter.name.value = it }, + nameFeedback = presenter.name.collectFeedbackAsState().value, + age = age, + onAgeChange = { presenter.age.value = it.toIntOrNull() }, + ageFeedback = presenter.age.collectFeedbackAsState().value, + licenseType = presenter.licenseType.collectValueAsState().value, + onLicenseTypeChange = { presenter.licenseType.value = it }, + licenseTypeFeedback = presenter.licenseType.collectFeedbackAsState().value, + displayServiceSettings = { serviceSettingsDisplayed = true }, + performReset = performReset, + resetButtonEnabled = presenter.canResetFlow.collectAsState(false).value, + applyButtonEnabled = presenter.canApplyFlow.collectAsState(true).value, + onApplyButtonClicked = { coroutineScope.launch { presenter.submitApplication() } }, + processingApplication = presenter.processingApplication.collectAsState().value, + focusFirstFocusRequester = focusFirstError + ) +} + +@Composable +fun DriversLicenseApplicationFormContent( + openNavigationDrawer: () -> Unit, + fieldsEditable: Boolean, + name: MutableState, + onNameChange: (String) -> Unit, + nameFeedback: List, + age: MutableState, + onAgeChange: (String) -> Unit, + ageFeedback: List, + licenseType: LicenseType?, + onLicenseTypeChange: (LicenseType) -> Unit, + licenseTypeFeedback: List, + displayServiceSettings: () -> Unit, + performReset: () -> Unit, + resetButtonEnabled: Boolean, + applyButtonEnabled: Boolean, + onApplyButtonClicked: () -> Unit, + processingApplication: Boolean, + focusFirstFocusRequester: FocusFirstFocusRequester +) { + Column { + ConerTopLevelTopAppBar( + title = { Text(strings.driversLicenseApplicationHeading) }, + navigationIcon = { + IconButton( + onClick = openNavigationDrawer + ) { + Icon(Icons.Default.Menu, contentDescription = strings.menuContentDescription) + } + }, + actions = { + IconButton( + onClick = displayServiceSettings, + modifier = Modifier + .testTag("driversLicenseApplicationServiceSettingsButton") + ) { + Icon( + painter = rememberVectorPainter(Icons.Filled.Settings), + contentDescription = strings.driversLicenseApplicationServiceSettings, + modifier = Modifier + .testTag("driversLicenseApplicationServiceSettingsIcon") + ) + } + IconButton( + onClick = performReset, + enabled = resetButtonEnabled, + modifier = Modifier + .testTag("resetButton") + ) { + Icon( + painter = rememberVectorPainter(Icons.AutoMirrored.Filled.Undo), + contentDescription = strings.driversLicenseApplicationFormReset, + modifier = Modifier + .testTag("resetButtonIcon"), + ) + } + } + ) + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxSize() + ) { + OutlinedCard( + modifier = Modifier + .width(432.dp) + ) { + Column( + modifier = Modifier + .padding(16.dp) + ) { + Row { + OutlinedTextField( + value = name.value, + label = { + Text( + text = strings.driversLicenseNameField, + modifier = Modifier + .testTag("nameField") + ) + }, + singleLine = true, + onValueChange = { + name.value = it + onNameChange(it) + }, + isError = nameFeedback.isInvalid, + supportingText = ValidationFeedbackSupportingText(nameFeedback, "name"), + readOnly = !fieldsEditable, + modifier = Modifier + .fillMaxWidth() + .focusOnError(focusFirstFocusRequester, DriversLicenseApplicationModel::name) + .testTag("name") + ) + } + Row { + OutlinedTextField( + value = age.value, + label = { + Text( + text = strings.driversLicenseAgeField, + modifier = Modifier + .testTag("ageField") + ) + }, + singleLine = true, + onValueChange = { + if (it.isEmpty() || it.all { char -> char.isDigit() }) { + age.value = it + onAgeChange(it) + } + }, + isError = ageFeedback.isInvalid, + supportingText = ValidationFeedbackSupportingText(ageFeedback, "age"), + readOnly = !fieldsEditable, + modifier = Modifier + .fillMaxWidth() + .focusOnError(focusFirstFocusRequester, DriversLicenseApplicationModel::age) + .testTag("age") + ) + } + Row { + var expanded by remember { mutableStateOf(false) } + val guardedMutateExpanded: (Boolean) -> Unit = { + if (fieldsEditable || !it) expanded = it + } + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = guardedMutateExpanded, + modifier = Modifier + .testTag("licenseTypeDropdownMenuBox") + ) { + OutlinedTextField( + value = licenseType.let { strings.getNullable(it) }, + label = { + Text( + text = strings.driversLicenseLicenseTypeField, + modifier = Modifier + .testTag("licenseTypeField") + ) + }, + readOnly = true, + onValueChange = { }, + isError = licenseTypeFeedback.isInvalid, + supportingText = ValidationFeedbackSupportingText(licenseTypeFeedback, "licenseType"), + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) + }, + modifier = Modifier + .fillMaxWidth() + .menuAnchor() + .onKeyEvent { + when { + it.key == Key.DirectionDown -> { guardedMutateExpanded(true); true } + else -> false + } + } + .focusOnError(focusFirstFocusRequester, DriversLicenseApplicationModel::licenseType) + .testTag("licenseType") + ) + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { guardedMutateExpanded(false) }, + modifier = Modifier + .testTag("licenseTypeDropdownMenu") + ) { + strings.licenseTypeLabels.forEach { pair -> + DropdownMenuItem( + text = { + Text( + text = pair.second, + modifier = Modifier + .testTag("licenseTypeDropdownMenuItemText") + ) + }, + onClick = { + guardedMutateExpanded(false) + onLicenseTypeChange(pair.first) + }, + modifier = Modifier + .testTag("licenseTypeDropdownMenuItem") + ) + } + } + } + } + Box( + modifier = Modifier + .fillMaxWidth() + ) { + Button( + onClick = onApplyButtonClicked, + enabled = applyButtonEnabled, + modifier = Modifier + .align(Alignment.CenterEnd) + .testTag("applyButton") + ) { + Row { + if (processingApplication) { + CircularProgressIndicator( + modifier = Modifier + .size(16.dp) + .align(Alignment.CenterVertically) + .testTag("applyButtonProgress") + ) + Spacer( + modifier = Modifier + .width(8.dp) + ) + } + Text( + text = strings.driversLicenseApplicationFormApply, + modifier = Modifier + .testTag("applyButtonText") + ) + } + } + } + } + } + } + } +} + +@Composable +@Preview +private fun DriversLicenseApplicationFormPreview() { + withDI(di) { + ConerTheme { + Scaffold { + DriversLicenseApplicationFormContent( + openNavigationDrawer= {}, + fieldsEditable = true, + name = mutableStateOf(""), + onNameChange = {}, + nameFeedback = emptyList(), + age = mutableStateOf(""), + onAgeChange = {}, + ageFeedback = emptyList(), + licenseType = null, + onLicenseTypeChange = {}, + licenseTypeFeedback = emptyList(), + displayServiceSettings = {}, + performReset = {}, + resetButtonEnabled = false, + applyButtonEnabled = true, + onApplyButtonClicked = {}, + processingApplication = false, + FocusFirstFocusRequester( + listOf( + DriversLicenseApplicationModel::name, + DriversLicenseApplicationModel::age, + DriversLicenseApplicationModel::licenseType + ) + ) + ) + } + } + } +} diff --git a/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/screen/driverslicenseapplication/DriversLicenseApplicationRejectionDialog.kt b/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/screen/driverslicenseapplication/DriversLicenseApplicationRejectionDialog.kt new file mode 100644 index 000000000..da2d74417 --- /dev/null +++ b/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/screen/driverslicenseapplication/DriversLicenseApplicationRejectionDialog.kt @@ -0,0 +1,57 @@ +package tech.coner.trailer.toolkit.sample.dmvapp.gui.screen.driverslicenseapplication + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material3.AlertDialogDefaults +import androidx.compose.material3.BasicAlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import tech.coner.trailer.toolkit.sample.dmvapp.gui.util.LocalStrings + +@Composable +fun DriversLicenseApplicationRejectionDialog( + onDismissRequest: () -> Unit, + rejectionMessage: String +) { + val strings = LocalStrings.current + BasicAlertDialog( + onDismissRequest = onDismissRequest + ) { + Surface( + modifier = Modifier.wrapContentSize(), + shape = MaterialTheme.shapes.large, + tonalElevation = AlertDialogDefaults.TonalElevation + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + strings.driversLicenseApplicationRejectionTitle, + style = MaterialTheme.typography.titleSmall + ) + Spacer( + Modifier.size(8.dp) + ) + Text(rejectionMessage) + Spacer( + Modifier.size(8.dp) + ) + Button( + modifier = Modifier.align(Alignment.End), + onClick = onDismissRequest + ) { + Text(strings.ok) + } + } + } + } +} diff --git a/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/screen/driverslicenseapplication/DriversLicenseApplicationServiceSettingsDialog.kt b/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/screen/driverslicenseapplication/DriversLicenseApplicationServiceSettingsDialog.kt new file mode 100644 index 000000000..61d581b72 --- /dev/null +++ b/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/screen/driverslicenseapplication/DriversLicenseApplicationServiceSettingsDialog.kt @@ -0,0 +1,103 @@ +package tech.coner.trailer.toolkit.sample.dmvapp.gui.screen.driverslicenseapplication + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material3.AlertDialogDefaults +import androidx.compose.material3.BasicAlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import org.kodein.di.compose.rememberDI +import org.kodein.di.instance +import tech.coner.trailer.toolkit.sample.dmvapp.domain.service.impl.DriversLicenseApplicationServiceImpl +import tech.coner.trailer.toolkit.sample.dmvapp.gui.util.LocalStrings + +@Composable +fun DriversLicenseApplicationServiceSettingsDialog( + onDismissRequest: () -> Unit +) { + val strings = LocalStrings.current + val service: DriversLicenseApplicationServiceImpl by rememberDI { instance() } + BasicAlertDialog( + onDismissRequest = onDismissRequest + ) { + Surface( + modifier = Modifier.wrapContentSize(), + shape = MaterialTheme.shapes.large, + tonalElevation = AlertDialogDefaults.TonalElevation + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + strings.driversLicenseApplicationServiceSettings, + style = MaterialTheme.typography.titleSmall + ) + + Spacer(Modifier.size(8.dp)) + + var buildingOnFireChance by remember { mutableFloatStateOf(service.buildingOnFireChance) } + FloatSetting( + label = strings.driversLicenseApplicationServiceBuildingOnFireChance, + value = buildingOnFireChance, + onValueChange = { buildingOnFireChance = it } + ) + + var sassChance by remember { mutableFloatStateOf(service.sassChance) } + FloatSetting( + label = strings.driversLicenseApplicationServiceSassChance, + value = sassChance, + onValueChange = { sassChance = it }, + ) + + var legallyProhibitedChance by remember { mutableFloatStateOf(service.legallyProhibitedChance) } + FloatSetting( + label = strings.driversLicenseApplicationServiceLegallyProhibitedChance, + value = legallyProhibitedChance, + onValueChange = { legallyProhibitedChance = it }, + ) + + Button( + onClick = { + service.buildingOnFireChance = buildingOnFireChance + service.sassChance = sassChance + service.legallyProhibitedChance = legallyProhibitedChance + onDismissRequest() + }, + modifier = Modifier + .align(Alignment.End) + ) { + Text(strings.ok) + } + } + } + } +} + +@Composable +private fun FloatSetting( + label: String, + value: Float, + onValueChange: (Float) -> Unit, + valueRange: ClosedFloatingPointRange = 0f..1f, +) { + Text(label) + Slider( + value = value, + onValueChange = onValueChange, + valueRange = valueRange + ) +} diff --git a/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/screen/driverslicenseapplication/DriversLicenseScreen.kt b/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/screen/driverslicenseapplication/DriversLicenseScreen.kt new file mode 100644 index 000000000..3571e1100 --- /dev/null +++ b/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/screen/driverslicenseapplication/DriversLicenseScreen.kt @@ -0,0 +1,172 @@ +package tech.coner.trailer.toolkit.sample.dmvapp.gui.screen.driverslicenseapplication + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.constraintlayout.compose.ChainStyle +import androidx.constraintlayout.compose.ConstraintLayout +import org.kodein.di.compose.withDI +import tech.coner.trailer.toolkit.sample.dmvapp.domain.entity.DriversLicense +import tech.coner.trailer.toolkit.sample.dmvapp.domain.entity.LicenseType +import tech.coner.trailer.toolkit.sample.dmvapp.gui.di +import tech.coner.trailer.toolkit.sample.dmvapp.gui.theme.ConerBrandColors +import tech.coner.trailer.toolkit.sample.dmvapp.gui.theme.ConerTheme +import tech.coner.trailer.toolkit.sample.dmvapp.gui.util.strings + +@Composable +fun DriversLicenseScreen( + driversLicense: DriversLicense +) { + DriversLicenseContent( + driversLicense = driversLicense + ) +} + +@Composable +private fun DriversLicenseContent( + driversLicense: DriversLicense +) { + Column { + ConstraintLayout( + modifier = Modifier + .background(ConerBrandColors.LogoGray) + .padding(16.dp) + .fillMaxWidth() + ) { + val (dmv, heading, dmvMotto) = createRefs() + createHorizontalChain(dmv, heading, chainStyle = ChainStyle.SpreadInside) + Text( + text = strings.dmvLabel, + color = ConerBrandColors.LogoWhite, + modifier = Modifier + .constrainAs(dmv) { + start.linkTo(parent.start) + end.linkTo(heading.start, margin = 32.dp) + } + ) + Text( + text = strings.driversLicenseHeading, + color = ConerBrandColors.LogoWhite, + textAlign = TextAlign.End, + modifier = Modifier + .constrainAs(heading) { + start.linkTo(dmv.end, margin = 32.dp) + end.linkTo(parent.end) + } + ) + Text( + text = strings.dmvMotto, + color = ConerBrandColors.LogoWhite, + fontStyle = FontStyle.Italic, + modifier = Modifier + .constrainAs(dmvMotto) { + start.linkTo(parent.start) + top.linkTo(dmv.bottom) + } + ) + } + Spacer(Modifier.height(16.dp)) + ConstraintLayout { + val (photoBox, nameField, name, ageField, age, typeField, type) = createRefs() + val fieldStartBarrier = createEndBarrier(photoBox, margin = 16.dp) + val fieldEndBarrier = createEndBarrier(nameField, ageField, typeField) + val fieldTopBarrier = createTopBarrier(nameField, ageField, typeField) + val fieldBottomBarrier = createBottomBarrier(nameField, ageField, typeField) + Surface( + tonalElevation = 8.dp, + modifier = Modifier + .constrainAs(photoBox) { + start.linkTo(parent.start, margin = 16.dp) + top.linkTo(fieldTopBarrier) + bottom.linkTo(fieldBottomBarrier) + } + ) { + Text( + text = strings.driversLicensePhotoPlaceholder + ) + } + Text( + text = strings.driversLicenseNameField, + modifier = Modifier + .constrainAs(nameField) { + start.linkTo(fieldStartBarrier) + top.linkTo(parent.top) + bottom.linkTo(name.bottom) + } + ) + Text( + text = driversLicense.name, + modifier = Modifier + .constrainAs(name) { + start.linkTo(fieldEndBarrier, margin = 8.dp) + top.linkTo(nameField.top) + end.linkTo(parent.end) + } + ) + Text( + text = strings.driversLicenseAgeField, + modifier = Modifier + .constrainAs(ageField) { + start.linkTo(fieldStartBarrier) + top.linkTo(nameField.bottom) + bottom.linkTo(age.bottom) + } + ) + Text( + text = driversLicense.age.toString(), + modifier = Modifier + .constrainAs(age) { + start.linkTo(name.start) + top.linkTo(ageField.top) + } + ) + Text( + text = strings.driversLicenseLicenseTypeField, + modifier = Modifier + .constrainAs(typeField) { + start.linkTo(fieldStartBarrier) + top.linkTo(ageField.bottom) + bottom.linkTo(type.bottom) + } + ) + Text( + text = strings[driversLicense.licenseType], + modifier = Modifier + .constrainAs(type) { + start.linkTo(name.start) + top.linkTo(typeField.top) + } + ) + } + } +} + +@Composable +@Preview +private fun DriversLicensePreview() { + withDI(di) { + ConerTheme { + Scaffold { + DriversLicenseContent( + driversLicense = DriversLicense( + name = "John Doe", + age = 18, + licenseType = LicenseType.FullLicense + ) + ) + } + } + } +} diff --git a/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/screen/main/DmvAppMainScreen.kt b/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/screen/main/DmvAppMainScreen.kt new file mode 100644 index 000000000..2babeb527 --- /dev/null +++ b/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/screen/main/DmvAppMainScreen.kt @@ -0,0 +1,56 @@ +package tech.coner.trailer.toolkit.sample.dmvapp.gui.screen.main + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.material3.DrawerValue +import androidx.compose.material3.Scaffold +import androidx.compose.material3.rememberDrawerState +import androidx.compose.runtime.* +import kotlinx.coroutines.launch +import org.kodein.di.compose.rememberDI +import org.kodein.di.compose.rememberInstance +import org.kodein.di.instance +import tech.coner.trailer.toolkit.sample.dmvapp.gui.presentation.entity.ThemeModePreference +import tech.coner.trailer.toolkit.sample.dmvapp.gui.presentation.presenter.SettingsPresenter +import tech.coner.trailer.toolkit.sample.dmvapp.gui.screen.DmvAppScreen +import tech.coner.trailer.toolkit.sample.dmvapp.gui.screen.driverslicenseapplication.DriversLicenseApplicationFormScreen +import tech.coner.trailer.toolkit.sample.dmvapp.gui.screen.settings.SettingsScreen +import tech.coner.trailer.toolkit.sample.dmvapp.gui.theme.ConerTheme +import tech.coner.trailer.toolkit.sample.dmvapp.presentation.localization.Strings + +@Composable +@Preview +fun DmvAppMainScreen() { + val strings: Strings by rememberInstance() + val selectedScreen = remember { + mutableStateOf(DmvAppScreen.DriversLicenseApplication()) + } + val drawerState = rememberDrawerState(DrawerValue.Closed) + val coroutineScope = rememberCoroutineScope() + val openNavigationDrawer: () -> Unit = remember { + { + coroutineScope.launch { + drawerState.open() + } + } + } + Scaffold { + DmvAppNavigationDrawerScreen( + drawerState = drawerState, + selectedScreen = selectedScreen + ) { + when (val screen = selectedScreen.value) { + is DmvAppScreen.DriversLicenseApplication -> { + DriversLicenseApplicationFormScreen( + screen = screen, + openNavigationDrawer = openNavigationDrawer + ) + } + DmvAppScreen.Settings -> { + SettingsScreen( + openNavigationDrawer = openNavigationDrawer + ) + } + } + } + } +} diff --git a/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/screen/main/DmvAppNavigationDrawerScreen.kt b/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/screen/main/DmvAppNavigationDrawerScreen.kt new file mode 100644 index 000000000..29fc3e2db --- /dev/null +++ b/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/screen/main/DmvAppNavigationDrawerScreen.kt @@ -0,0 +1,97 @@ +package tech.coner.trailer.toolkit.sample.dmvapp.gui.screen.main + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.layout.Spacer +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountBox +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.DrawerState +import androidx.compose.material3.DrawerValue +import androidx.compose.material3.Icon +import androidx.compose.material3.NavigationDrawerItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.rememberDrawerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import kotlinx.coroutines.launch +import org.kodein.di.compose.rememberInstance +import org.kodein.di.compose.withDI +import tech.coner.trailer.toolkit.sample.dmvapp.gui.composable.ConerTopLevelNavigationDrawer +import tech.coner.trailer.toolkit.sample.dmvapp.gui.di +import tech.coner.trailer.toolkit.sample.dmvapp.gui.screen.DmvAppScreen +import tech.coner.trailer.toolkit.sample.dmvapp.gui.theme.ConerTheme +import tech.coner.trailer.toolkit.sample.dmvapp.gui.util.strings +import tech.coner.trailer.toolkit.sample.dmvapp.presentation.localization.Strings + +@Composable +fun DmvAppNavigationDrawerScreen( + drawerState: DrawerState, + selectedScreen: MutableState, + appContent: @Composable () -> Unit +) { + val strings: Strings by rememberInstance() + DmvAppNavigationDrawerContent( + drawerState = drawerState, + selectedScreen = selectedScreen, + appContent = appContent + ) +} + +@Composable +fun DmvAppNavigationDrawerContent( + drawerState: DrawerState, + selectedScreen: MutableState, + appContent: @Composable () -> Unit +) { + val coroutineScope = rememberCoroutineScope() + val closeAndSelect: (DmvAppScreen) -> Unit = remember { + { + coroutineScope.launch { + drawerState.close() + } + selectedScreen.value = it + } + } + ConerTopLevelNavigationDrawer( + drawerState = drawerState, + heroTitle = strings.dmvLabel, + heroSubtitle = strings.dmvMotto, + innerDrawerContent = { + NavigationDrawerItem( + label = { Text(strings.driversLicenseApplicationHeading) }, + icon = { Icon(Icons.Default.AccountBox, strings.driversLicenseApplicationHeading) }, + selected = selectedScreen.value is DmvAppScreen.DriversLicenseApplication, + onClick = { closeAndSelect(DmvAppScreen.DriversLicenseApplication()) } + ) + Spacer(Modifier.weight(1f)) + NavigationDrawerItem( + label = { Text(strings.settings) }, + icon = { Icon(Icons.Default.Settings, strings.settings) }, + selected = selectedScreen.value == DmvAppScreen.Settings, + onClick = { closeAndSelect(DmvAppScreen.Settings) } + ) + }, + content = appContent + ) +} + +@Composable +@Preview +private fun DmvAppNavigationDrawerScreenPreview() { + withDI(di) { + ConerTheme { + Scaffold { + DmvAppNavigationDrawerContent( + drawerState = rememberDrawerState(DrawerValue.Open), + selectedScreen = remember { mutableStateOf(DmvAppScreen.DriversLicenseApplication()) }, + appContent = {} + ) + } + } + } +} \ No newline at end of file diff --git a/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/screen/settings/SettingsScreen.kt b/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/screen/settings/SettingsScreen.kt new file mode 100644 index 000000000..5f2471324 --- /dev/null +++ b/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/screen/settings/SettingsScreen.kt @@ -0,0 +1,178 @@ +package tech.coner.trailer.toolkit.sample.dmvapp.gui.screen.settings + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material3.Card +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import org.kodein.di.compose.rememberInstance +import org.kodein.di.compose.withDI +import tech.coner.trailer.toolkit.sample.dmvapp.gui.composable.ConerTopLevelTopAppBar +import tech.coner.trailer.toolkit.sample.dmvapp.gui.di +import tech.coner.trailer.toolkit.sample.dmvapp.gui.presentation.entity.ThemeModePreference +import tech.coner.trailer.toolkit.sample.dmvapp.gui.presentation.presenter.SettingsPresenter +import tech.coner.trailer.toolkit.sample.dmvapp.gui.theme.ConerTheme +import tech.coner.trailer.toolkit.sample.dmvapp.gui.util.collectAsState +import tech.coner.trailer.toolkit.sample.dmvapp.gui.util.strings +import tech.coner.trailer.toolkit.sample.dmvapp.presentation.localization.Strings + +@Composable +fun SettingsScreen(openNavigationDrawer: () -> Unit) { + val strings: Strings by rememberInstance() + val presenter: SettingsPresenter by rememberInstance() + SettingsContent( + openNavigationDrawer, + themeMode = presenter.themeMode.collectAsState().value, + setThemeMode = { presenter.themeMode.value = it } + ) +} + +@Composable +fun SettingsContent( + openNavigationDrawer: () -> Unit, + themeMode: ThemeModePreference, + setThemeMode: (ThemeModePreference) -> Unit +) { + val coroutineScope = rememberCoroutineScope() + Column { + ConerTopLevelTopAppBar( + title = { Text(text = strings.settings) }, + navigationIcon = { + IconButton( + onClick = openNavigationDrawer + ) { + Icon(Icons.Default.Menu, contentDescription = strings.menuContentDescription) + } + }, + actions = {}, + ) + LazyColumn( + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier + .padding(16.dp) + ) { + item { + ThemeCard( + themeMode = themeMode, + setThemeMode = setThemeMode + ) + } + } + } +} + +@Composable +private fun ThemeCard( + themeMode: ThemeModePreference, + setThemeMode: (ThemeModePreference) -> Unit +) { + @Composable + fun ThemeModeRow( + selected: Boolean, + onClick: () -> Unit, + text: String + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.clickable(onClick = onClick), + ) { + RadioButton( + selected = selected, + onClick = null + ) + Spacer(Modifier.size(8.dp)) + Text( + text = text, + style = MaterialTheme.typography.labelLarge + ) + } + } + + SettingsCard( + title = strings.settingsThemeTitle + ) { + Text( + text = strings.settingsThemeModeTitle, + style = MaterialTheme.typography.titleMedium + ) + ThemeModeRow( + selected = themeMode == ThemeModePreference.AUTO, + onClick = { setThemeMode(ThemeModePreference.AUTO) }, + text = strings.settingsThemeModeAuto + ) + ThemeModeRow( + selected = themeMode == ThemeModePreference.LIGHT, + onClick = { setThemeMode(ThemeModePreference.LIGHT) }, + text = strings.settingsThemeModeLight + ) + ThemeModeRow( + selected = themeMode == ThemeModePreference.DARK, + onClick = { setThemeMode(ThemeModePreference.DARK) }, + text = strings.settingsThemeModeDark + ) + } +} + +@Composable +private fun SettingsCard( + title: String, + content: @Composable ColumnScope.() -> Unit +) { + Card( + modifier = Modifier + .fillMaxWidth() + ) { + + Column( + modifier = Modifier + .padding(16.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.titleLarge + ) + Spacer(Modifier.height(16.dp)) + content() + } + } +} + +@Preview +@Composable +fun SettingsPreview() { + withDI(di) { + val presenter: SettingsPresenter by rememberInstance() + ConerTheme { + Scaffold { + SettingsContent( + openNavigationDrawer = {}, + themeMode = presenter.themeMode.value, + setThemeMode = {} + ) + } + } + } +} \ No newline at end of file diff --git a/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/theme/ConerBrandColors.kt b/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/theme/ConerBrandColors.kt new file mode 100644 index 000000000..1a760593b --- /dev/null +++ b/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/theme/ConerBrandColors.kt @@ -0,0 +1,9 @@ +package tech.coner.trailer.toolkit.sample.dmvapp.gui.theme + +import androidx.compose.ui.graphics.Color + +object ConerBrandColors { + val LogoOrange = Color(0xFFF15A24) + val LogoGray = Color(0xFF808080) + val LogoWhite = Color(0xFFFFFFFF) +} \ No newline at end of file diff --git a/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/theme/ConerTheme.kt b/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/theme/ConerTheme.kt new file mode 100644 index 000000000..76e77341b --- /dev/null +++ b/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/theme/ConerTheme.kt @@ -0,0 +1,27 @@ +package tech.coner.trailer.toolkit.sample.dmvapp.gui.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import tech.coner.trailer.toolkit.sample.dmvapp.gui.presentation.entity.ThemeModePreference + +@Composable +fun ConerTheme( + themeModePreference: ThemeModePreference = ThemeModePreference.AUTO, + content: @Composable () -> Unit +) { + val colors = ConerThemeColorsGenerated20240628_2 + MaterialTheme( + colorScheme = with(colors) { + when (themeModePreference) { + ThemeModePreference.AUTO -> if (isSystemInDarkTheme()) + darkColorScheme() + else + lightColorScheme() + ThemeModePreference.LIGHT -> lightColorScheme() + ThemeModePreference.DARK -> darkColorScheme() + } + }, + content = content + ) +} diff --git a/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/theme/ConerThemeColors.kt b/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/theme/ConerThemeColors.kt new file mode 100644 index 000000000..738fabcba --- /dev/null +++ b/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/theme/ConerThemeColors.kt @@ -0,0 +1,306 @@ +package tech.coner.trailer.toolkit.sample.dmvapp.gui.theme + +import androidx.compose.material3.darkColorScheme +import androidx.compose.ui.graphics.Color + +interface ConerThemeColors { + val Primary: Color + val OnPrimary: Color + val PrimaryContainer: Color + val OnPrimaryContainer: Color + val Secondary: Color + val OnSecondary: Color + val SecondaryContainer: Color + val OnSecondaryContainer: Color + val Tertiary: Color + val OnTertiary: Color + val TertiaryContainer: Color + val OnTertiaryContainer: Color + val Error: Color + val OnError: Color + val ErrorContainer: Color + val OnErrorContainer: Color + val Background: Color + val OnBackground: Color + val Surface: Color + val OnSurface: Color + val SurfaceVariant: Color + val OnSurfaceVariant: Color + val Outline: Color + val PrimaryDark: Color + val OnPrimaryDark: Color + val PrimaryContainerDark: Color + val OnPrimaryContainerDark: Color + val SecondaryDark: Color + val OnSecondaryDark: Color + val SecondaryContainerDark: Color + val OnSecondaryContainerDark: Color + val TertiaryDark: Color + val OnTertiaryDark: Color + val TertiaryContainerDark: Color + val OnTertiaryContainerDark: Color + val ErrorDark: Color + val OnErrorDark: Color + val ErrorContainerDark: Color + val OnErrorContainerDark: Color + val BackgroundDark: Color + val OnBackgroundDark: Color + val SurfaceDark: Color + val OnSurfaceDark: Color + val SurfaceVariantDark: Color + val OnSurfaceVariantDark: Color + val OutlineDark: Color + + fun lightColorScheme() = androidx.compose.material3.lightColorScheme( + primary = Primary, + onPrimary = OnPrimary, + primaryContainer = PrimaryContainer, + onPrimaryContainer = OnPrimaryContainer, + secondary = Secondary, + onSecondary = OnSecondary, + secondaryContainer = SecondaryContainer, + onSecondaryContainer = OnSecondaryContainer, + tertiary = Tertiary, + onTertiary = OnTertiary, + tertiaryContainer = TertiaryContainer, + onTertiaryContainer = OnTertiaryContainer, + error = Error, + onError = OnError, + errorContainer = ErrorContainer, + onErrorContainer = OnErrorContainer, + background = Background, + onBackground = OnBackground, + surface = Surface, + onSurface = OnSurface, + surfaceVariant = SurfaceVariant, + onSurfaceVariant = OnSurfaceVariant, + outline = Outline + ) + + fun darkColorScheme() = darkColorScheme( + primary = PrimaryDark, + onPrimary = OnPrimaryDark, + primaryContainer = PrimaryContainerDark, + onPrimaryContainer = OnPrimaryContainerDark, + secondary = SecondaryDark, + onSecondary = OnSecondaryDark, + secondaryContainer = SecondaryContainerDark, + onSecondaryContainer = OnSecondaryContainerDark, + tertiary = TertiaryDark, + onTertiary = OnTertiaryDark, + tertiaryContainer = TertiaryContainerDark, + onTertiaryContainer = OnTertiaryContainerDark, + error = ErrorDark, + onError = OnErrorDark, + errorContainer = ErrorContainerDark, + onErrorContainer = OnErrorContainerDark, + background = BackgroundDark, + onBackground = OnBackgroundDark, + surface = SurfaceDark, + onSurface = OnSurfaceDark, + surfaceVariant = SurfaceVariantDark, + onSurfaceVariant = OnSurfaceVariantDark, + outline = OutlineDark + ) +} + +object ConerThemeColorsGenerated20240628_2 : ConerThemeColors { + override val Primary = Color(0xFF063E65) + override val OnPrimary = Color(0xFFFFFFFF) + override val PrimaryContainer = Color(0xFFA1C9E6) + override val OnPrimaryContainer = Color(0xFF031F33) + override val Secondary = Color(0xFFF15A24) + override val OnSecondary = Color(0xFFFFFFFF) + override val SecondaryContainer = Color(0xFFE6B8A8) + override val OnSecondaryContainer = Color(0xFF331308) + override val Tertiary = Color(0xFF428D00) + override val OnTertiary = Color(0xFFFFFFFF) + override val TertiaryContainer = Color(0xFFBFE69D) + override val OnTertiaryContainer = Color(0xFF183300) + override val Error = Color(0xFFB3261E) + override val OnError = Color(0xFFFFFFFF) + override val ErrorContainer = Color(0xFFE6ACA9) + override val OnErrorContainer = Color(0xFF330B09) + override val Background = Color(0xFFfbfcfc) + override val OnBackground = Color(0xFF313233) + override val Surface = Color(0xFFfbfcfc) + override val OnSurface = Color(0xFF313233) + override val SurfaceVariant = Color(0xFFd8e0e6) + override val OnSurfaceVariant = Color(0xFF535e66) + override val Outline = Color(0xFF7c8d99) + + override val PrimaryDark = Color(0xFF85BDE6) + override val OnPrimaryDark = Color(0xFF052F4C) + override val PrimaryContainerDark = Color(0xFF063E66) + override val OnPrimaryContainerDark = Color(0xFFA1C9E6) + override val SecondaryDark = Color(0xFFE6A58E) + override val OnSecondaryDark = Color(0xFF4C1C0B) + override val SecondaryContainerDark = Color(0xFF66260F) + override val OnSecondaryContainerDark = Color(0xFFE6B8A8) + override val TertiaryDark = Color(0xFFAFE67F) + override val OnTertiaryDark = Color(0xFF244C00) + override val TertiaryContainerDark = Color(0xFF306600) + override val OnTertiaryContainerDark = Color(0xFFBFE69D) + override val ErrorDark = Color(0xFFE69490) + override val OnErrorDark = Color(0xFF4C100D) + override val ErrorContainerDark = Color(0xFF661511) + override val OnErrorContainerDark = Color(0xFFE6ACA9) + override val BackgroundDark = Color(0xFF313233) + override val OnBackgroundDark = Color(0xFFe2e4e6) + override val SurfaceDark = Color(0xFF313233) + override val OnSurfaceDark = Color(0xFFe2e4e6) + override val SurfaceVariantDark = Color(0xFF535e66) + override val OnSurfaceVariantDark = Color(0xFFd2dde6) + override val OutlineDark = Color(0xFF9daab3) +} + +object ConerThemeColorsGenerated20240628_1 : ConerThemeColors { + override val Primary = Color(0xFF063E65) + override val OnPrimary = Color(0xFFFFFFFF) + override val PrimaryContainer = Color(0xFFA1C9E6) + override val OnPrimaryContainer = Color(0xFF031F33) + override val Secondary = Color(0xFF428D00) + override val OnSecondary = Color(0xFFFFFFFF) + override val SecondaryContainer = Color(0xFFBFE69D) + override val OnSecondaryContainer = Color(0xFF183300) + override val Tertiary = Color(0xFFF15A24) + override val OnTertiary = Color(0xFFFFFFFF) + override val TertiaryContainer = Color(0xFFE6B8A8) + override val OnTertiaryContainer = Color(0xFF331308) + override val Error = Color(0xFFB3261E) + override val OnError = Color(0xFFFFFFFF) + override val ErrorContainer = Color(0xFFE6ACA9) + override val OnErrorContainer = Color(0xFF330B09) + override val Background = Color(0xFFfbfcfc) + override val OnBackground = Color(0xFF313233) + override val Surface = Color(0xFFfbfcfc) + override val OnSurface = Color(0xFF313233) + override val SurfaceVariant = Color(0xFFd8e0e6) + override val OnSurfaceVariant = Color(0xFF535e66) + override val Outline = Color(0xFF7c8d99) + + override val PrimaryDark = Color(0xFF85BDE6) + override val OnPrimaryDark = Color(0xFF052F4C) + override val PrimaryContainerDark = Color(0xFF063E66) + override val OnPrimaryContainerDark = Color(0xFFA1C9E6) + override val SecondaryDark = Color(0xFFAFE67F) + override val OnSecondaryDark = Color(0xFF244C00) + override val SecondaryContainerDark = Color(0xFF306600) + override val OnSecondaryContainerDark = Color(0xFFBFE69D) + override val TertiaryDark = Color(0xFFE6A58E) + override val OnTertiaryDark = Color(0xFF4C1C0B) + override val TertiaryContainerDark = Color(0xFF66260F) + override val OnTertiaryContainerDark = Color(0xFFE6B8A8) + override val ErrorDark = Color(0xFFE69490) + override val OnErrorDark = Color(0xFF4C100D) + override val ErrorContainerDark = Color(0xFF661511) + override val OnErrorContainerDark = Color(0xFFE6ACA9) + override val BackgroundDark = Color(0xFF313233) + override val OnBackgroundDark = Color(0xFFe2e4e6) + override val SurfaceDark = Color(0xFF313233) + override val OnSurfaceDark = Color(0xFFe2e4e6) + override val SurfaceVariantDark = Color(0xFF535e66) + override val OnSurfaceVariantDark = Color(0xFFd2dde6) + override val OutlineDark = Color(0xFF9daab3) +} + +object ConerThemeColorsGenerated20240628_0 : ConerThemeColors { + override val Primary = Color(0xFF606060) + override val OnPrimary = Color(0xFFFFFFFF) + override val PrimaryContainer = Color(0xFFE6E6E6) + override val OnPrimaryContainer = Color(0xFF333333) + override val Secondary = Color(0xFF1F679B) + override val OnSecondary = Color(0xFFFFFFFF) + override val SecondaryContainer = Color(0xFFABCDE6) + override val OnSecondaryContainer = Color(0xFF0A2233) + override val Tertiary = Color(0xFF77D720) + override val OnTertiary = Color(0xFFFFFFFF) + override val TertiaryContainer = Color(0xFFC5E6A8) + override val OnTertiaryContainer = Color(0xFF1C3308) + override val Error = Color(0xFFB3261E) + override val OnError = Color(0xFFFFFFFF) + override val ErrorContainer = Color(0xFFE6ACA9) + override val OnErrorContainer = Color(0xFF330B09) + override val Background = Color(0xFFfcfcfc) + override val OnBackground = Color(0xFF333333) + override val Surface = Color(0xFFfcfcfc) + override val OnSurface = Color(0xFF333333) + override val SurfaceVariant = Color(0xFFe6e6e6) + override val OnSurfaceVariant = Color(0xFF666666) + override val Outline = Color(0xFF999999) + + override val PrimaryDark = Color(0xFFE6E6E6) + override val OnPrimaryDark = Color(0xFF4C4C4C) + override val PrimaryContainerDark = Color(0xFF666666) + override val OnPrimaryContainerDark = Color(0xFFE6E6E6) + override val SecondaryDark = Color(0xFF93C3E6) + override val OnSecondaryDark = Color(0xFF0F324C) + override val SecondaryContainerDark = Color(0xFF144366) + override val OnSecondaryContainerDark = Color(0xFFABCDE6) + override val TertiaryDark = Color(0xFFB7E68E) + override val OnTertiaryDark = Color(0xFF2A4C0B) + override val TertiaryContainerDark = Color(0xFF38660F) + override val OnTertiaryContainerDark = Color(0xFFC5E6A8) + override val ErrorDark = Color(0xFFE69490) + override val OnErrorDark = Color(0xFF4C100D) + override val ErrorContainerDark = Color(0xFF661511) + override val OnErrorContainerDark = Color(0xFFE6ACA9) + override val BackgroundDark = Color(0xFF333333) + override val OnBackgroundDark = Color(0xFFe6e6e6) + override val SurfaceDark = Color(0xFF333333) + override val OnSurfaceDark = Color(0xFFe6e6e6) + override val SurfaceVariantDark = Color(0xFF666666) + override val OnSurfaceVariantDark = Color(0xFFe6e6e6) + override val OutlineDark = Color(0xFFb3b3b3) +} + +object ConerThemeColorsGenerated20240627 : ConerThemeColors { + + override val Primary = Color(0xFF1F679B) + override val OnPrimary = Color(0xFFFFFFFF) + override val PrimaryContainer = Color(0xFFABCDE6) + override val OnPrimaryContainer = Color(0xFF0A2233) + override val Secondary = Color(0xFF77D720) + override val OnSecondary = Color(0xFFFFFFFF) + override val SecondaryContainer = Color(0xFFC5E6A8) + override val OnSecondaryContainer = Color(0xFF1C3308) + override val Tertiary = Color(0xFFFF7900) + override val OnTertiary = Color(0xFFFFFFFF) + override val TertiaryContainer = Color(0xFFE6BF9D) + override val OnTertiaryContainer = Color(0xFF331800) + override val Error = Color(0xFFB3261E) + override val OnError = Color(0xFFFFFFFF) + override val ErrorContainer = Color(0xFFE6ACA9) + override val OnErrorContainer = Color(0xFF330B09) + override val Background = Color(0xFFfbfcfc) + override val OnBackground = Color(0xFF313233) + override val Surface = Color(0xFFfbfcfc) + override val OnSurface = Color(0xFF313233) + override val SurfaceVariant = Color(0xFFdae1e6) + override val OnSurfaceVariant = Color(0xFF565f66) + override val Outline = Color(0xFF818f99) + + override val PrimaryDark = Color(0xFF93C3E6) + override val OnPrimaryDark = Color(0xFF0F324C) + override val PrimaryContainerDark = Color(0xFF144366) + override val OnPrimaryContainerDark = Color(0xFFABCDE6) + override val SecondaryDark = Color(0xFFB7E68E) + override val OnSecondaryDark = Color(0xFF2A4C0B) + override val SecondaryContainerDark = Color(0xFF38660F) + override val OnSecondaryContainerDark = Color(0xFFC5E6A8) + override val TertiaryDark = Color(0xFFE6AF7F) + override val OnTertiaryDark = Color(0xFF4C2400) + override val TertiaryContainerDark = Color(0xFF663000) + override val OnTertiaryContainerDark = Color(0xFFE6BF9D) + override val ErrorDark = Color(0xFFE69490) + override val OnErrorDark = Color(0xFF4C100D) + override val ErrorContainerDark = Color(0xFF661511) + override val OnErrorContainerDark = Color(0xFFE6ACA9) + override val BackgroundDark = Color(0xFF313233) + override val OnBackgroundDark = Color(0xFFe3e4e6) + override val SurfaceDark = Color(0xFF313233) + override val OnSurfaceDark = Color(0xFFe3e4e6) + override val SurfaceVariantDark = Color(0xFF565f66) + override val OnSurfaceVariantDark = Color(0xFFd5dfe6) + override val OutlineDark = Color(0xFFa0abb3) +} \ No newline at end of file diff --git a/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/util/ComposeFlowExtensions.kt b/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/util/ComposeFlowExtensions.kt new file mode 100644 index 000000000..9df4c135e --- /dev/null +++ b/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/util/ComposeFlowExtensions.kt @@ -0,0 +1,34 @@ +package tech.coner.trailer.toolkit.sample.dmvapp.gui.util + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext +import tech.coner.trailer.toolkit.presentation.state.ItemModelState +import tech.coner.trailer.toolkit.presentation.state.MutableItemModelPropertyDelegate +import tech.coner.trailer.toolkit.presentation.state.State +import tech.coner.trailer.toolkit.presentation.state.StateContainer +import tech.coner.trailer.toolkit.validation.Feedback + +@Composable +fun StateContainer.StatePropertyContainer.collectAsState( + context: CoroutineContext = EmptyCoroutineContext +) = flow.collectAsState(immutableValue, context) + +@Composable +fun , ITEM, PROPERTY, FEEDBACK : Feedback> + MutableItemModelPropertyDelegate.collectValueAsState( + context: CoroutineContext = EmptyCoroutineContext +) = valueFlow.collectAsState( + initial = value, + context = context +) + +@Composable +fun , ITEM, PROPERTY, FEEDBACK : Feedback> +MutableItemModelPropertyDelegate.collectFeedbackAsState( + context: CoroutineContext = EmptyCoroutineContext +) = feedbackFlow.collectAsState( + initial = feedback, + context = context +) diff --git a/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/util/FocusFirstFocusRequester.kt b/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/util/FocusFirstFocusRequester.kt new file mode 100644 index 000000000..982b8f991 --- /dev/null +++ b/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/util/FocusFirstFocusRequester.kt @@ -0,0 +1,47 @@ +package tech.coner.trailer.toolkit.sample.dmvapp.gui.util + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusEvent +import kotlin.reflect.KProperty1 + +class FocusFirstFocusRequester( + internal val store: LinkedHashMap, Entry> +) { + + fun requestFocus(property: KProperty1) { + if (store.values.any { it.focused }) return + store[property]?.focusRequester?.requestFocus() + } + + class Entry( + val focusRequester: FocusRequester, + ) { + var focused: Boolean = false + } + + companion object { + + fun FocusFirstFocusRequester(properties: List>): FocusFirstFocusRequester { + return FocusFirstFocusRequester( + store = linkedMapOf( + *properties.map { it to Entry(FocusRequester()) }.toTypedArray() + ) + ) + } + } +} + +@Composable +fun Modifier.focusOnError( + focusFirstFocusRequester: FocusFirstFocusRequester, + property: KProperty1 +): Modifier { + val focusRequester: FocusRequester = focusFirstFocusRequester.store[property]?.focusRequester ?: return this + return focusRequester(focusRequester) + .onFocusEvent { + focusFirstFocusRequester.store[property]?.focused = it.isFocused + } +} \ No newline at end of file diff --git a/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/util/LocalStrings.kt b/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/util/LocalStrings.kt new file mode 100644 index 000000000..626526548 --- /dev/null +++ b/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/util/LocalStrings.kt @@ -0,0 +1,13 @@ +package tech.coner.trailer.toolkit.sample.dmvapp.gui.util + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.compositionLocalOf +import org.kodein.di.direct +import org.kodein.di.instance +import tech.coner.trailer.toolkit.sample.dmvapp.gui.di +import tech.coner.trailer.toolkit.sample.dmvapp.presentation.localization.Strings + +val LocalStrings = compositionLocalOf { di.direct.instance() } + +val strings: Strings +@Composable get() = LocalStrings.current \ No newline at end of file diff --git a/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/util/LocalWindowState.kt b/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/util/LocalWindowState.kt new file mode 100644 index 000000000..d41d59d5a --- /dev/null +++ b/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/util/LocalWindowState.kt @@ -0,0 +1,6 @@ +package tech.coner.trailer.toolkit.sample.dmvapp.gui.util + +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.ui.window.WindowState + +val LocalWindowState = compositionLocalOf { WindowState() } diff --git a/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/window/DmvAppMainWindow.kt b/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/window/DmvAppMainWindow.kt new file mode 100644 index 000000000..c9b9d57aa --- /dev/null +++ b/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/window/DmvAppMainWindow.kt @@ -0,0 +1,31 @@ +package tech.coner.trailer.toolkit.sample.dmvapp.gui.window + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.window.ApplicationScope +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.rememberWindowState +import tech.coner.trailer.toolkit.sample.dmvapp.gui.screen.main.DmvAppMainScreen +import tech.coner.trailer.toolkit.sample.dmvapp.gui.util.LocalWindowState +import tech.coner.trailer.toolkit.sample.dmvapp.gui.util.strings +import java.awt.Dimension + +@Composable +fun ApplicationScope.DmvAppMainWindow() { + val state = rememberWindowState() + CompositionLocalProvider(LocalWindowState provides state) { + Window( + title = strings.dmvLabel, + icon = painterResource("coner-icon/coner-icon_512.png"), + state = state, + onCloseRequest = ::exitApplication, + ) { + LaunchedEffect(null) { + window.minimumSize = Dimension(600, 600) + } + DmvAppMainScreen() + } + } +} \ No newline at end of file diff --git a/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/window/DriversLicenseWindow.kt b/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/window/DriversLicenseWindow.kt new file mode 100644 index 000000000..7e0ff1239 --- /dev/null +++ b/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/window/DriversLicenseWindow.kt @@ -0,0 +1,32 @@ +package tech.coner.trailer.toolkit.sample.dmvapp.gui.window + +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.rememberWindowState +import org.kodein.di.compose.rememberInstance +import tech.coner.trailer.toolkit.sample.dmvapp.domain.entity.DriversLicense +import tech.coner.trailer.toolkit.sample.dmvapp.gui.presentation.presenter.SettingsPresenter +import tech.coner.trailer.toolkit.sample.dmvapp.gui.screen.driverslicenseapplication.DriversLicenseScreen +import tech.coner.trailer.toolkit.sample.dmvapp.gui.util.strings + + +@Composable +fun DriversLicenseWindow( + driversLicense: DriversLicense, + onCloseRequest: () -> Unit +) { + val settingsPresenter: SettingsPresenter by rememberInstance() + val windowState = rememberWindowState(width = 480.dp, height = 240.dp) + Window( + title = strings.driversLicenseHeading, + state = windowState, + resizable = false, + onCloseRequest = onCloseRequest, + ) { + Scaffold { + DriversLicenseScreen(driversLicense) + } + } +} diff --git a/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/window/ExceptionWindow.kt b/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/window/ExceptionWindow.kt new file mode 100644 index 000000000..37b5328a7 --- /dev/null +++ b/toolkit/samples/dmvapp/dmvapp-gui/src/main/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/window/ExceptionWindow.kt @@ -0,0 +1,21 @@ +package tech.coner.trailer.toolkit.sample.dmvapp.gui.window + +import androidx.compose.runtime.Composable +import androidx.compose.ui.window.Window +import tech.coner.trailer.toolkit.sample.dmvapp.gui.screen.ExceptionScreen + +@Composable +fun ExceptionWindow( + cause: Throwable, + onCloseRequest: () -> Unit +) { + Window( + title = "Exception${cause::class.simpleName?.let { ": $it" } }", + onCloseRequest = onCloseRequest, + ) { + ExceptionScreen( + cause = cause, + onCloseRequest = onCloseRequest + ) + } +} \ No newline at end of file diff --git a/toolkit/samples/dmvapp/dmvapp-gui/src/main/resources/coner-icon/coner-icon_1024.png b/toolkit/samples/dmvapp/dmvapp-gui/src/main/resources/coner-icon/coner-icon_1024.png new file mode 100644 index 000000000..b2390c982 Binary files /dev/null and b/toolkit/samples/dmvapp/dmvapp-gui/src/main/resources/coner-icon/coner-icon_1024.png differ diff --git a/toolkit/samples/dmvapp/dmvapp-gui/src/main/resources/coner-icon/coner-icon_128.png b/toolkit/samples/dmvapp/dmvapp-gui/src/main/resources/coner-icon/coner-icon_128.png new file mode 100644 index 000000000..b4305078b Binary files /dev/null and b/toolkit/samples/dmvapp/dmvapp-gui/src/main/resources/coner-icon/coner-icon_128.png differ diff --git a/toolkit/samples/dmvapp/dmvapp-gui/src/main/resources/coner-icon/coner-icon_16.png b/toolkit/samples/dmvapp/dmvapp-gui/src/main/resources/coner-icon/coner-icon_16.png new file mode 100644 index 000000000..9a235b783 Binary files /dev/null and b/toolkit/samples/dmvapp/dmvapp-gui/src/main/resources/coner-icon/coner-icon_16.png differ diff --git a/toolkit/samples/dmvapp/dmvapp-gui/src/main/resources/coner-icon/coner-icon_256.png b/toolkit/samples/dmvapp/dmvapp-gui/src/main/resources/coner-icon/coner-icon_256.png new file mode 100644 index 000000000..fe4e03a49 Binary files /dev/null and b/toolkit/samples/dmvapp/dmvapp-gui/src/main/resources/coner-icon/coner-icon_256.png differ diff --git a/toolkit/samples/dmvapp/dmvapp-gui/src/main/resources/coner-icon/coner-icon_32.png b/toolkit/samples/dmvapp/dmvapp-gui/src/main/resources/coner-icon/coner-icon_32.png new file mode 100644 index 000000000..382fe9618 Binary files /dev/null and b/toolkit/samples/dmvapp/dmvapp-gui/src/main/resources/coner-icon/coner-icon_32.png differ diff --git a/toolkit/samples/dmvapp/dmvapp-gui/src/main/resources/coner-icon/coner-icon_3840.png b/toolkit/samples/dmvapp/dmvapp-gui/src/main/resources/coner-icon/coner-icon_3840.png new file mode 100644 index 000000000..f4088fa0c Binary files /dev/null and b/toolkit/samples/dmvapp/dmvapp-gui/src/main/resources/coner-icon/coner-icon_3840.png differ diff --git a/toolkit/samples/dmvapp/dmvapp-gui/src/main/resources/coner-icon/coner-icon_48.png b/toolkit/samples/dmvapp/dmvapp-gui/src/main/resources/coner-icon/coner-icon_48.png new file mode 100644 index 000000000..c12a33e0a Binary files /dev/null and b/toolkit/samples/dmvapp/dmvapp-gui/src/main/resources/coner-icon/coner-icon_48.png differ diff --git a/toolkit/samples/dmvapp/dmvapp-gui/src/main/resources/coner-icon/coner-icon_512.png b/toolkit/samples/dmvapp/dmvapp-gui/src/main/resources/coner-icon/coner-icon_512.png new file mode 100644 index 000000000..1cec6d3df Binary files /dev/null and b/toolkit/samples/dmvapp/dmvapp-gui/src/main/resources/coner-icon/coner-icon_512.png differ diff --git a/toolkit/samples/dmvapp/dmvapp-gui/src/main/resources/coner-icon/coner-icon_64.png b/toolkit/samples/dmvapp/dmvapp-gui/src/main/resources/coner-icon/coner-icon_64.png new file mode 100644 index 000000000..476789535 Binary files /dev/null and b/toolkit/samples/dmvapp/dmvapp-gui/src/main/resources/coner-icon/coner-icon_64.png differ diff --git a/toolkit/samples/dmvapp/dmvapp-gui/src/main/resources/coner-logo/coner-logo_1024.png b/toolkit/samples/dmvapp/dmvapp-gui/src/main/resources/coner-logo/coner-logo_1024.png new file mode 100644 index 000000000..0069588e6 Binary files /dev/null and b/toolkit/samples/dmvapp/dmvapp-gui/src/main/resources/coner-logo/coner-logo_1024.png differ diff --git a/toolkit/samples/dmvapp/dmvapp-gui/src/main/resources/coner-logo/coner-logo_128.png b/toolkit/samples/dmvapp/dmvapp-gui/src/main/resources/coner-logo/coner-logo_128.png new file mode 100644 index 000000000..0168b5f1a Binary files /dev/null and b/toolkit/samples/dmvapp/dmvapp-gui/src/main/resources/coner-logo/coner-logo_128.png differ diff --git a/toolkit/samples/dmvapp/dmvapp-gui/src/main/resources/coner-logo/coner-logo_256.png b/toolkit/samples/dmvapp/dmvapp-gui/src/main/resources/coner-logo/coner-logo_256.png new file mode 100644 index 000000000..f0457f26c Binary files /dev/null and b/toolkit/samples/dmvapp/dmvapp-gui/src/main/resources/coner-logo/coner-logo_256.png differ diff --git a/toolkit/samples/dmvapp/dmvapp-gui/src/main/resources/coner-logo/coner-logo_96.png b/toolkit/samples/dmvapp/dmvapp-gui/src/main/resources/coner-logo/coner-logo_96.png new file mode 100644 index 000000000..6ac501552 Binary files /dev/null and b/toolkit/samples/dmvapp/dmvapp-gui/src/main/resources/coner-logo/coner-logo_96.png differ diff --git a/toolkit/samples/dmvapp/dmvapp-gui/src/test/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/composable/DriversLicenseApplicationFormTest.kt b/toolkit/samples/dmvapp/dmvapp-gui/src/test/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/composable/DriversLicenseApplicationFormTest.kt new file mode 100644 index 000000000..ef1231a5e --- /dev/null +++ b/toolkit/samples/dmvapp/dmvapp-gui/src/test/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/composable/DriversLicenseApplicationFormTest.kt @@ -0,0 +1,212 @@ +package tech.coner.trailer.toolkit.sample.dmvapp.gui.composable + +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.test.* +import org.junit.Before +import org.junit.Test +import org.kodein.di.compose.withDI +import org.kodein.di.direct +import org.kodein.di.instance +import tech.coner.trailer.toolkit.sample.dmvapp.domain.entity.LicenseType +import tech.coner.trailer.toolkit.sample.dmvapp.domain.entity.LicenseType.FullLicense +import tech.coner.trailer.toolkit.sample.dmvapp.gui.di +import tech.coner.trailer.toolkit.sample.dmvapp.gui.page.DriversLicenseApplicationFormPage +import tech.coner.trailer.toolkit.sample.dmvapp.gui.screen.DmvAppScreen +import tech.coner.trailer.toolkit.sample.dmvapp.gui.screen.driverslicenseapplication.DriversLicenseApplicationFormScreen +import tech.coner.trailer.toolkit.sample.dmvapp.gui.testutil.assertValidationFeedbackSupportingTextIsEmpty +import tech.coner.trailer.toolkit.sample.dmvapp.gui.testutil.assertValidationFeedbackSupportingTextIsExactly +import tech.coner.trailer.toolkit.sample.dmvapp.presentation.localization.Strings +import tech.coner.trailer.toolkit.sample.dmvapp.presentation.validation.DriversLicenseApplicationModelFeedback.* +import kotlin.random.Random + +class DriversLicenseApplicationFormTest { + + lateinit var strings: Strings + + @Before + fun setup() { + with(di.direct) { + strings = instance() + } + } + + @Test + fun showsDriversLicenseFormInInitialState() = runDriversLicenseFormUiTest { page -> + page.nameField + .assertExists() + .assertTextEquals(strings.driversLicenseNameField) + page.name + .assertExists() + .assertTextEquals("") + .assertIsEnabled() + page.nameFeedback + .assertValidationFeedbackSupportingTextIsEmpty() + + page.ageField + .assertExists() + .assertTextEquals(strings.driversLicenseAgeField) + page.age + .assertExists() + .assertTextEquals("") + page.ageFeedback + .assertValidationFeedbackSupportingTextIsEmpty() + + page.licenseTypeField + .assertExists() + .assertTextEquals(strings.driversLicenseLicenseTypeField) + page.licenseType + .assertExists() + .assertTextEquals("") + page.licenseTypeDropdownMenu + .assertDoesNotExist() + page.licenseTypeDropdownMenuItems + .assertCountEquals(0) + page.licenseTypeDropdownMenuItemTexts + .assertCountEquals(0) + page.licenseTypeDropdownMenuBox + .assertExists() + .performClick() + page.licenseTypeDropdownMenu + .assertExists() + page.licenseTypeDropdownMenuItems + .assertCountEquals(3) + page.licenseTypeDropdownMenuItemTexts + .assertCountEquals(3) + .apply { + get(0).assertTextContains(strings[LicenseType.GraduatedLearnerPermit]) + get(1).assertTextContains(strings[LicenseType.LearnerPermit]) + get(2).assertTextContains(strings[FullLicense]) + } + page.licenseTypeDropdownMenuBox + .performKeyInput { pressKey(Key.Escape) } + page.licenseTypeDropdownMenu + .assertDoesNotExist() + page.licenseTypeFeedback + .assertValidationFeedbackSupportingTextIsEmpty() + + page.resetButton + .assertExists() + .assertIsNotEnabled() + page.resetButtonIcon + .assertExists() + .assertContentDescriptionEquals(strings.driversLicenseApplicationFormReset) + + page.applyButton + .assertExists() + .assertIsEnabled() + page.applyButtonText + .assertExists() + .assertTextEquals(strings.driversLicenseApplicationFormApply) + + page.applyButtonProgress + .assertDoesNotExist() + } + + @Test + fun acceptsValidInput() = runDriversLicenseFormUiTest { page -> + page.name.performTextInput("name") + page.nameFeedback.assertValidationFeedbackSupportingTextIsEmpty() + + page.age.performTextInput("18") + page.ageFeedback.assertValidationFeedbackSupportingTextIsEmpty() + + val fullLicenseText = strings[FullLicense] + page.licenseType + .assertExists() + .assertTextEquals("") + page.licenseTypeDropdownMenuBox.performClick() + page.licenseTypeDropdownMenuItemTexts.apply { + assertCountEquals(3) + filterToOne(hasText(fullLicenseText)) + .performClick() + } + page.licenseType.assertTextEquals(fullLicenseText) + page.licenseTypeFeedback.assertValidationFeedbackSupportingTextIsEmpty() + + page.resetButton + .assertExists() + .assertIsEnabled() + + page.applyButton + .assertExists() + .assertIsEnabled() + } + + @Test + fun rejectsInvalidInput() = runDriversLicenseFormUiTest { page -> + page.applyButton + .performClick() + + page.nameFeedback + .assertValidationFeedbackSupportingTextIsExactly(strings[NameIsRequired]) + page.ageFeedback + .assertValidationFeedbackSupportingTextIsExactly(strings[AgeIsRequired]) + page.licenseTypeFeedback + .assertValidationFeedbackSupportingTextIsExactly(strings[LicenseTypeIsRequired]) + + page.applyButton + .assertIsNotEnabled() + } + + @Test + fun nameRecoversFromInvalidInput() = runDriversLicenseFormUiTest { page -> + val nameText = "name" + page.applyButton + .performClick() + + page.name + .performTextInput(nameText) + page.name + .assertTextEquals(nameText) + + page.nameFeedback + .assertValidationFeedbackSupportingTextIsEmpty() + } + + @Test + fun ageRecoversFromInvalidInput() = runDriversLicenseFormUiTest { page -> + val ageText = "18" + page.applyButton + .performClick() + + page.age + .performTextInput(ageText) + page.age + .assertTextEquals(ageText) + + page.ageFeedback + .assertValidationFeedbackSupportingTextIsEmpty() + } + + @Test + fun licenseTypeRecoversFromInvalidInput() = runDriversLicenseFormUiTest { page -> + val licenseType = FullLicense + val licenseTypeText = strings[licenseType] + page.applyButton + .performClick() + + page.licenseTypeDropdownMenuBox + .performClick() + page.licenseTypeDropdownMenuItemTexts + .filterToOne(hasText(licenseTypeText)) + .performClick() + + page.licenseTypeFeedback + .assertValidationFeedbackSupportingTextIsEmpty() + } + + private fun runDriversLicenseFormUiTest( + block: ComposeUiTest.(DriversLicenseApplicationFormPage) -> Unit + ) = runComposeUiTest { + setContent { + withDI(di) { + DriversLicenseApplicationFormScreen( + screen = DmvAppScreen.DriversLicenseApplication(Random.nextInt()), + openNavigationDrawer = {} + ) + } + } + + block(this, DriversLicenseApplicationFormPage()) + } +} \ No newline at end of file diff --git a/toolkit/samples/dmvapp/dmvapp-gui/src/test/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/page/DriversLicenseApplicationFormPage.kt b/toolkit/samples/dmvapp/dmvapp-gui/src/test/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/page/DriversLicenseApplicationFormPage.kt new file mode 100644 index 000000000..777a3e18d --- /dev/null +++ b/toolkit/samples/dmvapp/dmvapp-gui/src/test/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/page/DriversLicenseApplicationFormPage.kt @@ -0,0 +1,30 @@ +package tech.coner.trailer.toolkit.sample.dmvapp.gui.page + +import androidx.compose.ui.test.ComposeUiTest +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithTag + +class DriversLicenseApplicationFormPage( + private val test: ComposeUiTest +) { + val nameField get() = test.onNodeWithTag("nameField", useUnmergedTree = true) + val name get() = test.onNodeWithTag("name", useUnmergedTree = true) + val nameFeedback get() = test.onAllNodesWithTag("nameValidationFeedback", useUnmergedTree = true) + val ageField get() = test.onNodeWithTag("ageField", useUnmergedTree = true) + val age get() = test.onNodeWithTag("age", useUnmergedTree = true) + val ageFeedback get() = test.onAllNodesWithTag("ageValidationFeedback", useUnmergedTree = true) + val licenseTypeDropdownMenuBox get() = test.onNodeWithTag("licenseTypeDropdownMenuBox", useUnmergedTree = true) + val licenseTypeField = test.onNodeWithTag("licenseTypeField", useUnmergedTree = true) + val licenseType get() = test.onNodeWithTag("licenseType", useUnmergedTree = true) + val licenseTypeDropdownMenu get() = test.onNodeWithTag("licenseTypeDropdownMenu", useUnmergedTree = true) + val licenseTypeDropdownMenuItems get() = test.onAllNodesWithTag("licenseTypeDropdownMenuItem", useUnmergedTree = true) + val licenseTypeDropdownMenuItemTexts get() = test.onAllNodesWithTag("licenseTypeDropdownMenuItemText", useUnmergedTree = true) + val licenseTypeFeedback get() = test.onAllNodesWithTag("licenseTypeValidationFeedback", useUnmergedTree = true) + val resetButton get() = test.onNodeWithTag("resetButton", useUnmergedTree = true) + val resetButtonIcon get() = test.onNodeWithTag("resetButtonIcon", useUnmergedTree = true) + val applyButton get() = test.onNodeWithTag("applyButton", useUnmergedTree = true) + val applyButtonText get() = test.onNodeWithTag("applyButtonText", useUnmergedTree = true) + val applyButtonProgress get() = test.onNodeWithTag("applyButtonProgress", useUnmergedTree = true) +} + +fun ComposeUiTest.DriversLicenseApplicationFormPage() = DriversLicenseApplicationFormPage(this) \ No newline at end of file diff --git a/toolkit/samples/dmvapp/dmvapp-gui/src/test/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/testutil/ValidationFeedbackTestUtils.kt b/toolkit/samples/dmvapp/dmvapp-gui/src/test/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/testutil/ValidationFeedbackTestUtils.kt new file mode 100644 index 000000000..4a14e7e81 --- /dev/null +++ b/toolkit/samples/dmvapp/dmvapp-gui/src/test/kotlin/tech/coner/trailer/toolkit/sample/dmvapp/gui/testutil/ValidationFeedbackTestUtils.kt @@ -0,0 +1,15 @@ +package tech.coner.trailer.toolkit.sample.dmvapp.gui.testutil + +import androidx.compose.ui.test.* + +fun SemanticsNodeInteractionCollection.assertValidationFeedbackSupportingTextIsEmpty() = + assertCountEquals(1) + .assertAll(hasText("")) + +fun SemanticsNodeInteractionCollection.assertValidationFeedbackSupportingTextIsExactly(vararg text: String) = + assertCountEquals(text.size) + .apply { + text.forEachIndexed { index, text -> + get(index).assertTextEquals(text) + } + } \ No newline at end of file diff --git a/toolkit/presentation/presentation-test/pom.xml b/toolkit/samples/fooapp/fooapp-common/pom.xml similarity index 68% rename from toolkit/presentation/presentation-test/pom.xml rename to toolkit/samples/fooapp/fooapp-common/pom.xml index b02d36080..bb67bea3d 100644 --- a/toolkit/presentation/presentation-test/pom.xml +++ b/toolkit/samples/fooapp/fooapp-common/pom.xml @@ -5,32 +5,27 @@ 4.0.0 tech.coner.trailer - parent + toolkit-sample-fooapp-parent 0.1.0-SNAPSHOT - ../../../pom.xml + ../fooapp-parent/pom.xml - toolkit-presentation-test + toolkit-sample-fooapp-common tech.coner.trailer - toolkit-presentation + toolkit-presentation-testsupport 0.1.0-SNAPSHOT - compile + test tech.coner.trailer - toolkit-presentation-testsupport + assertk-arrowkt 0.1.0-SNAPSHOT test - - - app.cash.turbine - turbine-jvm - \ No newline at end of file diff --git a/toolkit/samples/fooapp/fooapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/fooapp/data/repository/FooRepositoryImpl.kt b/toolkit/samples/fooapp/fooapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/fooapp/data/repository/FooRepositoryImpl.kt new file mode 100644 index 000000000..d2c3d06b4 --- /dev/null +++ b/toolkit/samples/fooapp/fooapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/fooapp/data/repository/FooRepositoryImpl.kt @@ -0,0 +1,22 @@ +package tech.coner.trailer.toolkit.sample.fooapp.data.repository + +import tech.coner.trailer.toolkit.sample.fooapp.domain.entity.FOO_ID_BAR +import tech.coner.trailer.toolkit.sample.fooapp.domain.entity.FOO_ID_BAT +import tech.coner.trailer.toolkit.sample.fooapp.domain.entity.FOO_ID_BAZ +import tech.coner.trailer.toolkit.sample.fooapp.domain.entity.FOO_ID_FOO +import tech.coner.trailer.toolkit.sample.fooapp.domain.entity.Foo + +class FooRepositoryImpl : MutableMapRepository() { + + override val keyFn: (Foo) -> Foo.Id = { it.id } + + override val mutableMap = arrayOf( + FOO_ID_FOO to "foo", + FOO_ID_BAR to "bar", + FOO_ID_BAZ to "baz", + FOO_ID_BAT to "bat", + ) + .associate { (idValue, name) -> Foo.Id(idValue).let { it to Foo(it, name) } } + .toMutableMap() + +} \ No newline at end of file diff --git a/toolkit/samples/fooapp/fooapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/fooapp/data/repository/MutableMapRepository.kt b/toolkit/samples/fooapp/fooapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/fooapp/data/repository/MutableMapRepository.kt new file mode 100644 index 000000000..60d3579ec --- /dev/null +++ b/toolkit/samples/fooapp/fooapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/fooapp/data/repository/MutableMapRepository.kt @@ -0,0 +1,54 @@ +package tech.coner.trailer.toolkit.sample.fooapp.data.repository + +import arrow.core.Either +import arrow.core.raise.either +import arrow.core.raise.ensure +import arrow.core.raise.ensureNotNull +import tech.coner.trailer.toolkit.sample.fooapp.domain.exception.AlreadyExistsException +import tech.coner.trailer.toolkit.sample.fooapp.domain.exception.NotFoundException +import tech.coner.trailer.toolkit.sample.fooapp.domain.repository.SimpleRepository +import tech.coner.trailer.toolkit.sample.fooapp.domain.repository.SimpleRepository.* + +abstract class MutableMapRepository : SimpleRepository { + + protected abstract val keyFn: (VALUE) -> KEY + protected abstract val mutableMap: MutableMap + + override fun create(value: VALUE): Result> = runCatching { + either { + val key = keyFn(value) + ensure(!mutableMap.containsKey(key)) { CreateFailure.AlreadyExists } + value.also { mutableMap[key] = it } + } + } + + override fun read(key: KEY): Result> = runCatching { + either { + ensureNotNull(mutableMap[key]) { + ReadFailure.NotFound + } + } + } + + override fun update(value: VALUE): Result> = runCatching { + either { + val key = keyFn(value) + ensure(mutableMap.containsKey(key)) { + UpdateFailure.NotFound + } + value.also { mutableMap[key] = it } + } + } + + override fun delete(key: KEY): Result> = runCatching { + either { + ensureNotNull(mutableMap.remove(key)) { + DeleteFailure.NotFound + } + } + } + + override fun exists(key: KEY): Result = runCatching { + mutableMap.containsKey(key) + } +} \ No newline at end of file diff --git a/toolkit/samples/fooapp/fooapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/fooapp/data/service/FooServiceImpl.kt b/toolkit/samples/fooapp/fooapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/fooapp/data/service/FooServiceImpl.kt new file mode 100644 index 000000000..adaf75140 --- /dev/null +++ b/toolkit/samples/fooapp/fooapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/fooapp/data/service/FooServiceImpl.kt @@ -0,0 +1,66 @@ +package tech.coner.trailer.toolkit.sample.fooapp.data.service + +import arrow.core.Either +import arrow.core.raise.either +import arrow.core.raise.ensure +import tech.coner.trailer.toolkit.sample.fooapp.domain.entity.Foo +import tech.coner.trailer.toolkit.sample.fooapp.domain.repository.FooRepository +import tech.coner.trailer.toolkit.sample.fooapp.domain.repository.SimpleRepository +import tech.coner.trailer.toolkit.sample.fooapp.domain.service.FooService +import tech.coner.trailer.toolkit.sample.fooapp.domain.service.FooService.CreateFailure +import tech.coner.trailer.toolkit.sample.fooapp.domain.validation.FooValidator +import tech.coner.trailer.toolkit.validation.invoke + +class FooServiceImpl( + private val repository: FooRepository, + private val validator: FooValidator, +) : FooService { + + override suspend fun create(create: Foo): Result> = runCatching { + either { + validator(create) + .also { ensure(it.isValid) { CreateFailure.Invalid(it) } } + + repository.create(create).getOrThrow() + .mapLeft { + when (it) { + SimpleRepository.CreateFailure.AlreadyExists -> CreateFailure.AlreadyExists + } + } + .bind() + } + } + + override suspend fun findByKey(key: Foo.Id): Result> = runCatching { + repository.read(key).getOrThrow() + .mapLeft { + when (it) { + SimpleRepository.ReadFailure.NotFound -> FooService.FindFailure.NotFound + } + } + } + + override suspend fun update(update: Foo): Result> = runCatching { + either { + validator(update) + .also { ensure(it.isValid) { FooService.UpdateFailure.Invalid(it) } } + + repository.update(update).getOrThrow() + .mapLeft { + when (it) { + SimpleRepository.UpdateFailure.NotFound -> FooService.UpdateFailure.NotFound + } + } + .bind() + } + } + + override suspend fun deleteByKey(key: Foo.Id): Result> = runCatching { + repository.delete(key).getOrThrow() + .mapLeft { + when (it) { + SimpleRepository.DeleteFailure.NotFound -> FooService.DeleteFailure.NotFound + } + } + } +} diff --git a/toolkit/presentation/presentation-test/src/main/kotlin/tech/coner/trailer/toolkit/presentation/testsupport/fooapp/domain/entity/Foo.kt b/toolkit/samples/fooapp/fooapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/fooapp/domain/entity/Foo.kt similarity index 71% rename from toolkit/presentation/presentation-test/src/main/kotlin/tech/coner/trailer/toolkit/presentation/testsupport/fooapp/domain/entity/Foo.kt rename to toolkit/samples/fooapp/fooapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/fooapp/domain/entity/Foo.kt index 059f3a838..77e34fe70 100644 --- a/toolkit/presentation/presentation-test/src/main/kotlin/tech/coner/trailer/toolkit/presentation/testsupport/fooapp/domain/entity/Foo.kt +++ b/toolkit/samples/fooapp/fooapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/fooapp/domain/entity/Foo.kt @@ -1,4 +1,4 @@ -package tech.coner.trailer.toolkit.presentation.testsupport.fooapp.domain.entity +package tech.coner.trailer.toolkit.sample.fooapp.domain.entity data class Foo(val id: Id, val name: String) { @JvmInline diff --git a/toolkit/presentation/presentation-test/src/main/kotlin/tech/coner/trailer/toolkit/presentation/testsupport/fooapp/domain/exception/AlreadyExistsException.kt b/toolkit/samples/fooapp/fooapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/fooapp/domain/exception/AlreadyExistsException.kt similarity index 56% rename from toolkit/presentation/presentation-test/src/main/kotlin/tech/coner/trailer/toolkit/presentation/testsupport/fooapp/domain/exception/AlreadyExistsException.kt rename to toolkit/samples/fooapp/fooapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/fooapp/domain/exception/AlreadyExistsException.kt index 4f4d09b42..af1668eec 100644 --- a/toolkit/presentation/presentation-test/src/main/kotlin/tech/coner/trailer/toolkit/presentation/testsupport/fooapp/domain/exception/AlreadyExistsException.kt +++ b/toolkit/samples/fooapp/fooapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/fooapp/domain/exception/AlreadyExistsException.kt @@ -1,3 +1,3 @@ -package tech.coner.trailer.toolkit.presentation.testsupport.fooapp.domain.exception +package tech.coner.trailer.toolkit.sample.fooapp.domain.exception class AlreadyExistsException(message: String? = null, cause: Throwable? = null) : Exception(message, cause) diff --git a/toolkit/presentation/presentation-test/src/main/kotlin/tech/coner/trailer/toolkit/presentation/testsupport/fooapp/domain/exception/NotFoundException.kt b/toolkit/samples/fooapp/fooapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/fooapp/domain/exception/NotFoundException.kt similarity index 56% rename from toolkit/presentation/presentation-test/src/main/kotlin/tech/coner/trailer/toolkit/presentation/testsupport/fooapp/domain/exception/NotFoundException.kt rename to toolkit/samples/fooapp/fooapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/fooapp/domain/exception/NotFoundException.kt index 62d029c7a..ca30216ea 100644 --- a/toolkit/presentation/presentation-test/src/main/kotlin/tech/coner/trailer/toolkit/presentation/testsupport/fooapp/domain/exception/NotFoundException.kt +++ b/toolkit/samples/fooapp/fooapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/fooapp/domain/exception/NotFoundException.kt @@ -1,4 +1,4 @@ -package tech.coner.trailer.toolkit.presentation.testsupport.fooapp.domain.exception +package tech.coner.trailer.toolkit.sample.fooapp.domain.exception class NotFoundException(message: String? = null, cause: Throwable? = null) : Exception(message, cause) { } \ No newline at end of file diff --git a/toolkit/samples/fooapp/fooapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/fooapp/domain/repository/FooRepository.kt b/toolkit/samples/fooapp/fooapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/fooapp/domain/repository/FooRepository.kt new file mode 100644 index 000000000..070ce9f7c --- /dev/null +++ b/toolkit/samples/fooapp/fooapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/fooapp/domain/repository/FooRepository.kt @@ -0,0 +1,5 @@ +package tech.coner.trailer.toolkit.sample.fooapp.domain.repository + +import tech.coner.trailer.toolkit.sample.fooapp.domain.entity.Foo + +typealias FooRepository = SimpleRepository diff --git a/toolkit/samples/fooapp/fooapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/fooapp/domain/repository/SimpleRepository.kt b/toolkit/samples/fooapp/fooapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/fooapp/domain/repository/SimpleRepository.kt new file mode 100644 index 000000000..14fac2f08 --- /dev/null +++ b/toolkit/samples/fooapp/fooapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/fooapp/domain/repository/SimpleRepository.kt @@ -0,0 +1,32 @@ +package tech.coner.trailer.toolkit.sample.fooapp.domain.repository + +import arrow.core.Either + +interface SimpleRepository { + + fun create(value: VALUE): Result> + + sealed interface CreateFailure { + data object AlreadyExists : CreateFailure + } + + fun read(key: KEY): Result> + + sealed interface ReadFailure { + data object NotFound : ReadFailure + } + + fun update(value: VALUE): Result> + + sealed interface UpdateFailure { + data object NotFound : UpdateFailure + } + + fun delete(key: KEY): Result> + + sealed interface DeleteFailure { + data object NotFound : DeleteFailure + } + + fun exists(key: KEY): Result +} \ No newline at end of file diff --git a/toolkit/samples/fooapp/fooapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/fooapp/domain/service/FooService.kt b/toolkit/samples/fooapp/fooapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/fooapp/domain/service/FooService.kt new file mode 100644 index 000000000..bfc647e2d --- /dev/null +++ b/toolkit/samples/fooapp/fooapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/fooapp/domain/service/FooService.kt @@ -0,0 +1,36 @@ +package tech.coner.trailer.toolkit.sample.fooapp.domain.service + +import arrow.core.Either +import tech.coner.trailer.toolkit.sample.fooapp.domain.entity.Foo +import tech.coner.trailer.toolkit.sample.fooapp.domain.validation.FooFeedback +import tech.coner.trailer.toolkit.validation.ValidationOutcome + +interface FooService { + + suspend fun create(create: Foo): Result> + + sealed interface CreateFailure { + data class Invalid(val validationOutcome: ValidationOutcome) : CreateFailure + data object AlreadyExists : CreateFailure + } + + suspend fun findByKey(key: Foo.Id): Result> + + sealed interface FindFailure { + data object NotFound : FindFailure + } + + suspend fun update(update: Foo): Result> + + sealed interface UpdateFailure { + data class Invalid(val validationOutcome: ValidationOutcome) : UpdateFailure + data object NotFound : UpdateFailure + } + + suspend fun deleteByKey(key: Foo.Id): Result> + + sealed interface DeleteFailure { + data object NotFound : DeleteFailure + } + +} \ No newline at end of file diff --git a/toolkit/samples/fooapp/fooapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/fooapp/domain/validation/FooFeedback.kt b/toolkit/samples/fooapp/fooapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/fooapp/domain/validation/FooFeedback.kt new file mode 100644 index 000000000..b515b1bb5 --- /dev/null +++ b/toolkit/samples/fooapp/fooapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/fooapp/domain/validation/FooFeedback.kt @@ -0,0 +1,24 @@ +package tech.coner.trailer.toolkit.sample.fooapp.domain.validation + +import tech.coner.trailer.toolkit.sample.fooapp.domain.entity.Foo +import tech.coner.trailer.toolkit.validation.Feedback +import tech.coner.trailer.toolkit.validation.Severity +import kotlin.reflect.KProperty1 + +sealed class FooFeedback : Feedback { + + override val severity = Severity.Error + + data object IdMustBeInRange : FooFeedback() { + override val property = Foo::id + } + data object NameMustBeLowercaseLettersOnly : FooFeedback() { + override val property = Foo::name + } + data object NameMustBeThreeCharacters : FooFeedback() { + override val property = Foo::name + } + data object NameOtherThanFooMustFollowPatternOfConsonantVowelConsonant : FooFeedback() { + override val property = Foo::name + } +} \ No newline at end of file diff --git a/toolkit/samples/fooapp/fooapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/fooapp/domain/validation/FooValidator.kt b/toolkit/samples/fooapp/fooapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/fooapp/domain/validation/FooValidator.kt new file mode 100644 index 000000000..4f18f418d --- /dev/null +++ b/toolkit/samples/fooapp/fooapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/fooapp/domain/validation/FooValidator.kt @@ -0,0 +1,28 @@ +package tech.coner.trailer.toolkit.sample.fooapp.domain.validation + +import tech.coner.trailer.toolkit.sample.fooapp.domain.entity.Foo +import tech.coner.trailer.toolkit.sample.fooapp.domain.validation.FooFeedback.IdMustBeInRange +import tech.coner.trailer.toolkit.sample.fooapp.domain.validation.FooFeedback.NameMustBeLowercaseLettersOnly +import tech.coner.trailer.toolkit.sample.fooapp.domain.validation.FooFeedback.NameMustBeThreeCharacters +import tech.coner.trailer.toolkit.sample.fooapp.domain.validation.FooFeedback.NameOtherThanFooMustFollowPatternOfConsonantVowelConsonant +import tech.coner.trailer.toolkit.validation.Validator + +typealias FooValidator = Validator + +fun FooValidator() = Validator { + Foo::id { id -> IdMustBeInRange.takeUnless { id.value in 0..4 } } + Foo::name { name -> NameMustBeLowercaseLettersOnly.takeUnless { name.all { it.isLowerCase() } } } + Foo::name { name -> NameMustBeThreeCharacters.takeUnless { name.length == 3 } } + + val vowels = "aeiouy" + val consonants = "bcdfghjklmnpqrstvwxz" + val namesOtherThanFooPattern = Regex("[$consonants][$vowels][$consonants]") + Foo::name { name -> + NameOtherThanFooMustFollowPatternOfConsonantVowelConsonant.takeUnless { + when (name) { + "foo" -> true + else -> namesOtherThanFooPattern.matches(name) + } + } + } +} \ No newline at end of file diff --git a/toolkit/samples/fooapp/fooapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/fooapp/presentation/adapter/FooEntityModelAdapter.kt b/toolkit/samples/fooapp/fooapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/fooapp/presentation/adapter/FooEntityModelAdapter.kt new file mode 100644 index 000000000..984f77b24 --- /dev/null +++ b/toolkit/samples/fooapp/fooapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/fooapp/presentation/adapter/FooEntityModelAdapter.kt @@ -0,0 +1,23 @@ +package tech.coner.trailer.toolkit.sample.fooapp.presentation.adapter + +import tech.coner.trailer.toolkit.presentation.adapter.EntityModelAdapter +import tech.coner.trailer.toolkit.sample.fooapp.domain.entity.Foo +import tech.coner.trailer.toolkit.sample.fooapp.presentation.model.FooModel +import tech.coner.trailer.toolkit.sample.fooapp.util.capitalizeFirstChar + +class FooEntityModelAdapter : EntityModelAdapter() { + override val entityToModelAdapter: (Foo) -> FooModel = { + FooModel( + id = it.id, + name = entityToModelNamePropertyAdapter(it.name) + ) + } + override val modelToEntityAdapter: (FooModel) -> Foo = { + Foo( + id = it.id, + name = it.name.lowercase() + ) + } + + val entityToModelNamePropertyAdapter: (String) -> String = { it.capitalizeFirstChar() } +} \ No newline at end of file diff --git a/toolkit/samples/fooapp/fooapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/fooapp/presentation/model/FooItemModel.kt b/toolkit/samples/fooapp/fooapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/fooapp/presentation/model/FooItemModel.kt new file mode 100644 index 000000000..ab52616ea --- /dev/null +++ b/toolkit/samples/fooapp/fooapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/fooapp/presentation/model/FooItemModel.kt @@ -0,0 +1,26 @@ +package tech.coner.trailer.toolkit.sample.fooapp.presentation.model + +import tech.coner.trailer.toolkit.presentation.model.BaseItemModel +import tech.coner.trailer.toolkit.sample.fooapp.domain.entity.Foo +import tech.coner.trailer.toolkit.sample.fooapp.domain.validation.FooFeedback +import tech.coner.trailer.toolkit.sample.fooapp.domain.validation.FooValidator +import tech.coner.trailer.toolkit.sample.fooapp.presentation.adapter.FooEntityModelAdapter +import tech.coner.trailer.toolkit.sample.fooapp.presentation.validation.FooModelFeedback +import tech.coner.trailer.toolkit.validation.Validator + +class FooItemModel( + override val initialItem: FooModel, + private val adapter: FooEntityModelAdapter = FooEntityModelAdapter() +) : BaseItemModel() { + + override val validator: Validator = Validator { + input( + otherTypeValidator = FooValidator(), + mapContextFn = {}, + mapInputFn = { adapter.modelToEntityAdapter(it) }, + mapFeedbackObjectFn = { FooModelFeedback(it) } + ) + } + override val validatorContext = Unit +} + diff --git a/toolkit/samples/fooapp/fooapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/fooapp/presentation/model/FooModel.kt b/toolkit/samples/fooapp/fooapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/fooapp/presentation/model/FooModel.kt new file mode 100644 index 000000000..76d8daadc --- /dev/null +++ b/toolkit/samples/fooapp/fooapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/fooapp/presentation/model/FooModel.kt @@ -0,0 +1,9 @@ +package tech.coner.trailer.toolkit.sample.fooapp.presentation.model + +import tech.coner.trailer.toolkit.presentation.model.Model +import tech.coner.trailer.toolkit.sample.fooapp.domain.entity.Foo + +data class FooModel( + val id: Foo.Id, + val name: String +) : Model diff --git a/toolkit/samples/fooapp/fooapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/fooapp/presentation/presenter/FooDetailPresenter.kt b/toolkit/samples/fooapp/fooapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/fooapp/presentation/presenter/FooDetailPresenter.kt new file mode 100644 index 000000000..875504176 --- /dev/null +++ b/toolkit/samples/fooapp/fooapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/fooapp/presentation/presenter/FooDetailPresenter.kt @@ -0,0 +1,87 @@ +package tech.coner.trailer.toolkit.sample.fooapp.presentation.presenter + +import arrow.core.Either +import arrow.core.raise.either +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.StateFlow +import tech.coner.trailer.toolkit.presentation.model.Loadable +import tech.coner.trailer.toolkit.presentation.model.whenLoadedSuccess +import tech.coner.trailer.toolkit.presentation.presenter.ItemModelPresenter +import tech.coner.trailer.toolkit.presentation.presenter.LoadablePresenter +import tech.coner.trailer.toolkit.presentation.presenter.PresenterCoroutineScope +import tech.coner.trailer.toolkit.presentation.presenter.StatefulPresenter +import tech.coner.trailer.toolkit.presentation.state.StateContainer +import tech.coner.trailer.toolkit.presentation.state.mutableLoadedProperty +import tech.coner.trailer.toolkit.sample.fooapp.domain.entity.Foo +import tech.coner.trailer.toolkit.sample.fooapp.domain.service.FooService +import tech.coner.trailer.toolkit.sample.fooapp.presentation.adapter.FooEntityModelAdapter +import tech.coner.trailer.toolkit.sample.fooapp.presentation.model.FooItemModel +import tech.coner.trailer.toolkit.sample.fooapp.presentation.model.FooModel +import tech.coner.trailer.toolkit.sample.fooapp.presentation.state.FooDetailState +import kotlin.time.Duration.Companion.seconds + +class FooDetailPresenter( + initialState: FooDetailState = FooDetailState(loadable = Loadable.Empty()), + private val argument: Foo.Id, + private val adapter: FooEntityModelAdapter, + private val service: FooService, + coroutineScope: PresenterCoroutineScope +) : LoadablePresenter, + StatefulPresenter, + ItemModelPresenter, + CoroutineScope by coroutineScope { + + private val stateContainer = StateContainer(initialState) + override val state: FooDetailState get() = stateContainer.state + override val stateFlow: StateFlow get() = stateContainer.stateFlow + + override suspend fun load(): Deferred>> = coroutineScope { + async { + runCatching { + stateContainer.update { it.copy(loadable = Loadable.Loading()) } + either { + service.findByKey(argument).getOrThrow() + .map { foo -> + // faking partially loaded with delay + delay(1.seconds) + val partial = FooItemModel( + adapter.entityToModelAdapter( + foo.copy(name = "${foo.name[0]}") + ) + ) + stateContainer.update { it.copy(loadable = Loadable.Loading(partial)) } + delay(1.seconds) + foo + } + .map { foo -> + FooItemModel(adapter.entityToModelAdapter(foo)) + .also { fooItemModel -> stateContainer.update { it.copy(loadable = Loadable.Loaded(Either.Right(fooItemModel))) } } + } + .onLeft { failure -> stateContainer.update { it.copy(loadable = Loadable.Loaded(Either.Left(failure))) } } + .bind() + } + } + .onFailure { throwable -> + stateContainer.update { it.copy(loadable = Loadable.FailedExceptionally(throwable)) } + } + } + } + + override suspend fun commit() { + state.loadable.whenLoadedSuccess { it.commit() } + } + + override suspend fun validate() { + state.loadable.whenLoadedSuccess { it.validate() } + } + + override suspend fun reset() { + state.loadable.whenLoadedSuccess { it.reset() } + } + + val name = stateContainer.mutableLoadedProperty( + getFn = { name }, + updateFn = { copy(name = adapter.entityToModelNamePropertyAdapter(it)) }, + property = FooModel::name + ) +} diff --git a/toolkit/samples/fooapp/fooapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/fooapp/presentation/state/FooDetailState.kt b/toolkit/samples/fooapp/fooapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/fooapp/presentation/state/FooDetailState.kt new file mode 100644 index 000000000..18133cddb --- /dev/null +++ b/toolkit/samples/fooapp/fooapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/fooapp/presentation/state/FooDetailState.kt @@ -0,0 +1,14 @@ +package tech.coner.trailer.toolkit.sample.fooapp.presentation.state + +import tech.coner.trailer.toolkit.presentation.model.Loadable +import tech.coner.trailer.toolkit.presentation.state.LoadableState +import tech.coner.trailer.toolkit.sample.fooapp.domain.service.FooService +import tech.coner.trailer.toolkit.sample.fooapp.domain.validation.FooFeedback +import tech.coner.trailer.toolkit.sample.fooapp.presentation.model.FooItemModel +import tech.coner.trailer.toolkit.sample.fooapp.presentation.model.FooModel +import tech.coner.trailer.toolkit.sample.fooapp.presentation.validation.FooModelFeedback + +data class FooDetailState( + override val loadable: Loadable = Loadable.Empty() +) : LoadableState { +} diff --git a/toolkit/samples/fooapp/fooapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/fooapp/presentation/validation/FooModelFeedback.kt b/toolkit/samples/fooapp/fooapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/fooapp/presentation/validation/FooModelFeedback.kt new file mode 100644 index 000000000..3ed36404a --- /dev/null +++ b/toolkit/samples/fooapp/fooapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/fooapp/presentation/validation/FooModelFeedback.kt @@ -0,0 +1,18 @@ +package tech.coner.trailer.toolkit.sample.fooapp.presentation.validation + +import tech.coner.trailer.toolkit.sample.fooapp.domain.entity.Foo +import tech.coner.trailer.toolkit.sample.fooapp.domain.validation.FooFeedback +import tech.coner.trailer.toolkit.sample.fooapp.presentation.model.FooModel +import tech.coner.trailer.toolkit.validation.Feedback +import tech.coner.trailer.toolkit.validation.FeedbackDelegate +import tech.coner.trailer.toolkit.validation.adapter.propertyAdapterOf + +data class FooModelFeedback(val source: FooFeedback) + : Feedback by FeedbackDelegate( + feedback = source, + propertyAdapter = propertyAdapterOf( + Foo::id to FooModel::id, + Foo::name to FooModel::name, + null to null, + ) +) diff --git a/toolkit/samples/fooapp/fooapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/fooapp/util/StringExtensions.kt b/toolkit/samples/fooapp/fooapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/fooapp/util/StringExtensions.kt new file mode 100644 index 000000000..06e3d4d4a --- /dev/null +++ b/toolkit/samples/fooapp/fooapp-common/src/main/kotlin/tech/coner/trailer/toolkit/sample/fooapp/util/StringExtensions.kt @@ -0,0 +1,7 @@ +package tech.coner.trailer.toolkit.sample.fooapp.util + +fun String.capitalizeFirstChar() = when (length) { + 0 -> this + 1 -> uppercase() + else -> "${this[0].uppercase()}${substring(1)}" +} \ No newline at end of file diff --git a/toolkit/presentation/presentation-test/src/test/kotlin/tech/coner/trailer/toolkit/presentation/testsupport/fooapp/domain/entity/FooAssertk.kt b/toolkit/samples/fooapp/fooapp-common/src/test/kotlin/tech/coner/trailer/toolkit/sample/fooapp/domain/entity/FooAssertk.kt similarity index 62% rename from toolkit/presentation/presentation-test/src/test/kotlin/tech/coner/trailer/toolkit/presentation/testsupport/fooapp/domain/entity/FooAssertk.kt rename to toolkit/samples/fooapp/fooapp-common/src/test/kotlin/tech/coner/trailer/toolkit/sample/fooapp/domain/entity/FooAssertk.kt index f28f0e76f..d146a7ecf 100644 --- a/toolkit/presentation/presentation-test/src/test/kotlin/tech/coner/trailer/toolkit/presentation/testsupport/fooapp/domain/entity/FooAssertk.kt +++ b/toolkit/samples/fooapp/fooapp-common/src/test/kotlin/tech/coner/trailer/toolkit/sample/fooapp/domain/entity/FooAssertk.kt @@ -1,4 +1,4 @@ -package tech.coner.trailer.toolkit.presentation.testsupport.fooapp.domain.entity +package tech.coner.trailer.toolkit.sample.fooapp.domain.entity import assertk.Assert import assertk.assertions.prop diff --git a/toolkit/samples/fooapp/fooapp-common/src/test/kotlin/tech/coner/trailer/toolkit/sample/fooapp/presentation/model/FooModelAssertk.kt b/toolkit/samples/fooapp/fooapp-common/src/test/kotlin/tech/coner/trailer/toolkit/sample/fooapp/presentation/model/FooModelAssertk.kt new file mode 100644 index 000000000..e2573cb71 --- /dev/null +++ b/toolkit/samples/fooapp/fooapp-common/src/test/kotlin/tech/coner/trailer/toolkit/sample/fooapp/presentation/model/FooModelAssertk.kt @@ -0,0 +1,6 @@ +package tech.coner.trailer.toolkit.sample.fooapp.presentation.model + +import assertk.Assert +import assertk.assertions.prop + +fun Assert.name() = prop(FooModel::name) \ No newline at end of file diff --git a/toolkit/samples/fooapp/fooapp-common/src/test/kotlin/tech/coner/trailer/toolkit/sample/fooapp/presentation/presenter/FooDetailPresenterTest.kt b/toolkit/samples/fooapp/fooapp-common/src/test/kotlin/tech/coner/trailer/toolkit/sample/fooapp/presentation/presenter/FooDetailPresenterTest.kt new file mode 100644 index 000000000..aca76b8ef --- /dev/null +++ b/toolkit/samples/fooapp/fooapp-common/src/test/kotlin/tech/coner/trailer/toolkit/sample/fooapp/presentation/presenter/FooDetailPresenterTest.kt @@ -0,0 +1,217 @@ +package tech.coner.trailer.toolkit.sample.fooapp.presentation.presenter + +import app.cash.turbine.test +import arrow.core.Either +import arrow.core.right +import assertk.Assert +import assertk.all +import assertk.assertThat +import assertk.assertions.* +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.Job +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import tech.coner.trailer.assertk.arrowkt.isLeft +import tech.coner.trailer.assertk.arrowkt.isRight +import tech.coner.trailer.toolkit.Error +import tech.coner.trailer.toolkit.presentation.model.* +import tech.coner.trailer.toolkit.presentation.model.isDirty +import tech.coner.trailer.toolkit.presentation.model.isValid +import tech.coner.trailer.toolkit.presentation.model.item +import tech.coner.trailer.toolkit.presentation.model.pendingItem +import tech.coner.trailer.toolkit.presentation.model.pendingItemValidation +import tech.coner.trailer.toolkit.presentation.presenter.PresenterCoroutineScope +import tech.coner.trailer.toolkit.presentation.state.loadable +import tech.coner.trailer.toolkit.presentation.state.state +import tech.coner.trailer.toolkit.presentation.state.value +import tech.coner.trailer.toolkit.sample.fooapp.data.repository.FooRepositoryImpl +import tech.coner.trailer.toolkit.sample.fooapp.data.service.FooServiceImpl +import tech.coner.trailer.toolkit.sample.fooapp.domain.entity.FOO_ID_FOO +import tech.coner.trailer.toolkit.sample.fooapp.domain.entity.Foo +import tech.coner.trailer.toolkit.sample.fooapp.domain.repository.FooRepository +import tech.coner.trailer.toolkit.sample.fooapp.domain.service.FooService +import tech.coner.trailer.toolkit.sample.fooapp.domain.validation.FooValidator +import tech.coner.trailer.toolkit.sample.fooapp.presentation.adapter.FooEntityModelAdapter +import tech.coner.trailer.toolkit.sample.fooapp.presentation.model.FooItemModel +import tech.coner.trailer.toolkit.sample.fooapp.presentation.model.name +import tech.coner.trailer.toolkit.sample.fooapp.presentation.state.FooDetailState +import tech.coner.trailer.toolkit.validation.testsupport.isValid + +class FooDetailPresenterTest { + + private val repository: FooRepository = FooRepositoryImpl() + + @Test + fun itsModelFlowShouldBeAdaptedFromInitialState() = runTest { + val id = Foo.Id(FOO_ID_FOO) + val presenter = createPresenterInitial(id) + + presenter.stateFlow.test { + assertThat(expectMostRecentItem()) + .loadable() + .isEmpty() + } + } + + @Test + fun itsModelFlowShouldEmitWhenLoadingAndLoaded() = runTest { + val id = Foo.Id(FOO_ID_FOO) + val presenter = createPresenterInitial(id) + + presenter.stateFlow.test { + skipItems(1) + + presenter.load() + + assertThat(awaitItem()) + .loadable() + .isLoading() + .partial() + .isNull() + assertThat(awaitItem()) + .loadable() + .isLoading() + .partial() + .isNotNull() + .item() + .name() + .length() + .isEqualTo(1) + assertThat(awaitItem()) + .loadable() + .isLoaded() + .either() + .isRight() + .item() + .name() + .isEqualTo("Foo") + } + } + + @Test + fun itsModelFlowShouldEmitWhenLoadingAndLoadFailedNotFound() = runTest { + val id = Foo.Id(Int.MAX_VALUE) + val presenter = createPresenterInitial(id) + + presenter.stateFlow.test { + skipItems(1) + + presenter.load() + + assertThat(awaitItem()) + .loadable() + .isLoading() + assertThat(awaitItem()) + .loadable() + .isLoaded() + .either() + .isLeft() + .isEqualTo(FooService.FindFailure.NotFound) + } + } + + @Test + fun whenItsModelNameChangedValidItsItemModelShouldBeValid() = runTest { + val foo = repository.read(Foo.Id(FOO_ID_FOO)).getOrThrow().getOrNull()!! + val presenter = createPresenterLoaded(foo) + + presenter.name.value = "bax" + presenter.validate() + + assertThat(presenter).all { + name().value().isEqualTo("Bax") + state() + .loadable() + .isLoaded() + .either() + .isRight() + .all { + isValid().isTrue() + isDirty().isTrue() + } + } + } + + @Test + fun whenItsModelNameChangedInvalidItsItemModelShouldBeInvalid() = runTest { + val foo = repository.read(Foo.Id(FOO_ID_FOO)).getOrThrow().getOrNull()!! + val presenter = createPresenterLoaded(foo) + + presenter.name.value = "boo" + presenter.validate() + + assertThat(presenter).all { + name().value().isEqualTo("Boo") + state() + .loadable() + .isLoaded() + .either() + .isRight() + .all { + isValid().isFalse() + isDirty().isTrue() + } + } + } + + @ParameterizedTest + @ValueSource(strings = ["Bar", "Baz", "Bat", "Cat", "Dat", "Far", "Ber", "Fir", "Nor", "Dur", "Xyr"]) + fun whenItsModelNameChangedValidItsItemModelShouldValidateValid(newName: String) = runTest { + val foo = repository.read(Foo.Id(FOO_ID_FOO)).getOrThrow().getOrNull()!! + val presenter = createPresenterLoaded(foo) + + presenter.name.value = newName + presenter.validate() + + assertThat(presenter).all { + state() + .loadable() + .isLoaded() + .either() + .isRight() + .all { + pendingItem().name().isEqualTo(newName) + isValid().isTrue() + pendingItemValidation().isValid() + isDirty().isTrue() + } + } + } +} + +private fun TestScope.createPresenterInitial(id: Foo.Id): FooDetailPresenter { + return createPresenter( + argument = id, + ) +} + +private fun TestScope.createPresenterLoaded(foo: Foo): FooDetailPresenter { + val adapter = FooEntityModelAdapter() + return createPresenter( + initialState = FooDetailState(loadable = Loadable.Loaded(Either.Right(FooItemModel(adapter.entityToModelAdapter(foo))))), + argument = foo.id, + adapter = adapter, + ) +} + +private fun TestScope.createPresenter( + initialState: FooDetailState = FooDetailState(), + argument: Foo.Id, + adapter: FooEntityModelAdapter = FooEntityModelAdapter() +): FooDetailPresenter { + return FooDetailPresenter( + initialState = initialState, + argument = argument, + adapter = adapter, + service = FooServiceImpl( + repository = FooRepositoryImpl(), + validator = FooValidator(), + ), + coroutineScope = PresenterCoroutineScope(coroutineContext + Job() + CoroutineName("FooDetailPresenter")) + ) +} + +fun Assert.name() = prop(FooDetailPresenter::name) diff --git a/toolkit/util/pom.xml b/toolkit/samples/fooapp/fooapp-parent/pom.xml similarity index 60% rename from toolkit/util/pom.xml rename to toolkit/samples/fooapp/fooapp-parent/pom.xml index 20e973e32..6e0b63bac 100644 --- a/toolkit/util/pom.xml +++ b/toolkit/samples/fooapp/fooapp-parent/pom.xml @@ -5,12 +5,16 @@ 4.0.0 tech.coner.trailer - parent + toolkit-sample-common 0.1.0-SNAPSHOT - ../../pom.xml + ../../samples-common/pom.xml - toolkit-util + toolkit-sample-fooapp-parent + pom + + ../fooapp-common + \ No newline at end of file diff --git a/toolkit/samples/passwordapp/src/main/kotlin/tech/coner/trailer/toolkit/sample/passwordapp/domain/entity/Password.kt b/toolkit/samples/passwordapp/src/main/kotlin/tech/coner/trailer/toolkit/sample/passwordapp/domain/entity/Password.kt new file mode 100644 index 000000000..5ac595892 --- /dev/null +++ b/toolkit/samples/passwordapp/src/main/kotlin/tech/coner/trailer/toolkit/sample/passwordapp/domain/entity/Password.kt @@ -0,0 +1,4 @@ +package tech.coner.trailer.toolkit.sample.passwordapp.domain.entity + +@JvmInline +value class Password(val value: String) diff --git a/toolkit/samples/passwordapp/src/main/kotlin/tech/coner/trailer/toolkit/sample/passwordapp/domain/state/ChangePasswordFormatState.kt b/toolkit/samples/passwordapp/src/main/kotlin/tech/coner/trailer/toolkit/sample/passwordapp/domain/state/ChangePasswordFormatState.kt index bf57b198b..7e1c6842f 100644 --- a/toolkit/samples/passwordapp/src/main/kotlin/tech/coner/trailer/toolkit/sample/passwordapp/domain/state/ChangePasswordFormatState.kt +++ b/toolkit/samples/passwordapp/src/main/kotlin/tech/coner/trailer/toolkit/sample/passwordapp/domain/state/ChangePasswordFormatState.kt @@ -1,12 +1,13 @@ package tech.coner.trailer.toolkit.sample.passwordapp.domain.state +import tech.coner.trailer.toolkit.sample.passwordapp.domain.entity.Password import tech.coner.trailer.toolkit.sample.passwordapp.domain.entity.PasswordPolicy data class ChangePasswordFormState( val passwordPolicy: PasswordPolicy, - val currentPassword: String, - val newPassword: String, - val newPasswordRepeated: String + val currentPassword: Password, + val newPassword: Password, + val newPasswordRepeated: Password ) diff --git a/toolkit/samples/passwordapp/src/main/kotlin/tech/coner/trailer/toolkit/sample/passwordapp/domain/validation/ChangePasswordFormFeedback.kt b/toolkit/samples/passwordapp/src/main/kotlin/tech/coner/trailer/toolkit/sample/passwordapp/domain/validation/ChangePasswordFormFeedback.kt index 237586529..32333bb95 100644 --- a/toolkit/samples/passwordapp/src/main/kotlin/tech/coner/trailer/toolkit/sample/passwordapp/domain/validation/ChangePasswordFormFeedback.kt +++ b/toolkit/samples/passwordapp/src/main/kotlin/tech/coner/trailer/toolkit/sample/passwordapp/domain/validation/ChangePasswordFormFeedback.kt @@ -1,19 +1,24 @@ package tech.coner.trailer.toolkit.sample.passwordapp.domain.validation +import tech.coner.trailer.toolkit.sample.passwordapp.domain.state.ChangePasswordFormState import tech.coner.trailer.toolkit.validation.Feedback import tech.coner.trailer.toolkit.validation.Severity +import kotlin.reflect.KProperty1 -sealed class ChangePasswordFormFeedback : Feedback { - data object MustNotBeEmpty : ChangePasswordFormFeedback() { +sealed class ChangePasswordFormFeedback : Feedback { + data class MustNotBeEmpty(override val property: KProperty1) : ChangePasswordFormFeedback() { override val severity = Severity.Error } data object NewPasswordSameAsCurrentPassword : ChangePasswordFormFeedback() { + override val property = ChangePasswordFormState::newPassword override val severity = Severity.Error } data class NewPasswordFeedback(val feedback: PasswordFeedback) : ChangePasswordFormFeedback() { + override val property = ChangePasswordFormState::newPassword override val severity: Severity get() = feedback.severity } data object RepeatPasswordMismatch : ChangePasswordFormFeedback() { + override val property = ChangePasswordFormState::newPasswordRepeated override val severity = Severity.Error } } \ No newline at end of file diff --git a/toolkit/samples/passwordapp/src/main/kotlin/tech/coner/trailer/toolkit/sample/passwordapp/domain/validation/ChangePasswordFormValidator.kt b/toolkit/samples/passwordapp/src/main/kotlin/tech/coner/trailer/toolkit/sample/passwordapp/domain/validation/ChangePasswordFormValidator.kt index cc578e4d4..57e739bc6 100644 --- a/toolkit/samples/passwordapp/src/main/kotlin/tech/coner/trailer/toolkit/sample/passwordapp/domain/validation/ChangePasswordFormValidator.kt +++ b/toolkit/samples/passwordapp/src/main/kotlin/tech/coner/trailer/toolkit/sample/passwordapp/domain/validation/ChangePasswordFormValidator.kt @@ -16,11 +16,9 @@ typealias ChangePasswordFormValidator = Validator - MustNotBeEmpty.takeIf { currentPassword.isEmpty() } - } - ChangePasswordFormState::newPassword { newPassword: String -> - NewPasswordSameAsCurrentPassword.takeIf { input.currentPassword.isNotEmpty() && newPassword == input.currentPassword } + ChangePasswordFormState::currentPassword { if (it.value.isEmpty()) MustNotBeEmpty(property) else null } + ChangePasswordFormState::newPassword { newPassword -> + NewPasswordSameAsCurrentPassword.takeIf { input.currentPassword.value.isNotEmpty() && newPassword == input.currentPassword } } ChangePasswordFormState::newPassword.invoke(passwordValidator, { it.passwordPolicy }, ::NewPasswordFeedback) ChangePasswordFormState::newPasswordRepeated { newPasswordRepeated -> diff --git a/toolkit/samples/passwordapp/src/main/kotlin/tech/coner/trailer/toolkit/sample/passwordapp/domain/validation/PasswordFeedback.kt b/toolkit/samples/passwordapp/src/main/kotlin/tech/coner/trailer/toolkit/sample/passwordapp/domain/validation/PasswordFeedback.kt index 6bfe1c9d7..6423dd5d7 100644 --- a/toolkit/samples/passwordapp/src/main/kotlin/tech/coner/trailer/toolkit/sample/passwordapp/domain/validation/PasswordFeedback.kt +++ b/toolkit/samples/passwordapp/src/main/kotlin/tech/coner/trailer/toolkit/sample/passwordapp/domain/validation/PasswordFeedback.kt @@ -1,9 +1,11 @@ package tech.coner.trailer.toolkit.sample.passwordapp.domain.validation +import tech.coner.trailer.toolkit.sample.passwordapp.domain.entity.Password import tech.coner.trailer.toolkit.validation.Feedback import tech.coner.trailer.toolkit.validation.Severity -sealed class PasswordFeedback : Feedback { +sealed class PasswordFeedback : Feedback { + override val property = null data class InsufficientLength(override val severity: Severity) : PasswordFeedback() data class InsufficientLetterLowercase(override val severity: Severity) : PasswordFeedback() data class InsufficientLetterUppercase(override val severity: Severity) : PasswordFeedback() diff --git a/toolkit/samples/passwordapp/src/main/kotlin/tech/coner/trailer/toolkit/sample/passwordapp/domain/validation/PasswordValidator.kt b/toolkit/samples/passwordapp/src/main/kotlin/tech/coner/trailer/toolkit/sample/passwordapp/domain/validation/PasswordValidator.kt index d4cc750a1..998e2e5bf 100644 --- a/toolkit/samples/passwordapp/src/main/kotlin/tech/coner/trailer/toolkit/sample/passwordapp/domain/validation/PasswordValidator.kt +++ b/toolkit/samples/passwordapp/src/main/kotlin/tech/coner/trailer/toolkit/sample/passwordapp/domain/validation/PasswordValidator.kt @@ -1,5 +1,6 @@ package tech.coner.trailer.toolkit.sample.passwordapp.domain.validation +import tech.coner.trailer.toolkit.sample.passwordapp.domain.entity.Password import tech.coner.trailer.toolkit.sample.passwordapp.domain.entity.PasswordPolicy import tech.coner.trailer.toolkit.sample.passwordapp.domain.validation.PasswordFeedback.InsufficientLength import tech.coner.trailer.toolkit.sample.passwordapp.domain.validation.PasswordFeedback.InsufficientLetterLowercase @@ -11,15 +12,15 @@ import tech.coner.trailer.toolkit.validation.Severity.Error import tech.coner.trailer.toolkit.validation.Severity.Warning import tech.coner.trailer.toolkit.validation.Validator -typealias PasswordValidator = Validator +typealias PasswordValidator = Validator fun PasswordValidator(): PasswordValidator = Validator { input( - { context.lengthThreshold(it.length, ::InsufficientLength) }, - { context.letterLowercaseThreshold(it.count { char -> char.isLowerCase() }, ::InsufficientLetterLowercase ) }, - { context.letterUppercaseThreshold(it.count { char -> char.isUpperCase() }, ::InsufficientLetterUppercase ) }, - { context.numericThreshold(it.count { char -> char.isDigit() }, ::InsufficientNumeric ) }, - { context.specialThreshold(it.count { char -> !char.isLetterOrDigit() }, ::InsufficientSpecial ) } + { context.lengthThreshold(it.value.length, ::InsufficientLength) }, + { context.letterLowercaseThreshold(it.value.count { char -> char.isLowerCase() }, ::InsufficientLetterLowercase ) }, + { context.letterUppercaseThreshold(it.value.count { char -> char.isUpperCase() }, ::InsufficientLetterUppercase ) }, + { context.numericThreshold(it.value.count { char -> char.isDigit() }, ::InsufficientNumeric ) }, + { context.specialThreshold(it.value.count { char -> !char.isLetterOrDigit() }, ::InsufficientSpecial ) } ) } diff --git a/toolkit/samples/passwordapp/src/test/kotlin/validation/ChangePasswordFormValidatorTest.kt b/toolkit/samples/passwordapp/src/test/kotlin/validation/ChangePasswordFormValidatorTest.kt index 5359dfe12..35a1865e0 100644 --- a/toolkit/samples/passwordapp/src/test/kotlin/validation/ChangePasswordFormValidatorTest.kt +++ b/toolkit/samples/passwordapp/src/test/kotlin/validation/ChangePasswordFormValidatorTest.kt @@ -9,6 +9,7 @@ import io.mockk.every import io.mockk.mockk import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.EnumSource +import tech.coner.trailer.toolkit.sample.passwordapp.domain.entity.Password import tech.coner.trailer.toolkit.sample.passwordapp.domain.entity.PasswordPolicy.Factory.anyOneChar import tech.coner.trailer.toolkit.sample.passwordapp.domain.entity.PasswordPolicy.Factory.irritating import tech.coner.trailer.toolkit.sample.passwordapp.domain.state.ChangePasswordFormState @@ -27,9 +28,10 @@ import tech.coner.trailer.toolkit.sample.passwordapp.domain.validation.PasswordF import tech.coner.trailer.toolkit.sample.passwordapp.domain.validation.PasswordValidator import tech.coner.trailer.toolkit.validation.Severity.Error import tech.coner.trailer.toolkit.validation.Severity.Warning -import tech.coner.trailer.toolkit.validation.ValidationResult +import tech.coner.trailer.toolkit.validation.ValidationOutcome import tech.coner.trailer.toolkit.validation.invoke import tech.coner.trailer.toolkit.validation.testsupport.feedback +import tech.coner.trailer.toolkit.validation.testsupport.feedbackByProperty import tech.coner.trailer.toolkit.validation.testsupport.isInvalid import tech.coner.trailer.toolkit.validation.testsupport.isValid @@ -54,28 +56,28 @@ class ChangePasswordFormValidatorTest { mockNewPasswordFeedback = listOf(InsufficientLength(Error)), input = ChangePasswordFormState( passwordPolicy = anyOneChar(), - currentPassword = "", - newPassword = "", - newPasswordRepeated = "" + currentPassword = Password(""), + newPassword = Password(""), + newPasswordRepeated = Password("") ), - expectedCurrentPasswordFeedback = listOf(MustNotBeEmpty), + expectedCurrentPasswordFeedback = listOf(MustNotBeEmpty(ChangePasswordFormState::currentPassword)), expectedIsValid = false ), MINIMUM_ANY_ONE_CHAR_VALID( input = ChangePasswordFormState( passwordPolicy = anyOneChar(), - currentPassword = "a", - newPassword = "b", - newPasswordRepeated = "b" + currentPassword = Password("a"), + newPassword = Password("b"), + newPasswordRepeated = Password("b") ), expectedIsValid = true ), SAME_ANY_ONE_CHAR_INVALID( input = ChangePasswordFormState( passwordPolicy = anyOneChar(), - currentPassword = "a", - newPassword = "a", - newPasswordRepeated = "a" + currentPassword = Password("a"), + newPassword = Password("a"), + newPasswordRepeated = Password("a") ), expectedLocalNewPasswordFeedback = listOf(NewPasswordSameAsCurrentPassword), expectedIsValid = false @@ -83,9 +85,9 @@ class ChangePasswordFormValidatorTest { REPEAT_MISMATCH_ANY_ONE_CHAR_INVALID( input = ChangePasswordFormState( passwordPolicy = anyOneChar(), - currentPassword = "a", - newPassword = "b", - newPasswordRepeated = "c" + currentPassword = Password("a"), + newPassword = Password("b"), + newPasswordRepeated = Password("c") ), expectedNewPasswordRepeatedFeedback = listOf(RepeatPasswordMismatch), expectedIsValid = false @@ -100,9 +102,9 @@ class ChangePasswordFormValidatorTest { ), input = ChangePasswordFormState( passwordPolicy = irritating(), - currentPassword = "a", - newPassword = "aA1!", - newPasswordRepeated = "aA1!" + currentPassword = Password("a"), + newPassword = Password("aA1!"), + newPasswordRepeated = Password("aA1!") ), expectedIsValid = false ), @@ -114,9 +116,9 @@ class ChangePasswordFormValidatorTest { ), input = ChangePasswordFormState( passwordPolicy = irritating(), - currentPassword = "a", - newPassword = "Tr0ub4dor&3", - newPasswordRepeated = "Tr0ub4dor&3" + currentPassword = Password("a"), + newPassword = Password("Tr0ub4dor&3"), + newPasswordRepeated = Password("Tr0ub4dor&3") ), expectedIsValid = true ), @@ -127,9 +129,9 @@ class ChangePasswordFormValidatorTest { ), input = ChangePasswordFormState( passwordPolicy = irritating(), - currentPassword = "a", - newPassword = "battery horse staple correct", - newPasswordRepeated = "battery horse staple correct" + currentPassword = Password("a"), + newPassword = Password("battery horse staple correct"), + newPasswordRepeated = Password("battery horse staple correct") ), expectedIsValid = false ); @@ -156,16 +158,16 @@ class ChangePasswordFormValidatorTest { every { passwordValidator(scenario.input.passwordPolicy, scenario.input.newPassword) } returns( - ValidationResult( + ValidationOutcome( scenario.mockNewPasswordFeedback - ?.let { mapOf(null to it) } - ?: emptyMap()) + ?: emptyList() + ) ) val actual = changePasswordFormValidator(scenario.input) assertThat(actual).all { - feedback().all { + feedbackByProperty().all { when (scenario.expectedCurrentPasswordFeedback) { is List -> key(ChangePasswordFormState::currentPassword).isEqualTo(scenario.expectedCurrentPasswordFeedback) else -> doesNotContainKey(ChangePasswordFormState::currentPassword) diff --git a/toolkit/samples/passwordapp/src/test/kotlin/validation/PasswordValidatorTest.kt b/toolkit/samples/passwordapp/src/test/kotlin/validation/PasswordValidatorTest.kt index cd43d8aa6..09181ffc3 100644 --- a/toolkit/samples/passwordapp/src/test/kotlin/validation/PasswordValidatorTest.kt +++ b/toolkit/samples/passwordapp/src/test/kotlin/validation/PasswordValidatorTest.kt @@ -7,6 +7,7 @@ import assertk.assertions.isEqualTo import assertk.assertions.key import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.EnumSource +import tech.coner.trailer.toolkit.sample.passwordapp.domain.entity.Password import tech.coner.trailer.toolkit.sample.passwordapp.domain.entity.PasswordPolicy import tech.coner.trailer.toolkit.sample.passwordapp.domain.entity.PasswordPolicy.Factory.anyOneChar import tech.coner.trailer.toolkit.sample.passwordapp.domain.entity.PasswordPolicy.Factory.irritating @@ -20,6 +21,7 @@ import tech.coner.trailer.toolkit.sample.passwordapp.domain.validation.PasswordV import tech.coner.trailer.toolkit.validation.Severity.Error import tech.coner.trailer.toolkit.validation.Severity.Warning import tech.coner.trailer.toolkit.validation.testsupport.feedback +import tech.coner.trailer.toolkit.validation.testsupport.feedbackByProperty import tech.coner.trailer.toolkit.validation.testsupport.isInvalid import tech.coner.trailer.toolkit.validation.testsupport.isValid @@ -29,24 +31,24 @@ class PasswordValidatorTest { enum class PasswordScenario( val policy: PasswordPolicy, - val password: String, + val password: Password, val expectedFeedback: List? = null, val expectedIsValid: Boolean ) { EMPTY_ANY_ONE_CHAR_INVALID( policy = anyOneChar(), - password = "", + password = Password(""), expectedFeedback = listOf(InsufficientLength(Error)), expectedIsValid = false ), MINIMUM_ANY_ONE_CHAR_VALID( policy = anyOneChar(), - password = "b", + password = Password("b"), expectedIsValid = true ), IRRITATING_INVALID( policy = irritating(), - password = "aA1!", + password = Password("aA1!"), expectedFeedback = listOf( InsufficientLength(Error), InsufficientLetterLowercase(Warning), @@ -58,7 +60,7 @@ class PasswordValidatorTest { ), IRRITATING_VALID_WITH_WARNINGS( policy = irritating(), - password = "Tr0ub4dor&3", + password = Password("Tr0ub4dor&3"), expectedFeedback = listOf( InsufficientLength(Warning), InsufficientLetterUppercase(Warning), @@ -68,7 +70,7 @@ class PasswordValidatorTest { ), IRRITATING_HARD_TO_REMEMBER_EASY_GUESS_FOR_COMPUTER( policy = irritating(), - password = "battery horse staple correct", + password = Password("battery horse staple correct"), expectedFeedback = listOf( InsufficientLetterUppercase(Error), InsufficientNumeric(Error) @@ -83,7 +85,7 @@ class PasswordValidatorTest { val actual = validator(scenario.policy, scenario.password) assertThat(actual).all { - feedback().all { + feedbackByProperty().all { if (scenario.expectedFeedback?.isNotEmpty() == true) { key(null).isEqualTo(scenario.expectedFeedback) } else { diff --git a/toolkit/samples/samples-common/pom.xml b/toolkit/samples/samples-common/pom.xml index 4a245b088..6afec156c 100644 --- a/toolkit/samples/samples-common/pom.xml +++ b/toolkit/samples/samples-common/pom.xml @@ -5,14 +5,15 @@ 4.0.0 tech.coner.trailer - parent + buildsrc-kotlin-parent 0.1.0-SNAPSHOT - ../../../pom.xml + ../../../buildsrc/buildsrc-kotlin-parent/pom.xml toolkit-sample-common pom + ../fooapp/fooapp-parent ../passwordapp ../dmvapp/dmvapp-parent diff --git a/toolkit/toolkit/pom.xml b/toolkit/toolkit/pom.xml new file mode 100644 index 000000000..7ae8c8b2a --- /dev/null +++ b/toolkit/toolkit/pom.xml @@ -0,0 +1,23 @@ + + + 4.0.0 + + tech.coner.trailer + buildsrc-kotlin-parent + 0.1.0-SNAPSHOT + ../../buildsrc/buildsrc-kotlin-parent/pom.xml + + + toolkit + + + + tech.coner.trailer + toolkit-validation + 0.1.0-SNAPSHOT + + + + \ No newline at end of file diff --git a/toolkit/toolkit/src/main/kotlin/tech/coner/trailer/toolkit/Error.kt b/toolkit/toolkit/src/main/kotlin/tech/coner/trailer/toolkit/Error.kt new file mode 100644 index 000000000..8202ab92f --- /dev/null +++ b/toolkit/toolkit/src/main/kotlin/tech/coner/trailer/toolkit/Error.kt @@ -0,0 +1,29 @@ +package tech.coner.trailer.toolkit + +sealed interface Error { + data object NotFound : Error + data object AlreadyExists : Error + data object ConcurrentEntry : Error + data object InvalidState : Error + + /** + * Indicates unhandled invalid input. + * + * Toolkit users should take care to handle MutationOutcome.InvalidInputFailure and make its ValidationResult's + * feedback available to the user in context. + */ + data object InvalidInput : Error + data object Timeout : Error + + // expand as necessary + + data class Exceptional(val throwable: Throwable) : Error + + fun toException() = ErrorException( + error = this, + cause = when (this) { + is Exceptional -> throwable + else -> null + } + ) +} \ No newline at end of file diff --git a/toolkit/toolkit/src/main/kotlin/tech/coner/trailer/toolkit/ErrorException.kt b/toolkit/toolkit/src/main/kotlin/tech/coner/trailer/toolkit/ErrorException.kt new file mode 100644 index 000000000..2deb263d1 --- /dev/null +++ b/toolkit/toolkit/src/main/kotlin/tech/coner/trailer/toolkit/ErrorException.kt @@ -0,0 +1,4 @@ +package tech.coner.trailer.toolkit + +class ErrorException(val error: Error, cause: Throwable? = null) : Exception(cause) { +} \ No newline at end of file diff --git a/toolkit/util/src/main/kotlin/tech/coner/trailer/toolkit/util/StringExtensions.kt b/toolkit/toolkit/src/main/kotlin/tech/coner/trailer/toolkit/StringExtensions.kt similarity index 56% rename from toolkit/util/src/main/kotlin/tech/coner/trailer/toolkit/util/StringExtensions.kt rename to toolkit/toolkit/src/main/kotlin/tech/coner/trailer/toolkit/StringExtensions.kt index 801ac90b6..ed7ad453a 100644 --- a/toolkit/util/src/main/kotlin/tech/coner/trailer/toolkit/util/StringExtensions.kt +++ b/toolkit/toolkit/src/main/kotlin/tech/coner/trailer/toolkit/StringExtensions.kt @@ -1,3 +1,3 @@ -package tech.coner.trailer.toolkit.util +package tech.coner.trailer.toolkit fun String.dashify() = lowercase().replace(' ', '-') \ No newline at end of file diff --git a/toolkit/validation/validation-testsupport/pom.xml b/toolkit/validation/validation-testsupport/pom.xml index ca7f649ef..e37d06a36 100644 --- a/toolkit/validation/validation-testsupport/pom.xml +++ b/toolkit/validation/validation-testsupport/pom.xml @@ -5,9 +5,9 @@ 4.0.0 tech.coner.trailer - parent + buildsrc-kotlin-parent 0.1.0-SNAPSHOT - ../../../pom.xml + ../../../buildsrc/buildsrc-kotlin-parent/pom.xml toolkit-validation-testsupport diff --git a/toolkit/validation/validation-testsupport/src/main/kotlin/tech/coner/trailer/toolkit/validation/testsupport/ValidationResultAssertk.kt b/toolkit/validation/validation-testsupport/src/main/kotlin/tech/coner/trailer/toolkit/validation/testsupport/ValidationResultAssertk.kt index cc8507b08..b11a0aade 100644 --- a/toolkit/validation/validation-testsupport/src/main/kotlin/tech/coner/trailer/toolkit/validation/testsupport/ValidationResultAssertk.kt +++ b/toolkit/validation/validation-testsupport/src/main/kotlin/tech/coner/trailer/toolkit/validation/testsupport/ValidationResultAssertk.kt @@ -3,10 +3,12 @@ package tech.coner.trailer.toolkit.validation.testsupport import assertk.Assert import assertk.assertions.prop import tech.coner.trailer.toolkit.validation.Feedback -import tech.coner.trailer.toolkit.validation.ValidationResult +import tech.coner.trailer.toolkit.validation.ValidationOutcome -fun Assert>.feedback() = prop(ValidationResult::feedback) +fun > Assert>.feedback() = prop(ValidationOutcome::feedback) -fun Assert>.isValid() = prop(ValidationResult<*, *>::isValid) -fun Assert>.isInvalid() = prop(ValidationResult<*, *>::isInvalid) +fun > Assert>.feedbackByProperty() = prop(ValidationOutcome::feedbackByProperty) + +fun Assert>.isValid() = prop(ValidationOutcome<*, *>::isValid) +fun Assert>.isInvalid() = prop(ValidationOutcome<*, *>::isInvalid) diff --git a/toolkit/validation/validation/pom.xml b/toolkit/validation/validation/pom.xml index 8b36089e7..3b262f8df 100644 --- a/toolkit/validation/validation/pom.xml +++ b/toolkit/validation/validation/pom.xml @@ -5,9 +5,9 @@ 4.0.0 tech.coner.trailer - parent + buildsrc-kotlin-parent 0.1.0-SNAPSHOT - ../../../pom.xml + ../../../buildsrc/buildsrc-kotlin-parent/pom.xml toolkit-validation diff --git a/toolkit/validation/validation/src/main/kotlin/tech/coner/trailer/toolkit/validation/Feedback.kt b/toolkit/validation/validation/src/main/kotlin/tech/coner/trailer/toolkit/validation/Feedback.kt index e4fed7db1..da1ff254e 100644 --- a/toolkit/validation/validation/src/main/kotlin/tech/coner/trailer/toolkit/validation/Feedback.kt +++ b/toolkit/validation/validation/src/main/kotlin/tech/coner/trailer/toolkit/validation/Feedback.kt @@ -1,6 +1,8 @@ package tech.coner.trailer.toolkit.validation -interface Feedback { +import kotlin.reflect.KProperty1 + +interface Feedback { + val property: KProperty1? val severity: Severity } - diff --git a/toolkit/validation/validation/src/main/kotlin/tech/coner/trailer/toolkit/validation/FeedbackDelegate.kt b/toolkit/validation/validation/src/main/kotlin/tech/coner/trailer/toolkit/validation/FeedbackDelegate.kt new file mode 100644 index 000000000..0c40abd01 --- /dev/null +++ b/toolkit/validation/validation/src/main/kotlin/tech/coner/trailer/toolkit/validation/FeedbackDelegate.kt @@ -0,0 +1,14 @@ +package tech.coner.trailer.toolkit.validation + +import tech.coner.trailer.toolkit.validation.adapter.PropertyAdapter +import kotlin.reflect.KProperty1 + +class FeedbackDelegate( + val feedback: Feedback, + val propertyAdapter: PropertyAdapter +) : Feedback { + override val property: KProperty1? + get() = propertyAdapter(feedback.property) + override val severity: Severity + get() = feedback.severity +} diff --git a/toolkit/validation/validation/src/main/kotlin/tech/coner/trailer/toolkit/validation/ValidationOutcome.kt b/toolkit/validation/validation/src/main/kotlin/tech/coner/trailer/toolkit/validation/ValidationOutcome.kt new file mode 100644 index 000000000..1687d18b4 --- /dev/null +++ b/toolkit/validation/validation/src/main/kotlin/tech/coner/trailer/toolkit/validation/ValidationOutcome.kt @@ -0,0 +1,49 @@ +package tech.coner.trailer.toolkit.validation + +import kotlin.reflect.KProperty1 + +data class ValidationOutcome>( + val feedback: List +) { + + val feedbackByProperty: Map?, List> by lazy { + feedback.groupBy { it.property } + } + + val isValid: Boolean by lazy { + feedback.isEmpty() + || feedback.isValid + } + + val isInvalid: Boolean by lazy { + feedback.isNotEmpty() + && feedback.isInvalid + } + + /** + * When this result is valid, invoke `validFn` or no-op + * + * @param validFn the function to invoke if this result is valid + * @return this, fluent interface + */ + fun whenValid(validFn: () -> Unit): ValidationOutcome { + if (isValid) validFn() + return this + } + + /** + * When this result is valid, invoke `validFn` and return its result or no-op and return null + * + * @param validFn the function to invoke if this result is valid + * @return the result of validFn, + */ + fun letValid(validFn: () -> R): R? { + return if (isValid) validFn() else null + } +} + +val List>.isValid: Boolean + get() = all { feedback -> feedback.severity.valid } + +val List>.isInvalid: Boolean + get() = any { feedback -> !feedback.severity.valid } \ No newline at end of file diff --git a/toolkit/validation/validation/src/main/kotlin/tech/coner/trailer/toolkit/validation/ValidationResult.kt b/toolkit/validation/validation/src/main/kotlin/tech/coner/trailer/toolkit/validation/ValidationResult.kt deleted file mode 100644 index d99e25e0d..000000000 --- a/toolkit/validation/validation/src/main/kotlin/tech/coner/trailer/toolkit/validation/ValidationResult.kt +++ /dev/null @@ -1,30 +0,0 @@ -package tech.coner.trailer.toolkit.validation - -import kotlin.reflect.KProperty1 - -data class ValidationResult( - val feedback: Map?, List> -) { - val isValid: Boolean by lazy { - feedback.isEmpty() - || feedback.values.all { it.all { feedback -> feedback.severity.valid } } - } - - val isInvalid: Boolean by lazy { - feedback.isNotEmpty() - && feedback.values.any { it.any { feedback -> !feedback.severity.valid } } - } - - /** - * When this result is valid, invoke `validFn` and return its result, or `null` otherwise - * - * @param validFn the function to invoke if this result is valid - * @return the result of `validFn` if this result is valid, or `null` otherwise - */ - fun whenValid(validFn: () -> R?): R? { - return when { - isValid -> validFn() - else -> null - } - } -} \ No newline at end of file diff --git a/toolkit/validation/validation/src/main/kotlin/tech/coner/trailer/toolkit/validation/Validator.kt b/toolkit/validation/validation/src/main/kotlin/tech/coner/trailer/toolkit/validation/Validator.kt index 0847cc2fe..30a1f2a75 100644 --- a/toolkit/validation/validation/src/main/kotlin/tech/coner/trailer/toolkit/validation/Validator.kt +++ b/toolkit/validation/validation/src/main/kotlin/tech/coner/trailer/toolkit/validation/Validator.kt @@ -1,30 +1,31 @@ package tech.coner.trailer.toolkit.validation +import tech.coner.trailer.toolkit.validation.context.PropertyValidationRuleContext import kotlin.reflect.KProperty1 import tech.coner.trailer.toolkit.validation.context.ValidationRuleContext import tech.coner.trailer.toolkit.validation.impl.ValidatorImpl -fun Validator( +fun > Validator( function: Validator.Builder.() -> Unit ): Validator { return ValidatorImpl(function) } -interface Validator { - operator fun invoke(context: CONTEXT, input: INPUT): ValidationResult +interface Validator> { + operator fun invoke(context: CONTEXT, input: INPUT): ValidationOutcome - interface Builder { + interface Builder> { operator fun KProperty1.invoke( - ruleFn: ValidationRuleContext.(PROPERTY) -> FEEDBACK? + ruleFn: PropertyValidationRuleContext.(PROPERTY) -> FEEDBACK? ) operator fun KProperty1.invoke( vararg ruleFns: ValidationRuleContext.(PROPERTY) -> FEEDBACK? ) - operator fun KProperty1.invoke( + operator fun > KProperty1.invoke( validator: Validator, mapContextFn: CONTEXT.(INPUT) -> DELEGATE_CONTEXT, mapFeedbackFn: (DELEGATE_FEEDBACK) -> FEEDBACK @@ -38,11 +39,14 @@ interface Validator { vararg ruleFns: ValidationRuleContext.(INPUT) -> FEEDBACK? ) - fun input( - validator: Validator, + fun input( + validator: Validator + ) + + fun > input( + otherTypeValidator: Validator, mapContextFn: CONTEXT.(INPUT) -> DELEGATE_CONTEXT, mapInputFn: CONTEXT.(INPUT) -> DELEGATE_INPUT, - mapFeedbackKeys: Map?, KProperty1?>, mapFeedbackObjectFn: (DELEGATE_FEEDBACK) -> FEEDBACK ) @@ -53,22 +57,20 @@ interface Validator { /** * Convenience for invoking Unit-context validators, omitting the redundant Unit context parameter */ -operator fun Validator.invoke(input: INPUT): ValidationResult { +operator fun > Validator.invoke(input: INPUT): ValidationOutcome { return invoke(Unit, input) } /** * Convenience for delegating object validation from one Unit-context validator to another */ -fun Validator.Builder.input( +fun , DELEGATE_INPUT, DELEGATE_FEEDBACK : Feedback> Validator.Builder.input( validator: Validator, mapInputFn: Unit.(INPUT) -> DELEGATE_INPUT, - mapFeedbackKeys: Map?, KProperty1?>, mapFeedbackObjectFn: (DELEGATE_FEEDBACK) -> FEEDBACK ) = input( - validator = validator, + otherTypeValidator = validator, mapContextFn = { }, mapInputFn = mapInputFn, - mapFeedbackKeys = mapFeedbackKeys, mapFeedbackObjectFn = mapFeedbackObjectFn ) \ No newline at end of file diff --git a/toolkit/validation/validation/src/main/kotlin/tech/coner/trailer/toolkit/validation/adapter/PropertyAdapter.kt b/toolkit/validation/validation/src/main/kotlin/tech/coner/trailer/toolkit/validation/adapter/PropertyAdapter.kt new file mode 100644 index 000000000..551bf933c --- /dev/null +++ b/toolkit/validation/validation/src/main/kotlin/tech/coner/trailer/toolkit/validation/adapter/PropertyAdapter.kt @@ -0,0 +1,35 @@ +package tech.coner.trailer.toolkit.validation.adapter + +import kotlin.reflect.KClass +import kotlin.reflect.KProperty1 + +interface PropertyAdapter { + operator fun invoke(property: KProperty1?): KProperty1? +} + +internal class PropertyAdapterImpl ( + private val map: Map?, KProperty1?> +) : PropertyAdapter { + override operator fun invoke(property: KProperty1?): KProperty1? { + return map[property] + } +} + +inline fun propertyAdapterOf( + vararg pairs: Pair?, KProperty1?> +): PropertyAdapter { + return propertyAdapterOf(rClass = R::class, pairs = pairs) +} + +fun propertyAdapterOf( + rClass: KClass<*>, + vararg pairs: Pair?, KProperty1?> +): PropertyAdapter { + check(pairs.size == rClass.properties.size + 1) { + "Count of validated pairs doesn't match" + } + return PropertyAdapterImpl(pairs.toMap()) +} + +private val KClass<*>.properties: List> + get() = members.filterIsInstance>() \ No newline at end of file diff --git a/toolkit/validation/validation/src/main/kotlin/tech/coner/trailer/toolkit/validation/adapter/ValidationOutcomeAdapter.kt b/toolkit/validation/validation/src/main/kotlin/tech/coner/trailer/toolkit/validation/adapter/ValidationOutcomeAdapter.kt new file mode 100644 index 000000000..4f25a2461 --- /dev/null +++ b/toolkit/validation/validation/src/main/kotlin/tech/coner/trailer/toolkit/validation/adapter/ValidationOutcomeAdapter.kt @@ -0,0 +1,14 @@ +package tech.coner.trailer.toolkit.validation.adapter + +import tech.coner.trailer.toolkit.validation.Feedback +import tech.coner.trailer.toolkit.validation.ValidationOutcome + +fun interface ValidationOutcomeAdapter, NEW_INPUT, NEW_FEEDBACK : Feedback> { + operator fun invoke(validationOutcome: ValidationOutcome): ValidationOutcome +} + +fun , NEW_INPUT, NEW_FEEDBACK : Feedback> ValidationOutcome.map(fn: (FEEDBACK) -> NEW_FEEDBACK): ValidationOutcome { + return ValidationOutcome( + feedback = feedback.map { fn(it) } + ) +} \ No newline at end of file diff --git a/toolkit/validation/validation/src/main/kotlin/tech/coner/trailer/toolkit/validation/context/PropertyValidationRuleContext.kt b/toolkit/validation/validation/src/main/kotlin/tech/coner/trailer/toolkit/validation/context/PropertyValidationRuleContext.kt new file mode 100644 index 000000000..0af2e0c18 --- /dev/null +++ b/toolkit/validation/validation/src/main/kotlin/tech/coner/trailer/toolkit/validation/context/PropertyValidationRuleContext.kt @@ -0,0 +1,7 @@ +package tech.coner.trailer.toolkit.validation.context + +import kotlin.reflect.KProperty1 + +interface PropertyValidationRuleContext : ValidationRuleContext { + val property: KProperty1 +} \ No newline at end of file diff --git a/toolkit/validation/validation/src/main/kotlin/tech/coner/trailer/toolkit/validation/impl/ValidatorImpl.kt b/toolkit/validation/validation/src/main/kotlin/tech/coner/trailer/toolkit/validation/impl/ValidatorImpl.kt index 5a95aebcd..6366bc78e 100644 --- a/toolkit/validation/validation/src/main/kotlin/tech/coner/trailer/toolkit/validation/impl/ValidatorImpl.kt +++ b/toolkit/validation/validation/src/main/kotlin/tech/coner/trailer/toolkit/validation/impl/ValidatorImpl.kt @@ -1,13 +1,16 @@ package tech.coner.trailer.toolkit.validation.impl -import tech.coner.trailer.toolkit.validation.* +import kotlin.reflect.KProperty1 +import tech.coner.trailer.toolkit.validation.Feedback +import tech.coner.trailer.toolkit.validation.ValidationOutcome +import tech.coner.trailer.toolkit.validation.Validator +import tech.coner.trailer.toolkit.validation.context.PropertyValidationRuleContext import tech.coner.trailer.toolkit.validation.context.ValidationRuleContext import tech.coner.trailer.toolkit.validation.impl.entry.ValidationEntry import tech.coner.trailer.toolkit.validation.impl.rule.ObjectValidationRuleImpl import tech.coner.trailer.toolkit.validation.impl.rule.PropertyValidationRuleImpl -import kotlin.reflect.KProperty1 -internal class ValidatorImpl( +internal class ValidatorImpl>( builder: Validator.Builder.() -> Unit ) : Validator { @@ -19,46 +22,51 @@ internal class ValidatorImpl( .also { entries = it.entries } } - override fun invoke(context: CONTEXT, input: INPUT): ValidationResult { - val feedback: MutableMap?, MutableList> = mutableMapOf() + override fun invoke(context: CONTEXT, input: INPUT): ValidationOutcome { + val feedback: MutableList = mutableListOf() for (entry in entries) { when (entry) { is ValidationEntry.InputPropertySingleFeedback -> { entry(context, input) - ?.also { feedback.createOrAppend(entry.property, it) } + ?.also { feedback += it } } is ValidationEntry.InputObjectSingleFeedback -> { entry(context, input) - ?.also { feedback.createOrAppend(null, it) } + ?.also { feedback += it } } is ValidationEntry.InputPropertyDelegatesToValidator -> { entry(context, input) - .also { feedback.createOrAppend(it.feedback) } + .also { feedback.addAll(it.feedback) } + } + + is ValidationEntry.InputObjectDelegatesToOtherTypeValidator -> { + entry(context, input) + .also { feedback += it.feedback } } - is ValidationEntry.InputObjectDelegatesToValidator -> { + is ValidationEntry.InputObjectDelegatesToSameTypeValidator -> { entry(context, input) - .also { feedback.createOrAppend(it.feedback) } + .also { feedback += it.feedback } } is ValidationEntry.ReturnEarlyIfAny -> { - if (entry(feedback.toMap())) { + if (entry(feedback)) { break } } } } - return ValidationResult(feedback.toMap()) + return ValidationOutcome(feedback) } - internal class BuilderImpl : Validator.Builder { + internal class BuilderImpl> : Validator.Builder { internal val entries: MutableList> = mutableListOf() override fun KProperty1.invoke( - ruleFn: ValidationRuleContext.(PROPERTY) -> FEEDBACK? + ruleFn: PropertyValidationRuleContext.(PROPERTY) -> FEEDBACK? ) { entries += ValidationEntry.InputPropertySingleFeedback( property = this, @@ -78,7 +86,7 @@ internal class ValidatorImpl( } } - override fun KProperty1.invoke( + override fun > KProperty1.invoke( validator: Validator, mapContextFn: CONTEXT.(INPUT) -> DELEGATE_CONTEXT, mapFeedbackFn: (DELEGATE_FEEDBACK) -> FEEDBACK @@ -106,22 +114,24 @@ internal class ValidatorImpl( } } - override fun input( - validator: Validator, + override fun > input( + otherTypeValidator: Validator, mapContextFn: CONTEXT.(INPUT) -> DELEGATE_CONTEXT, mapInputFn: CONTEXT.(INPUT) -> DELEGATE_INPUT, - mapFeedbackKeys: Map?, KProperty1?>, mapFeedbackObjectFn: (DELEGATE_FEEDBACK) -> FEEDBACK ) { - entries += ValidationEntry.InputObjectDelegatesToValidator( - validator = validator, + entries += ValidationEntry.InputObjectDelegatesToOtherTypeValidator( + validator = otherTypeValidator, mapContextFn = mapContextFn, mapInputFn = mapInputFn, - mapFeedbackKeys = mapFeedbackKeys, mapFeedbackObjectFn = mapFeedbackObjectFn ) } + override fun input(validator: Validator) { + entries += ValidationEntry.InputObjectDelegatesToSameTypeValidator(validator) + } + override fun returnEarlyIfAny(matchFn: (FEEDBACK) -> Boolean) { entries += ValidationEntry.ReturnEarlyIfAny(matchFn) } diff --git a/toolkit/validation/validation/src/main/kotlin/tech/coner/trailer/toolkit/validation/impl/context/PropertyValidationRuleContextImpl.kt b/toolkit/validation/validation/src/main/kotlin/tech/coner/trailer/toolkit/validation/impl/context/PropertyValidationRuleContextImpl.kt new file mode 100644 index 000000000..9404c790f --- /dev/null +++ b/toolkit/validation/validation/src/main/kotlin/tech/coner/trailer/toolkit/validation/impl/context/PropertyValidationRuleContextImpl.kt @@ -0,0 +1,10 @@ +package tech.coner.trailer.toolkit.validation.impl.context + +import tech.coner.trailer.toolkit.validation.context.PropertyValidationRuleContext +import kotlin.reflect.KProperty1 + +data class PropertyValidationRuleContextImpl( + override val context: CONTEXT, + override val input: INPUT, + override val property: KProperty1 +) : PropertyValidationRuleContext diff --git a/toolkit/validation/validation/src/main/kotlin/tech/coner/trailer/toolkit/validation/impl/entry/ValidationEntry.kt b/toolkit/validation/validation/src/main/kotlin/tech/coner/trailer/toolkit/validation/impl/entry/ValidationEntry.kt index 8f6dc9b5b..088d78d85 100644 --- a/toolkit/validation/validation/src/main/kotlin/tech/coner/trailer/toolkit/validation/impl/entry/ValidationEntry.kt +++ b/toolkit/validation/validation/src/main/kotlin/tech/coner/trailer/toolkit/validation/impl/entry/ValidationEntry.kt @@ -2,14 +2,14 @@ package tech.coner.trailer.toolkit.validation.impl.entry import kotlin.reflect.KProperty1 import tech.coner.trailer.toolkit.validation.Feedback -import tech.coner.trailer.toolkit.validation.ValidationResult +import tech.coner.trailer.toolkit.validation.ValidationOutcome import tech.coner.trailer.toolkit.validation.Validator import tech.coner.trailer.toolkit.validation.rule.PropertyValidationRule import tech.coner.trailer.toolkit.validation.rule.ValidationRule -internal sealed class ValidationEntry { +internal sealed class ValidationEntry> { - internal class InputPropertySingleFeedback( + internal class InputPropertySingleFeedback>( val property: KProperty1, private val rule: PropertyValidationRule ) @@ -20,7 +20,7 @@ internal sealed class ValidationEntry { } } - internal class InputObjectSingleFeedback( + internal class InputObjectSingleFeedback>( private val rule: ValidationRule ) : ValidationEntry() { @@ -29,7 +29,7 @@ internal sealed class ValidationEntry { } } - internal class InputPropertyDelegatesToValidator( + internal class InputPropertyDelegatesToValidator, FEEDBACK : Feedback>( private val property: KProperty1, private val validator: Validator, private val mapContextFn: CONTEXT.(INPUT) -> DELEGATE_CONTEXT, @@ -37,54 +37,51 @@ internal sealed class ValidationEntry { ) : ValidationEntry() { - operator fun invoke(context: CONTEXT, input: INPUT): ValidationResult { - return ValidationResult( + operator fun invoke(context: CONTEXT, input: INPUT): ValidationOutcome { + return ValidationOutcome( validator( context = mapContextFn(context, input), input = property.get(input) ) .feedback - .flatMap { it.value.map(mapFeedbackFn) } - .let { - if (it.isNotEmpty()) { - mapOf(property to it) - } else { - emptyMap() - } - } + .map { mapFeedbackFn(it) } ) } } - internal class InputObjectDelegatesToValidator( + internal class InputObjectDelegatesToOtherTypeValidator, FEEDBACK : Feedback>( private val validator: Validator, private val mapContextFn: CONTEXT.(INPUT) -> DELEGATE_CONTEXT, private val mapInputFn: CONTEXT.(INPUT) -> DELEGATE_INPUT, - private val mapFeedbackKeys: Map?, KProperty1?>, private val mapFeedbackObjectFn: (DELEGATE_FEEDBACK) -> FEEDBACK ) : ValidationEntry() { - operator fun invoke(context: CONTEXT, input: INPUT): ValidationResult { - return ValidationResult( + operator fun invoke(context: CONTEXT, input: INPUT): ValidationOutcome { + return ValidationOutcome( validator( context = mapContextFn(context, input), input = mapInputFn(context, input) ) .feedback - .map { mapFeedbackKeys[it.key] to it.value.map(mapFeedbackObjectFn) } - .toMap() + .map { mapFeedbackObjectFn(it) } ) } } - internal class ReturnEarlyIfAny( + internal class InputObjectDelegatesToSameTypeValidator>( + private val validator: Validator, + ) : ValidationEntry() { + operator fun invoke(context: CONTEXT, input: INPUT): ValidationOutcome { + return validator(context, input) + } + } + + internal class ReturnEarlyIfAny>( private val matchFn: (FEEDBACK) -> Boolean ) : ValidationEntry() { - operator fun invoke(feedback: Map?, List>): Boolean { + operator fun invoke(feedback: List): Boolean { return feedback - .values - .flatten() .any { matchFn(it) } } } diff --git a/toolkit/validation/validation/src/main/kotlin/tech/coner/trailer/toolkit/validation/impl/rule/ObjectValidationRuleImpl.kt b/toolkit/validation/validation/src/main/kotlin/tech/coner/trailer/toolkit/validation/impl/rule/ObjectValidationRuleImpl.kt index 2c1df1b59..652280a8d 100644 --- a/toolkit/validation/validation/src/main/kotlin/tech/coner/trailer/toolkit/validation/impl/rule/ObjectValidationRuleImpl.kt +++ b/toolkit/validation/validation/src/main/kotlin/tech/coner/trailer/toolkit/validation/impl/rule/ObjectValidationRuleImpl.kt @@ -5,7 +5,7 @@ import tech.coner.trailer.toolkit.validation.context.ValidationRuleContext import tech.coner.trailer.toolkit.validation.rule.ObjectValidationRule import tech.coner.trailer.toolkit.validation.impl.context.ValidationRuleContextImpl -class ObjectValidationRuleImpl( +class ObjectValidationRuleImpl>( private val ruleFn: ValidationRuleContext.(INPUT) -> FEEDBACK? ) : ObjectValidationRule { diff --git a/toolkit/validation/validation/src/main/kotlin/tech/coner/trailer/toolkit/validation/impl/rule/PropertyValidationRuleImpl.kt b/toolkit/validation/validation/src/main/kotlin/tech/coner/trailer/toolkit/validation/impl/rule/PropertyValidationRuleImpl.kt index a1096bb90..4ee98968d 100644 --- a/toolkit/validation/validation/src/main/kotlin/tech/coner/trailer/toolkit/validation/impl/rule/PropertyValidationRuleImpl.kt +++ b/toolkit/validation/validation/src/main/kotlin/tech/coner/trailer/toolkit/validation/impl/rule/PropertyValidationRuleImpl.kt @@ -2,17 +2,19 @@ package tech.coner.trailer.toolkit.validation.impl.rule import kotlin.reflect.KProperty1 import tech.coner.trailer.toolkit.validation.Feedback +import tech.coner.trailer.toolkit.validation.context.PropertyValidationRuleContext import tech.coner.trailer.toolkit.validation.context.ValidationRuleContext +import tech.coner.trailer.toolkit.validation.impl.context.PropertyValidationRuleContextImpl import tech.coner.trailer.toolkit.validation.impl.context.ValidationRuleContextImpl import tech.coner.trailer.toolkit.validation.rule.PropertyValidationRule -internal class PropertyValidationRuleImpl( +internal class PropertyValidationRuleImpl>( override val property: KProperty1, - private val validationRule: ValidationRuleContext.(PROPERTY) -> FEEDBACK? + private val validationRule: PropertyValidationRuleContext.(PROPERTY) -> FEEDBACK? ) : PropertyValidationRule { override fun invoke(context: CONTEXT, input: INPUT): FEEDBACK? { - return ValidationRuleContextImpl(context, input) + return PropertyValidationRuleContextImpl(context, input, property) .validationRule(property.get(input)) } } \ No newline at end of file diff --git a/toolkit/validation/validation/src/main/kotlin/tech/coner/trailer/toolkit/validation/presentation/localization/EnglishUsValidationTranslation.kt b/toolkit/validation/validation/src/main/kotlin/tech/coner/trailer/toolkit/validation/presentation/localization/EnglishUsValidationTranslation.kt new file mode 100644 index 000000000..59cce5b92 --- /dev/null +++ b/toolkit/validation/validation/src/main/kotlin/tech/coner/trailer/toolkit/validation/presentation/localization/EnglishUsValidationTranslation.kt @@ -0,0 +1,9 @@ +package tech.coner.trailer.toolkit.validation.presentation.localization + +class EnglishUsValidationTranslation : ValidationTranslation { + override val severityError get() = "Error" + override val severityWarning get() = "Warning" + override val severitySuccess get() = "Success" + override val severityInfo get() = "Info" + +} \ No newline at end of file diff --git a/toolkit/validation/validation/src/main/kotlin/tech/coner/trailer/toolkit/validation/presentation/localization/ValidationStrings.kt b/toolkit/validation/validation/src/main/kotlin/tech/coner/trailer/toolkit/validation/presentation/localization/ValidationStrings.kt new file mode 100644 index 000000000..f635eb1b4 --- /dev/null +++ b/toolkit/validation/validation/src/main/kotlin/tech/coner/trailer/toolkit/validation/presentation/localization/ValidationStrings.kt @@ -0,0 +1,7 @@ +package tech.coner.trailer.toolkit.validation.presentation.localization + +import tech.coner.trailer.toolkit.validation.Severity + +interface ValidationStrings { + operator fun get(severity: Severity): String +} \ No newline at end of file diff --git a/toolkit/validation/validation/src/main/kotlin/tech/coner/trailer/toolkit/validation/presentation/localization/ValidationStringsImpl.kt b/toolkit/validation/validation/src/main/kotlin/tech/coner/trailer/toolkit/validation/presentation/localization/ValidationStringsImpl.kt new file mode 100644 index 000000000..dc3a38bf4 --- /dev/null +++ b/toolkit/validation/validation/src/main/kotlin/tech/coner/trailer/toolkit/validation/presentation/localization/ValidationStringsImpl.kt @@ -0,0 +1,15 @@ +package tech.coner.trailer.toolkit.validation.presentation.localization + +import tech.coner.trailer.toolkit.validation.Severity + +class ValidationStringsImpl(translation: ValidationTranslation) + : ValidationStrings, + ValidationTranslation by translation { + + override fun get(severity: Severity) = when (severity) { + Severity.Error -> severityError + Severity.Warning -> severityWarning + Severity.Success -> severitySuccess + Severity.Info -> severityInfo + } +} \ No newline at end of file diff --git a/toolkit/validation/validation/src/main/kotlin/tech/coner/trailer/toolkit/validation/presentation/localization/ValidationTranslation.kt b/toolkit/validation/validation/src/main/kotlin/tech/coner/trailer/toolkit/validation/presentation/localization/ValidationTranslation.kt new file mode 100644 index 000000000..2a93b5865 --- /dev/null +++ b/toolkit/validation/validation/src/main/kotlin/tech/coner/trailer/toolkit/validation/presentation/localization/ValidationTranslation.kt @@ -0,0 +1,8 @@ +package tech.coner.trailer.toolkit.validation.presentation.localization + +interface ValidationTranslation { + val severityError: String + val severityWarning: String + val severitySuccess: String + val severityInfo: String +} \ No newline at end of file diff --git a/toolkit/validation/validation/src/main/kotlin/tech/coner/trailer/toolkit/validation/rule/ObjectValidationRule.kt b/toolkit/validation/validation/src/main/kotlin/tech/coner/trailer/toolkit/validation/rule/ObjectValidationRule.kt index 4cc3f48f9..71ebcbb36 100644 --- a/toolkit/validation/validation/src/main/kotlin/tech/coner/trailer/toolkit/validation/rule/ObjectValidationRule.kt +++ b/toolkit/validation/validation/src/main/kotlin/tech/coner/trailer/toolkit/validation/rule/ObjectValidationRule.kt @@ -2,6 +2,6 @@ package tech.coner.trailer.toolkit.validation.rule import tech.coner.trailer.toolkit.validation.Feedback -internal interface ObjectValidationRule +internal interface ObjectValidationRule> : ValidationRule { } \ No newline at end of file diff --git a/toolkit/validation/validation/src/main/kotlin/tech/coner/trailer/toolkit/validation/rule/PropertyValidationRule.kt b/toolkit/validation/validation/src/main/kotlin/tech/coner/trailer/toolkit/validation/rule/PropertyValidationRule.kt index c3fb63f8d..28beaea09 100644 --- a/toolkit/validation/validation/src/main/kotlin/tech/coner/trailer/toolkit/validation/rule/PropertyValidationRule.kt +++ b/toolkit/validation/validation/src/main/kotlin/tech/coner/trailer/toolkit/validation/rule/PropertyValidationRule.kt @@ -3,7 +3,7 @@ package tech.coner.trailer.toolkit.validation.rule import kotlin.reflect.KProperty1 import tech.coner.trailer.toolkit.validation.Feedback -internal interface PropertyValidationRule : +internal interface PropertyValidationRule> : ValidationRule { val property: KProperty1 } \ No newline at end of file diff --git a/toolkit/validation/validation/src/main/kotlin/tech/coner/trailer/toolkit/validation/rule/ValidationRule.kt b/toolkit/validation/validation/src/main/kotlin/tech/coner/trailer/toolkit/validation/rule/ValidationRule.kt index 5c4ae3ad1..13d658d31 100644 --- a/toolkit/validation/validation/src/main/kotlin/tech/coner/trailer/toolkit/validation/rule/ValidationRule.kt +++ b/toolkit/validation/validation/src/main/kotlin/tech/coner/trailer/toolkit/validation/rule/ValidationRule.kt @@ -2,7 +2,7 @@ package tech.coner.trailer.toolkit.validation.rule import tech.coner.trailer.toolkit.validation.Feedback -fun interface ValidationRule { +fun interface ValidationRule> { operator fun invoke(context: CONTEXT, input: INPUT): FEEDBACK? } \ No newline at end of file diff --git a/webapp-competition/pom.xml b/webapp-competition/pom.xml index 03154f1b9..90878a25d 100644 --- a/webapp-competition/pom.xml +++ b/webapp-competition/pom.xml @@ -3,9 +3,10 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> - parent + buildsrc-kotlin-parent tech.coner.trailer 0.1.0-SNAPSHOT + ../buildsrc/buildsrc-kotlin-parent/pom.xml 4.0.0