├── .idea ├── .name ├── .gitignore ├── vcs.xml ├── compiler.xml ├── kotlinc.xml ├── migrations.xml ├── misc.xml ├── deploymentTargetDropDown.xml ├── gradle.xml └── inspectionProfiles │ └── Project_Default.xml ├── app ├── .gitignore ├── src │ ├── main │ │ ├── res │ │ │ ├── mipmap-hdpi │ │ │ │ ├── ic_launcher.webp │ │ │ │ └── ic_launcher_round.webp │ │ │ ├── mipmap-mdpi │ │ │ │ ├── ic_launcher.webp │ │ │ │ └── ic_launcher_round.webp │ │ │ ├── mipmap-xhdpi │ │ │ │ ├── ic_launcher.webp │ │ │ │ └── ic_launcher_round.webp │ │ │ ├── mipmap-xxhdpi │ │ │ │ ├── ic_launcher.webp │ │ │ │ └── ic_launcher_round.webp │ │ │ ├── mipmap-xxxhdpi │ │ │ │ ├── ic_launcher.webp │ │ │ │ └── ic_launcher_round.webp │ │ │ ├── values │ │ │ │ ├── themes.xml │ │ │ │ ├── colors.xml │ │ │ │ ├── strings.xml │ │ │ │ └── styles.xml │ │ │ ├── mipmap-anydpi-v26 │ │ │ │ ├── ic_launcher.xml │ │ │ │ └── ic_launcher_round.xml │ │ │ ├── values-night │ │ │ │ └── styles.xml │ │ │ ├── xml │ │ │ │ ├── backup_rules.xml │ │ │ │ └── data_extraction_rules.xml │ │ │ ├── drawable-v24 │ │ │ │ └── ic_launcher_foreground.xml │ │ │ └── drawable │ │ │ │ └── ic_launcher_background.xml │ │ ├── java │ │ │ └── com │ │ │ │ └── edorex │ │ │ │ └── mobile │ │ │ │ └── composeForm │ │ │ │ ├── App.kt │ │ │ │ ├── ui │ │ │ │ └── theme │ │ │ │ │ ├── Color.kt │ │ │ │ │ ├── Shape.kt │ │ │ │ │ ├── Type.kt │ │ │ │ │ └── Theme.kt │ │ │ │ ├── models │ │ │ │ └── Country.kt │ │ │ │ ├── di │ │ │ │ └── ResourcesProvider.kt │ │ │ │ ├── MainViewModel.kt │ │ │ │ ├── MainForm.kt │ │ │ │ └── MainActivity.kt │ │ └── AndroidManifest.xml │ ├── test │ │ └── java │ │ │ └── com │ │ │ └── edorex │ │ │ └── mobile │ │ │ └── composeForm │ │ │ └── ExampleUnitTest.kt │ └── androidTest │ │ └── java │ │ └── com │ │ └── edorex │ │ └── mobile │ │ └── composeForm │ │ └── ExampleInstrumentedTest.kt ├── proguard-rules.pro └── build.gradle ├── composeform ├── .gitignore ├── consumer-rules.pro ├── src │ ├── main │ │ ├── AndroidManifest.xml │ │ └── java │ │ │ └── ch │ │ │ └── benlu │ │ │ └── composeform │ │ │ ├── Validator.kt │ │ │ ├── FormField.kt │ │ │ ├── formatters │ │ │ ├── StringFormatters.kt │ │ │ └── DateFormatters.kt │ │ │ ├── validators │ │ │ ├── NotEmptyValidator.kt │ │ │ ├── MinLengthValidator.kt │ │ │ ├── IsEqualValidator.kt │ │ │ ├── DateValidator.kt │ │ │ └── EmailValidator.kt │ │ │ ├── FieldState.kt │ │ │ ├── fields │ │ │ ├── CheckboxField.kt │ │ │ ├── TextField.kt │ │ │ ├── PasswordField.kt │ │ │ ├── DateField.kt │ │ │ └── PickerField.kt │ │ │ ├── Field.kt │ │ │ ├── components │ │ │ ├── RadioButtonComponent.kt │ │ │ ├── CheckboxComponent.kt │ │ │ ├── SingleSelectDialogComponent.kt │ │ │ └── TextFieldComponent.kt │ │ │ └── Form.kt │ ├── test │ │ └── java │ │ │ └── ch │ │ │ └── benlu │ │ │ └── composeform │ │ │ └── ExampleUnitTest.kt │ └── androidTest │ │ └── java │ │ └── ch │ │ └── benlu │ │ └── composeform │ │ └── ExampleInstrumentedTest.kt ├── proguard-rules.pro └── build.gradle ├── jitpack.yml ├── images ├── logo.png └── logo-square.png ├── screenshots ├── png │ ├── simple-form.png │ └── extended-form.png └── gif │ └── composeform-extended.gif ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── .gitignore ├── settings.gradle ├── LICENSE ├── gradle.properties ├── gradlew.bat ├── gradlew └── README.md /.idea/.name: -------------------------------------------------------------------------------- 1 | Compose Form -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /composeform/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /composeform/consumer-rules.pro: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /jitpack.yml: -------------------------------------------------------------------------------- 1 | jdk: 2 | - openjdk11 -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | -------------------------------------------------------------------------------- /images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benjamin-luescher/compose-form/HEAD/images/logo.png -------------------------------------------------------------------------------- /images/logo-square.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benjamin-luescher/compose-form/HEAD/images/logo-square.png -------------------------------------------------------------------------------- /screenshots/png/simple-form.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benjamin-luescher/compose-form/HEAD/screenshots/png/simple-form.png -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benjamin-luescher/compose-form/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /screenshots/png/extended-form.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benjamin-luescher/compose-form/HEAD/screenshots/png/extended-form.png -------------------------------------------------------------------------------- /screenshots/gif/composeform-extended.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benjamin-luescher/compose-form/HEAD/screenshots/gif/composeform-extended.gif -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benjamin-luescher/compose-form/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benjamin-luescher/compose-form/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benjamin-luescher/compose-form/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benjamin-luescher/compose-form/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benjamin-luescher/compose-form/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benjamin-luescher/compose-form/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benjamin-luescher/compose-form/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benjamin-luescher/compose-form/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benjamin-luescher/compose-form/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /composeform/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benjamin-luescher/compose-form/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/compiler.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /composeform/src/main/java/ch/benlu/composeform/Validator.kt: -------------------------------------------------------------------------------- 1 | package ch.benlu.composeform 2 | 3 | abstract class Validator( 4 | val validate: (s: T?) -> Boolean, 5 | val errorText: String 6 | ) 7 | -------------------------------------------------------------------------------- /.idea/kotlinc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /composeform/src/main/java/ch/benlu/composeform/FormField.kt: -------------------------------------------------------------------------------- 1 | package ch.benlu.composeform 2 | 3 | @Target(AnnotationTarget.FIELD) 4 | @Retention(AnnotationRetention.RUNTIME) 5 | annotation class FormField() { 6 | } -------------------------------------------------------------------------------- /app/src/main/java/com/edorex/mobile/composeForm/App.kt: -------------------------------------------------------------------------------- 1 | package com.edorex.mobile.composeForm 2 | 3 | import android.app.Application 4 | import dagger.hilt.android.HiltAndroidApp 5 | 6 | @HiltAndroidApp 7 | class App : Application() -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Tue Sep 20 17:59:02 CEST 2022 2 | distributionBase=GRADLE_USER_HOME 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-bin.zip 4 | distributionPath=wrapper/dists 5 | zipStorePath=wrapper/dists 6 | zipStoreBase=GRADLE_USER_HOME 7 | -------------------------------------------------------------------------------- /composeform/src/main/java/ch/benlu/composeform/formatters/StringFormatters.kt: -------------------------------------------------------------------------------- 1 | package ch.benlu.composeform.formatters 2 | 3 | fun upperCase(r: String?): String { 4 | return r?.uppercase() ?: "" 5 | } 6 | 7 | fun lowerCase(r: String?): String { 8 | return r?.lowercase() ?: "" 9 | } 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea/caches 5 | /.idea/libraries 6 | /.idea/modules.xml 7 | /.idea/workspace.xml 8 | /.idea/navEditor.xml 9 | /.idea/assetWizardSettings.xml 10 | .DS_Store 11 | /build 12 | /captures 13 | .externalNativeBuild 14 | .cxx 15 | local.properties 16 | -------------------------------------------------------------------------------- /app/src/main/java/com/edorex/mobile/composeForm/ui/theme/Color.kt: -------------------------------------------------------------------------------- 1 | package com.edorex.mobile.composeForm.ui.theme 2 | 3 | import androidx.compose.ui.graphics.Color 4 | 5 | val Purple200 = Color(0xFFBB86FC) 6 | val Purple500 = Color(0xFF6200EE) 7 | val Purple700 = Color(0xFF3700B3) 8 | val Teal200 = Color(0xFF03DAC5) -------------------------------------------------------------------------------- /.idea/migrations.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 8 | -------------------------------------------------------------------------------- /composeform/src/main/java/ch/benlu/composeform/validators/NotEmptyValidator.kt: -------------------------------------------------------------------------------- 1 | package ch.benlu.composeform.validators 2 | 3 | import ch.benlu.composeform.Validator 4 | 5 | class NotEmptyValidator(errorText: String? = null) : Validator( 6 | validate = { 7 | it != null 8 | }, 9 | errorText = errorText ?: "This field should not be empty" 10 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/edorex/mobile/composeForm/models/Country.kt: -------------------------------------------------------------------------------- 1 | package com.edorex.mobile.composeForm.models 2 | 3 | import ch.benlu.composeform.fields.PickerValue 4 | 5 | data class Country( 6 | val code: String, 7 | val name: String 8 | ): PickerValue() { 9 | override fun searchFilter(query: String): Boolean { 10 | return this.name.startsWith(query) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /composeform/src/main/java/ch/benlu/composeform/validators/MinLengthValidator.kt: -------------------------------------------------------------------------------- 1 | package ch.benlu.composeform.validators 2 | 3 | import ch.benlu.composeform.Validator 4 | 5 | class MinLengthValidator(minLength: Int, errorText: String? = null) : Validator( 6 | validate = { 7 | (it?.length ?: -1) >= minLength 8 | }, 9 | errorText = errorText ?: "This field is too short" 10 | ) -------------------------------------------------------------------------------- /composeform/src/main/java/ch/benlu/composeform/validators/IsEqualValidator.kt: -------------------------------------------------------------------------------- 1 | package ch.benlu.composeform.validators 2 | 3 | import ch.benlu.composeform.Validator 4 | 5 | class IsEqualValidator(expectedValue: () -> T, errorText: String? = null) : Validator( 6 | validate = { 7 | it == expectedValue() 8 | }, 9 | errorText = errorText ?: "This field's value is not as expected." 10 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/edorex/mobile/composeForm/ui/theme/Shape.kt: -------------------------------------------------------------------------------- 1 | package com.edorex.mobile.composeForm.ui.theme 2 | 3 | import androidx.compose.foundation.shape.RoundedCornerShape 4 | import androidx.compose.material.Shapes 5 | import androidx.compose.ui.unit.dp 6 | 7 | val Shapes = Shapes( 8 | small = RoundedCornerShape(4.dp), 9 | medium = RoundedCornerShape(4.dp), 10 | large = RoundedCornerShape(0.dp) 11 | ) -------------------------------------------------------------------------------- /composeform/src/main/java/ch/benlu/composeform/validators/DateValidator.kt: -------------------------------------------------------------------------------- 1 | package ch.benlu.composeform.validators 2 | 3 | import ch.benlu.composeform.Validator 4 | import java.util.* 5 | 6 | class DateValidator(minDateTime: () -> Long, errorText: String? = null) : Validator( 7 | validate = { 8 | (it?.time ?: -1) >= minDateTime() 9 | }, 10 | errorText = errorText ?: "This field is not valid." 11 | ) -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | gradlePluginPortal() 4 | google() 5 | mavenCentral() 6 | } 7 | } 8 | dependencyResolutionManagement { 9 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) 10 | repositories { 11 | google() 12 | mavenCentral() 13 | } 14 | } 15 | rootProject.name = "Compose Form" 16 | include ':app' 17 | include ':composeform' 18 | -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFBB86FC 4 | #FF6200EE 5 | #FF3700B3 6 | #FF03DAC5 7 | #FF018786 8 | #FF000000 9 | #FFFFFFFF 10 | -------------------------------------------------------------------------------- /composeform/src/main/java/ch/benlu/composeform/formatters/DateFormatters.kt: -------------------------------------------------------------------------------- 1 | package ch.benlu.composeform.formatters 2 | 3 | import java.text.DateFormat 4 | import java.util.* 5 | 6 | fun dateShort(r: Date?): String { 7 | return if (r != null) DateFormat.getDateInstance().format(r) else "" 8 | } 9 | 10 | fun dateLong(r: Date?): String { 11 | return if (r != null) DateFormat.getDateInstance(DateFormat.LONG).format(r) else "" 12 | } 13 | -------------------------------------------------------------------------------- /composeform/src/test/java/ch/benlu/composeform/ExampleUnitTest.kt: -------------------------------------------------------------------------------- 1 | package ch.benlu.composeform 2 | 3 | import org.junit.Test 4 | 5 | import org.junit.Assert.* 6 | 7 | /** 8 | * Example local unit test, which will execute on the development machine (host). 9 | * 10 | * See [testing documentation](http://d.android.com/tools/testing). 11 | */ 12 | class ExampleUnitTest { 13 | @Test 14 | fun addition_isCorrect() { 15 | assertEquals(4, 2 + 2) 16 | } 17 | } -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Compose Form 3 | Cancel 4 | Search 5 | This field should not be empty 6 | This field doesn\'t match the min-length requirement. 7 | This date should be after start date. 8 | -------------------------------------------------------------------------------- /app/src/test/java/com/edorex/mobile/composeForm/ExampleUnitTest.kt: -------------------------------------------------------------------------------- 1 | package com.edorex.mobile.composeForm 2 | 3 | import org.junit.Test 4 | 5 | import org.junit.Assert.* 6 | 7 | /** 8 | * Example local unit test, which will execute on the development machine (host). 9 | * 10 | * See [testing documentation](http://d.android.com/tools/testing). 11 | */ 12 | class ExampleUnitTest { 13 | @Test 14 | fun addition_isCorrect() { 15 | assertEquals(4, 2 + 2) 16 | } 17 | } -------------------------------------------------------------------------------- /app/src/main/res/values-night/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/xml/backup_rules.xml: -------------------------------------------------------------------------------- 1 | 8 | 9 | 13 | -------------------------------------------------------------------------------- /app/src/main/java/com/edorex/mobile/composeForm/di/ResourcesProvider.kt: -------------------------------------------------------------------------------- 1 | package com.edorex.mobile.composeForm.di 2 | 3 | import android.content.Context 4 | import androidx.annotation.StringRes 5 | import dagger.hilt.android.qualifiers.ApplicationContext 6 | import javax.inject.Inject 7 | import javax.inject.Singleton 8 | 9 | @Singleton 10 | class ResourcesProvider @Inject constructor( 11 | @ApplicationContext private val context: Context 12 | ) { 13 | fun getString(@StringRes stringResId: Int): String { 14 | return context.getString(stringResId) 15 | } 16 | } -------------------------------------------------------------------------------- /app/src/main/res/xml/data_extraction_rules.xml: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 12 | 13 | 19 | -------------------------------------------------------------------------------- /composeform/src/main/java/ch/benlu/composeform/validators/EmailValidator.kt: -------------------------------------------------------------------------------- 1 | package ch.benlu.composeform.validators 2 | 3 | import ch.benlu.composeform.Validator 4 | 5 | class EmailValidator(errorText: String? = null) : Validator( 6 | validate = { 7 | """(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])""".toRegex().matches("$it") 8 | }, 9 | errorText = errorText ?: "Please enter a valid e-mail address." 10 | ) -------------------------------------------------------------------------------- /.idea/deploymentTargetDropDown.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /composeform/src/androidTest/java/ch/benlu/composeform/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package ch.benlu.composeform 2 | 3 | import androidx.test.platform.app.InstrumentationRegistry 4 | import androidx.test.ext.junit.runners.AndroidJUnit4 5 | 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | import org.junit.Assert.* 10 | 11 | /** 12 | * Instrumented test, which will execute on an Android device. 13 | * 14 | * See [testing documentation](http://d.android.com/tools/testing). 15 | */ 16 | @RunWith(AndroidJUnit4::class) 17 | class ExampleInstrumentedTest { 18 | @Test 19 | fun useAppContext() { 20 | // Context of the app under test. 21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 22 | assertEquals("ch.benlu.composeform.test", appContext.packageName) 23 | } 24 | } -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /app/src/androidTest/java/com/edorex/mobile/composeForm/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package com.edorex.mobile.composeForm 2 | 3 | import androidx.test.platform.app.InstrumentationRegistry 4 | import androidx.test.ext.junit.runners.AndroidJUnit4 5 | 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | import org.junit.Assert.* 10 | 11 | /** 12 | * Instrumented test, which will execute on an Android device. 13 | * 14 | * See [testing documentation](http://d.android.com/tools/testing). 15 | */ 16 | @RunWith(AndroidJUnit4::class) 17 | class ExampleInstrumentedTest { 18 | @Test 19 | fun useAppContext() { 20 | // Context of the app under test. 21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 22 | assertEquals("com.edorex.mobile.composeForm", appContext.packageName) 23 | } 24 | } -------------------------------------------------------------------------------- /composeform/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /app/src/main/java/com/edorex/mobile/composeForm/MainViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.edorex.mobile.composeForm 2 | 3 | import android.util.Log 4 | import androidx.lifecycle.ViewModel 5 | import ch.benlu.composeform.validators.NotEmptyValidator 6 | import com.edorex.mobile.composeForm.di.ResourcesProvider 7 | import dagger.hilt.android.lifecycle.HiltViewModel 8 | import javax.inject.Inject 9 | 10 | @HiltViewModel 11 | class MainViewModel @Inject constructor( 12 | resourcesProvider: ResourcesProvider 13 | ): ViewModel() { 14 | var form = MainForm(resourcesProvider) 15 | 16 | fun validate() { 17 | form.validate(true) 18 | form.logRawValue() 19 | Log.d("MainViewModel", "Submit (form is valid: ${form.isValid})") 20 | } 21 | 22 | fun doSomething() { 23 | form.name.validators.removeIf { it::class == NotEmptyValidator::class } 24 | form.name.state.value = "Benji" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /.idea/gradle.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 20 | 21 | -------------------------------------------------------------------------------- /app/src/main/java/com/edorex/mobile/composeForm/ui/theme/Type.kt: -------------------------------------------------------------------------------- 1 | package com.edorex.mobile.composeForm.ui.theme 2 | 3 | import androidx.compose.material.Typography 4 | import androidx.compose.ui.text.TextStyle 5 | import androidx.compose.ui.text.font.FontFamily 6 | import androidx.compose.ui.text.font.FontWeight 7 | import androidx.compose.ui.unit.sp 8 | 9 | // Set of Material typography styles to start with 10 | val Typography = Typography( 11 | body1 = TextStyle( 12 | fontFamily = FontFamily.Default, 13 | fontWeight = FontWeight.Normal, 14 | fontSize = 16.sp 15 | ) 16 | /* Other default text styles to override 17 | button = TextStyle( 18 | fontFamily = FontFamily.Default, 19 | fontWeight = FontWeight.W500, 20 | fontSize = 14.sp 21 | ), 22 | caption = TextStyle( 23 | fontFamily = FontFamily.Default, 24 | fontWeight = FontWeight.Normal, 25 | fontSize = 12.sp 26 | ) 27 | */ 28 | ) -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Benjamin Lüscher 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /composeform/src/main/java/ch/benlu/composeform/FieldState.kt: -------------------------------------------------------------------------------- 1 | package ch.benlu.composeform 2 | 3 | import androidx.compose.runtime.MutableState 4 | import androidx.compose.runtime.mutableStateOf 5 | 6 | class FieldState( 7 | val state: MutableState, 8 | val validators: MutableList> = mutableListOf(), 9 | val errorText: MutableList = mutableListOf(), 10 | val isValid: MutableState = mutableStateOf(false), 11 | val isVisible: () -> Boolean = { true }, 12 | val hasChanges: MutableState = mutableStateOf(false), 13 | var options: MutableList = mutableListOf(), 14 | val optionItemFormatter: ((T?) -> String)? = null, 15 | ) { 16 | fun hasError(): Boolean { 17 | return isVisible() && isValid.value == false && hasChanges.value == true 18 | } 19 | 20 | fun selectedOption(): T? { 21 | return options.firstOrNull { it == state.value } 22 | } 23 | 24 | fun selectedOptionText(): String? { 25 | val selectedOption = selectedOption() ?: return null 26 | return optionItemFormatter?.invoke(selectedOption) ?: selectedOption.toString() 27 | } 28 | } -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 16 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /app/src/main/java/com/edorex/mobile/composeForm/ui/theme/Theme.kt: -------------------------------------------------------------------------------- 1 | package com.edorex.mobile.composeForm.ui.theme 2 | 3 | import androidx.compose.foundation.isSystemInDarkTheme 4 | import androidx.compose.material.MaterialTheme 5 | import androidx.compose.material.darkColors 6 | import androidx.compose.material.lightColors 7 | import androidx.compose.runtime.Composable 8 | 9 | private val DarkColorPalette = darkColors( 10 | primary = Purple200, 11 | primaryVariant = Purple700, 12 | secondary = Teal200 13 | ) 14 | 15 | private val LightColorPalette = lightColors( 16 | primary = Purple500, 17 | primaryVariant = Purple700, 18 | secondary = Teal200 19 | 20 | /* Other default colors to override 21 | background = Color.White, 22 | surface = Color.White, 23 | onPrimary = Color.White, 24 | onSecondary = Color.Black, 25 | onBackground = Color.Black, 26 | onSurface = Color.Black, 27 | */ 28 | ) 29 | 30 | @Composable 31 | fun ComposeFormTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) { 32 | val colors = if (darkTheme) { 33 | DarkColorPalette 34 | } else { 35 | LightColorPalette 36 | } 37 | 38 | MaterialTheme( 39 | colors = colors, 40 | typography = Typography, 41 | shapes = Shapes, 42 | content = content 43 | ) 44 | } -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | # IDE (e.g. Android Studio) users: 3 | # Gradle settings configured through the IDE *will override* 4 | # any settings specified in this file. 5 | # For more details on how to configure your build environment visit 6 | # http://www.gradle.org/docs/current/userguide/build_environment.html 7 | # Specifies the JVM arguments used for the daemon process. 8 | # The setting is particularly useful for tweaking memory settings. 9 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 10 | # When configured, Gradle will run in incubating parallel mode. 11 | # This option should only be used with decoupled projects. More details, visit 12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 13 | # org.gradle.parallel=true 14 | # AndroidX package structure to make it clearer which packages are bundled with the 15 | # Android operating system, and which are packaged with your app"s APK 16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn 17 | android.useAndroidX=true 18 | # Kotlin code style for this project: "official" or "obsolete": 19 | kotlin.code.style=official 20 | # Enables namespacing of each library's R class so that its R class includes only the 21 | # resources declared in the library itself and none from the library's dependencies, 22 | # thereby reducing the size of the R class for that library 23 | android.nonTransitiveRClass=true -------------------------------------------------------------------------------- /composeform/src/main/java/ch/benlu/composeform/fields/CheckboxField.kt: -------------------------------------------------------------------------------- 1 | package ch.benlu.composeform.fields 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.ui.Modifier 5 | import androidx.compose.ui.text.input.ImeAction 6 | import ch.benlu.composeform.Field 7 | import ch.benlu.composeform.FieldState 8 | import ch.benlu.composeform.Form 9 | import ch.benlu.composeform.components.CheckboxComponent 10 | 11 | class CheckboxField( 12 | label: String, 13 | form: Form, 14 | modifier: Modifier? = Modifier, 15 | fieldState: FieldState, 16 | isEnabled: Boolean = true, 17 | imeAction: ImeAction = ImeAction.Next, 18 | formatter: ((raw: Boolean?) -> String)? = null, 19 | changed: ((v: Boolean?) -> Unit)? = null 20 | ) : Field( 21 | label = label, 22 | form = form, 23 | fieldState = fieldState, 24 | isEnabled = isEnabled, 25 | modifier = modifier, 26 | imeAction = imeAction, 27 | formatter = formatter, 28 | changed = changed 29 | ) { 30 | 31 | /** 32 | * Returns a composable representing the DateField / Picker for this field 33 | */ 34 | @Composable 35 | override fun Field() { 36 | this.updateComposableValue() 37 | if (!fieldState.isVisible()) { 38 | return 39 | } 40 | CheckboxComponent( 41 | modifier = modifier ?: Modifier, 42 | checked = fieldState.state.value == true, 43 | onCheckedChange = { 44 | this.onChange(it, form) 45 | }, 46 | label = label, 47 | hasError = fieldState.hasError(), 48 | errorText = fieldState.errorText 49 | ) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /composeform/src/main/java/ch/benlu/composeform/Field.kt: -------------------------------------------------------------------------------- 1 | package ch.benlu.composeform 2 | 3 | import android.annotation.SuppressLint 4 | import androidx.compose.runtime.* 5 | import androidx.compose.ui.Modifier 6 | import androidx.compose.ui.text.input.ImeAction 7 | import androidx.compose.ui.text.input.KeyboardType 8 | import androidx.compose.ui.text.input.VisualTransformation 9 | import java.util.* 10 | 11 | abstract class Field ( 12 | open val fieldState: FieldState, 13 | open val label: String, 14 | open val form: Form, 15 | open val keyboardType: KeyboardType = KeyboardType.Text, 16 | open val visualTransformation: VisualTransformation = VisualTransformation.None, 17 | open val isEnabled: Boolean = true, 18 | open val modifier: Modifier? = Modifier, 19 | open val imeAction: ImeAction? = ImeAction.Next, 20 | open val formatter: ((raw: T?) -> String)? = null, 21 | open var changed: ((v: T?) -> Unit)? = null 22 | ) { 23 | var value: MutableState = mutableStateOf(null) 24 | 25 | /** 26 | * This method is called when the value on the input field is changed 27 | */ 28 | fun onChange(v: Any?, form: Form) { 29 | @Suppress("UNCHECKED_CAST") 30 | this.value.value = v as T? 31 | this.updateFormValue() 32 | form.validate() 33 | changed?.invoke(v) 34 | } 35 | 36 | fun updateComposableValue() { 37 | this.value.value = fieldState.state.value 38 | } 39 | 40 | private fun updateFormValue() { 41 | fieldState.state.value = this.value.value 42 | fieldState.hasChanges.value = true 43 | } 44 | 45 | @SuppressLint("NotConstructor") 46 | @Composable 47 | abstract fun Field() 48 | } 49 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 29 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 15 | 18 | 21 | 22 | 23 | 24 | 30 | -------------------------------------------------------------------------------- /composeform/src/main/java/ch/benlu/composeform/components/RadioButtonComponent.kt: -------------------------------------------------------------------------------- 1 | package ch.benlu.composeform.components 2 | 3 | import androidx.compose.foundation.layout.Row 4 | import androidx.compose.foundation.layout.fillMaxWidth 5 | import androidx.compose.foundation.layout.padding 6 | import androidx.compose.foundation.selection.selectable 7 | import androidx.compose.material.MaterialTheme 8 | import androidx.compose.material.Text 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.ui.Alignment 11 | import androidx.compose.ui.Modifier 12 | import androidx.compose.ui.tooling.preview.Preview 13 | import androidx.compose.ui.unit.dp 14 | 15 | @Composable 16 | fun RadioButtonComponent( 17 | label: String, 18 | value: T? = null, 19 | selectedValue: T?, 20 | onClickListener: (T?) -> Unit 21 | ) { 22 | Row( 23 | Modifier 24 | .fillMaxWidth() 25 | .selectable( 26 | selected = value == selectedValue, 27 | onClick = { 28 | onClickListener(value) 29 | } 30 | ) 31 | ) { 32 | androidx.compose.material.RadioButton( 33 | modifier = Modifier 34 | .padding(start = 16.dp) 35 | .align(alignment = Alignment.CenterVertically), 36 | selected = value == selectedValue, 37 | onClick = { 38 | onClickListener(value) 39 | } 40 | ) 41 | Text( 42 | text = label, 43 | style = MaterialTheme.typography.body1.merge(), 44 | modifier = Modifier 45 | .padding(8.dp) 46 | .align(alignment = Alignment.CenterVertically) 47 | ) 48 | } 49 | } 50 | 51 | @Composable 52 | @Preview 53 | fun RadioButtonPreview() { 54 | RadioButtonComponent(label = "RadioButton", value = "", onClickListener = {}, selectedValue = "") 55 | } -------------------------------------------------------------------------------- /composeform/src/main/java/ch/benlu/composeform/components/CheckboxComponent.kt: -------------------------------------------------------------------------------- 1 | package ch.benlu.composeform.components 2 | 3 | import androidx.compose.foundation.clickable 4 | import androidx.compose.foundation.interaction.MutableInteractionSource 5 | import androidx.compose.foundation.layout.* 6 | import androidx.compose.material.Checkbox 7 | import androidx.compose.material.MaterialTheme 8 | import androidx.compose.material.Text 9 | import androidx.compose.material.ripple.rememberRipple 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.runtime.remember 12 | import androidx.compose.ui.Alignment 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.draw.clip 15 | import androidx.compose.ui.text.TextStyle 16 | import androidx.compose.ui.unit.dp 17 | 18 | @Composable 19 | fun CheckboxComponent( 20 | modifier: Modifier = Modifier, 21 | checked: Boolean, 22 | onCheckedChange: ((Boolean) -> Unit), 23 | label: String, 24 | hasError: Boolean = false, 25 | errorText: MutableList? = null 26 | ) { 27 | Column(modifier = Modifier.padding(top = 8.dp)) { 28 | Column(modifier = modifier 29 | .fillMaxWidth() 30 | .clip(MaterialTheme.shapes.small) 31 | .clickable( 32 | indication = rememberRipple(color = MaterialTheme.colors.primary), 33 | interactionSource = remember { MutableInteractionSource() }, 34 | onClick = { onCheckedChange(!checked) } 35 | ) 36 | ) { 37 | Row( 38 | verticalAlignment = Alignment.CenterVertically, 39 | modifier = Modifier.padding(vertical = 8.dp) 40 | ) { 41 | Checkbox( 42 | checked = checked, 43 | onCheckedChange = null 44 | ) 45 | 46 | Spacer(Modifier.size(6.dp)) 47 | 48 | Text( 49 | text = label, 50 | style = MaterialTheme.typography.button 51 | ) 52 | } 53 | } 54 | 55 | if (hasError && errorText != null) { 56 | Text( 57 | text = errorText.joinToString(), 58 | modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), 59 | style = TextStyle.Default.copy(color = MaterialTheme.colors.error) 60 | ) 61 | } 62 | } 63 | } -------------------------------------------------------------------------------- /composeform/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'com.android.library' 3 | id 'org.jetbrains.kotlin.android' 4 | id 'maven-publish' 5 | } 6 | 7 | android { 8 | namespace 'ch.benlu.composeform' 9 | compileSdk 33 10 | 11 | defaultConfig { 12 | minSdk 24 13 | targetSdk 33 14 | 15 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 16 | consumerProguardFiles "consumer-rules.pro" 17 | } 18 | 19 | buildTypes { 20 | release { 21 | minifyEnabled false 22 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 23 | } 24 | } 25 | compileOptions { 26 | sourceCompatibility JavaVersion.VERSION_1_8 27 | targetCompatibility JavaVersion.VERSION_1_8 28 | } 29 | kotlinOptions { 30 | jvmTarget = '1.8' 31 | } 32 | 33 | buildFeatures { 34 | compose true 35 | } 36 | 37 | ext { 38 | compose_version = '1.2.0' 39 | kotlin_version = '1.7.0' 40 | } 41 | 42 | publishing { 43 | singleVariant("release") { 44 | withSourcesJar() 45 | withJavadocJar() 46 | } 47 | } 48 | } 49 | 50 | dependencies { 51 | implementation 'androidx.core:core-ktx:1.9.0' 52 | implementation "androidx.compose.ui:ui:$compose_version" 53 | implementation "androidx.compose.material:material:$compose_version" 54 | implementation "androidx.compose.ui:ui-tooling-preview:$compose_version" 55 | implementation "androidx.compose.material:material-icons-extended:$compose_version" 56 | implementation 'androidx.appcompat:appcompat:1.4.1' 57 | testImplementation 'junit:junit:4.13.2' 58 | androidTestImplementation 'androidx.test.ext:junit:1.1.5' 59 | androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' 60 | 61 | apply plugin: 'kotlin-kapt' 62 | 63 | // Reflection 64 | implementation("org.jetbrains.kotlin:kotlin-reflect:$kotlin_version") 65 | debugImplementation "androidx.compose.ui:ui-tooling:$compose_version" 66 | } 67 | 68 | publishing { 69 | publications { 70 | release(MavenPublication) { 71 | groupId = 'com.github.benjamin-luescher' 72 | artifactId = 'compose-form' 73 | version = '0.2.8' 74 | 75 | afterEvaluate { 76 | from components.release 77 | } 78 | } 79 | } 80 | } -------------------------------------------------------------------------------- /composeform/src/main/java/ch/benlu/composeform/fields/TextField.kt: -------------------------------------------------------------------------------- 1 | package ch.benlu.composeform.fields 2 | 3 | import android.annotation.SuppressLint 4 | import androidx.compose.foundation.text.KeyboardActions 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.ui.Modifier 7 | import androidx.compose.ui.text.input.ImeAction 8 | import androidx.compose.ui.text.input.KeyboardType 9 | import androidx.compose.ui.text.input.VisualTransformation 10 | import ch.benlu.composeform.Field 11 | import ch.benlu.composeform.FieldState 12 | import ch.benlu.composeform.Form 13 | import ch.benlu.composeform.components.TextFieldComponent 14 | import java.util.* 15 | 16 | class TextField( 17 | label: String, 18 | form: Form, 19 | modifier: Modifier? = Modifier, 20 | fieldState: FieldState, 21 | isEnabled: Boolean = true, 22 | imeAction: ImeAction = ImeAction.Next, 23 | formatter: ((raw: String?) -> String)? = null, 24 | keyboardType: KeyboardType = KeyboardType.Text, 25 | visualTransformation: VisualTransformation = VisualTransformation.None, 26 | changed: ((v: String?) -> Unit)? = null 27 | ) : Field( 28 | label = label, 29 | form = form, 30 | fieldState = fieldState, 31 | isEnabled = isEnabled, 32 | modifier = modifier, 33 | imeAction = imeAction, 34 | formatter = formatter, 35 | keyboardType = keyboardType, 36 | visualTransformation = visualTransformation, 37 | changed = changed 38 | ) { 39 | 40 | /** 41 | * Returns a composable representing the DateField / Picker for this field 42 | */ 43 | @SuppressLint("NotConstructor") 44 | @Composable 45 | override fun Field() { 46 | this.updateComposableValue() 47 | if (!fieldState.isVisible()) { 48 | return 49 | } 50 | 51 | TextFieldComponent( 52 | modifier = modifier ?: Modifier, 53 | imeAction = imeAction ?: ImeAction.Next, 54 | isEnabled = isEnabled, 55 | keyBoardActions = KeyboardActions.Default, 56 | keyboardType = keyboardType, 57 | onChange = { 58 | this.onChange(it, form) 59 | }, 60 | label = label, 61 | text = formatter?.invoke(value.value) ?: (value.value ?: ""), 62 | hasError = fieldState.hasError(), 63 | errorText = fieldState.errorText, 64 | visualTransformation = visualTransformation 65 | ) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'com.android.application' 3 | id 'org.jetbrains.kotlin.android' 4 | id("dagger.hilt.android.plugin") 5 | } 6 | 7 | android { 8 | compileSdk 33 9 | 10 | defaultConfig { 11 | applicationId "com.edorex.mobile.composeForm" 12 | minSdk 24 13 | targetSdk 33 14 | versionCode 1 15 | versionName "1.0" 16 | 17 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 18 | vectorDrawables { 19 | useSupportLibrary true 20 | } 21 | } 22 | 23 | buildTypes { 24 | release { 25 | minifyEnabled false 26 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 27 | } 28 | } 29 | compileOptions { 30 | sourceCompatibility JavaVersion.VERSION_1_8 31 | targetCompatibility JavaVersion.VERSION_1_8 32 | } 33 | kotlinOptions { 34 | jvmTarget = '1.8' 35 | } 36 | buildFeatures { 37 | compose true 38 | } 39 | composeOptions { 40 | kotlinCompilerExtensionVersion compose_version 41 | } 42 | packagingOptions { 43 | resources { 44 | excludes += '/META-INF/{AL2.0,LGPL2.1}' 45 | } 46 | } 47 | namespace 'com.edorex.mobile.composeForm' 48 | } 49 | 50 | dependencies { 51 | implementation 'androidx.core:core-ktx:1.9.0' 52 | implementation "androidx.compose.ui:ui:$compose_version" 53 | implementation "androidx.compose.material:material:$compose_version" 54 | implementation "androidx.compose.ui:ui-tooling-preview:$compose_version" 55 | implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.5.1' 56 | implementation 'androidx.activity:activity-compose:1.6.1' 57 | implementation project(path: ':composeform') 58 | testImplementation 'junit:junit:4.13.2' 59 | androidTestImplementation 'androidx.test.ext:junit:1.1.3' 60 | androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' 61 | androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version" 62 | debugImplementation "androidx.compose.ui:ui-tooling:$compose_version" 63 | debugImplementation "androidx.compose.ui:ui-test-manifest:$compose_version" 64 | 65 | apply plugin: 'kotlin-kapt' 66 | 67 | // Hilt 68 | implementation("com.google.dagger:hilt-android:2.42") 69 | kapt("com.google.dagger:hilt-android-compiler:2.42") 70 | implementation("androidx.hilt:hilt-navigation-compose:1.0.0") 71 | 72 | // RxKotlin 73 | implementation("io.reactivex.rxjava3:rxkotlin:3.0.1") 74 | 75 | // Reflection 76 | implementation("org.jetbrains.kotlin:kotlin-reflect:$kotlin_version") 77 | } -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if "%ERRORLEVEL%"=="0" goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 84 | exit /b 1 85 | 86 | :mainEnd 87 | if "%OS%"=="Windows_NT" endlocal 88 | 89 | :omega 90 | -------------------------------------------------------------------------------- /composeform/src/main/java/ch/benlu/composeform/fields/PasswordField.kt: -------------------------------------------------------------------------------- 1 | package ch.benlu.composeform.fields 2 | 3 | import android.annotation.SuppressLint 4 | import android.graphics.drawable.Icon 5 | import androidx.compose.foundation.text.KeyboardActions 6 | import androidx.compose.material.Icon 7 | import androidx.compose.material.IconButton 8 | import androidx.compose.material.icons.Icons 9 | import androidx.compose.material.icons.filled.Visibility 10 | import androidx.compose.material.icons.filled.VisibilityOff 11 | import androidx.compose.runtime.* 12 | import androidx.compose.ui.Modifier 13 | import androidx.compose.ui.text.input.ImeAction 14 | import androidx.compose.ui.text.input.KeyboardType 15 | import androidx.compose.ui.text.input.PasswordVisualTransformation 16 | import androidx.compose.ui.text.input.VisualTransformation 17 | import ch.benlu.composeform.Field 18 | import ch.benlu.composeform.FieldState 19 | import ch.benlu.composeform.Form 20 | import ch.benlu.composeform.components.TextFieldComponent 21 | import java.util.* 22 | 23 | class PasswordField( 24 | label: String, 25 | form: Form, 26 | modifier: Modifier? = Modifier, 27 | fieldState: FieldState, 28 | isEnabled: Boolean = true, 29 | imeAction: ImeAction = ImeAction.Next, 30 | changed: ((v: String?) -> Unit)? = null 31 | ) : Field( 32 | label = label, 33 | form = form, 34 | fieldState = fieldState, 35 | isEnabled = isEnabled, 36 | modifier = modifier, 37 | imeAction = imeAction, 38 | formatter = null, 39 | changed = changed 40 | ) { 41 | 42 | /** 43 | * Returns a composable representing the DateField / Picker for this field 44 | */ 45 | @SuppressLint("NotConstructor") 46 | @Composable 47 | override fun Field() { 48 | this.updateComposableValue() 49 | if (!fieldState.isVisible()) { 50 | return 51 | } 52 | 53 | var passwordVisible by remember { mutableStateOf(false) } 54 | TextFieldComponent( 55 | modifier = modifier ?: Modifier, 56 | imeAction = imeAction ?: ImeAction.Next, 57 | isEnabled = isEnabled, 58 | keyBoardActions = KeyboardActions.Default, 59 | keyboardType = KeyboardType.Password, 60 | onChange = { 61 | this.onChange(it, form) 62 | }, 63 | label = label, 64 | text = formatter?.invoke(value.value) ?: (value.value ?: ""), 65 | hasError = fieldState.hasError(), 66 | errorText = fieldState.errorText, 67 | visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), 68 | trailingIcon = { 69 | val image = if (passwordVisible) 70 | Icons.Filled.Visibility 71 | else Icons.Filled.VisibilityOff 72 | 73 | // Please provide localized description for accessibility services 74 | val description = if (passwordVisible) "Hide password" else "Show password" 75 | 76 | IconButton(onClick = {passwordVisible = !passwordVisible}){ 77 | Icon(imageVector = image, description) 78 | } 79 | } 80 | ) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /composeform/src/main/java/ch/benlu/composeform/Form.kt: -------------------------------------------------------------------------------- 1 | package ch.benlu.composeform 2 | 3 | import android.util.Log 4 | import androidx.compose.runtime.* 5 | 6 | abstract class Form { 7 | var isValid by mutableStateOf(true) 8 | 9 | abstract fun self(): Form 10 | 11 | /** 12 | * Returns a list of all fields in the form. 13 | */ 14 | private fun getFormFields(): List> { 15 | return this::class.java.declaredFields.map { 16 | Pair(it.name, it.getAnnotation(FormField::class.java)) 17 | }.filter { it.second != null } 18 | } 19 | 20 | fun logRawValue() { 21 | getFormFields().forEach { pair -> 22 | val name = pair.first 23 | val f = this::class.java.getDeclaredField(name) 24 | f.isAccessible = true 25 | 26 | try { 27 | val fieldState = (f.get(this) as FieldState<*>) 28 | val value = fieldState.state.value 29 | val isVisible = fieldState.isVisible() 30 | Log.d("Form", "$name:${value} (isVisible: $isVisible)") 31 | } catch (e: Exception) { 32 | Log.e("Form", e.toString()) 33 | } 34 | } 35 | } 36 | 37 | /** 38 | * Triggers validation for all fields in the form. 39 | * @param markAsChanged If true, all fields will be marked as changed. 40 | * @param ignoreInvisible If true, invisible fields will be ignored during validation. 41 | */ 42 | fun validate(markAsChanged: Boolean = false, ignoreInvisible: Boolean = true) { 43 | var isValid = true 44 | val formFields = getFormFields() 45 | 46 | formFields.forEach { pair -> 47 | val name = pair.first 48 | val f = this::class.java.getDeclaredField(name) 49 | f.isAccessible = true 50 | 51 | 52 | try { 53 | val fieldState = (f.get(this) as FieldState) 54 | 55 | // if we should ignore invisible fields, skip validation 56 | if (ignoreInvisible && !fieldState.isVisible()) { 57 | return@forEach 58 | } 59 | 60 | val value = fieldState.state?.value 61 | val validators = fieldState.validators 62 | 63 | println("$name:${value}") 64 | var isFieldValid = true 65 | 66 | // first clear all error text before validation 67 | fieldState.errorText.clear() 68 | 69 | validators.forEach { 70 | if (!it.validate(value)) { 71 | isValid = false 72 | isFieldValid = false 73 | // add error text to fieldState 74 | fieldState.errorText.add(it.errorText) 75 | } 76 | } 77 | Log.d("Form", "Field Validation ($name): $isFieldValid") 78 | fieldState.isValid.value = isFieldValid 79 | 80 | // if we should ignore untouched fields, every field should be marked as changed 81 | if (markAsChanged) { 82 | fieldState.hasChanges.value = true 83 | } 84 | 85 | } catch (e: Exception) { 86 | Log.e("Form", e.toString()) 87 | } 88 | } 89 | 90 | Log.d("Form", "Form Validation: $isValid") 91 | 92 | this.isValid = isValid 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /composeform/src/main/java/ch/benlu/composeform/fields/DateField.kt: -------------------------------------------------------------------------------- 1 | package ch.benlu.composeform.fields 2 | 3 | import android.app.DatePickerDialog 4 | import android.widget.DatePicker 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.runtime.mutableStateOf 7 | import androidx.compose.runtime.remember 8 | import androidx.compose.ui.Modifier 9 | import androidx.compose.ui.focus.FocusRequester 10 | import androidx.compose.ui.platform.LocalContext 11 | import androidx.compose.ui.platform.LocalFocusManager 12 | import androidx.compose.ui.text.input.ImeAction 13 | import ch.benlu.composeform.Field 14 | import ch.benlu.composeform.FieldState 15 | import ch.benlu.composeform.Form 16 | import ch.benlu.composeform.components.TextFieldComponent 17 | import java.util.* 18 | 19 | class DateField( 20 | label: String, 21 | form: Form, 22 | modifier: Modifier? = Modifier, 23 | fieldState: FieldState, 24 | isEnabled: Boolean = true, 25 | imeAction: ImeAction = ImeAction.Next, 26 | formatter: ((raw: Date?) -> String)? = null, 27 | private val themeResId: Int = 0, 28 | changed: ((v: Date?) -> Unit)? = null 29 | ) : Field( 30 | label = label, 31 | form = form, 32 | fieldState = fieldState, 33 | isEnabled = isEnabled, 34 | modifier = modifier, 35 | imeAction = imeAction, 36 | formatter = formatter, 37 | changed = changed 38 | ) { 39 | 40 | /** 41 | * Returns a composable representing the DateField / Picker for this field 42 | */ 43 | @Composable 44 | override fun Field() { 45 | this.updateComposableValue() 46 | if (!fieldState.isVisible()) { 47 | return 48 | } 49 | 50 | val focusRequester = FocusRequester() 51 | val focusManager = LocalFocusManager.current 52 | val context = LocalContext.current 53 | val year: Int 54 | val month: Int 55 | val day: Int 56 | 57 | val calendar = Calendar.getInstance() 58 | calendar.time = value.value ?: Date() 59 | year = calendar.get(Calendar.YEAR) 60 | month = calendar.get(Calendar.MONTH) 61 | day = calendar.get(Calendar.DAY_OF_MONTH) 62 | 63 | val date = remember { mutableStateOf("") } 64 | val datePickerDialog = DatePickerDialog( 65 | context, 66 | themeResId, 67 | { _: DatePicker, yyyy: Int, mm: Int, dd: Int -> 68 | val c = Calendar.getInstance() 69 | c.set(yyyy, mm, dd, 0, 0); 70 | val d = c.time 71 | date.value = d.toString() 72 | value.value = d 73 | this.onChange(d, form) 74 | }, 75 | year, 76 | month, 77 | day 78 | ) 79 | 80 | datePickerDialog.setOnDismissListener { 81 | focusManager.clearFocus() 82 | } 83 | 84 | TextFieldComponent( 85 | modifier = modifier ?: Modifier, 86 | isEnabled = isEnabled, 87 | label = label, 88 | text = formatter?.invoke(value.value) ?: value.value.toString(), 89 | hasError = fieldState.hasError(), 90 | errorText = fieldState.errorText, 91 | isReadOnly = true, 92 | focusRequester = focusRequester, 93 | focusChanged = { 94 | if (it.isFocused) { 95 | datePickerDialog.show() 96 | } 97 | } 98 | ) 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /composeform/src/main/java/ch/benlu/composeform/fields/PickerField.kt: -------------------------------------------------------------------------------- 1 | package ch.benlu.composeform.fields 2 | 3 | import androidx.compose.material.Icon 4 | import androidx.compose.material.icons.Icons 5 | import androidx.compose.material.icons.filled.KeyboardArrowDown 6 | import androidx.compose.material.icons.filled.KeyboardArrowUp 7 | import androidx.compose.runtime.* 8 | import androidx.compose.ui.Modifier 9 | import androidx.compose.ui.focus.FocusRequester 10 | import androidx.compose.ui.platform.LocalFocusManager 11 | import androidx.compose.ui.res.stringResource 12 | import androidx.compose.ui.text.input.ImeAction 13 | import ch.benlu.composeform.Field 14 | import ch.benlu.composeform.FieldState 15 | import ch.benlu.composeform.Form 16 | import ch.benlu.composeform.components.SingleSelectDialogComponent 17 | import ch.benlu.composeform.components.TextFieldComponent 18 | 19 | abstract class PickerValue { 20 | abstract fun searchFilter(query: String): Boolean 21 | } 22 | 23 | class PickerField( 24 | label: String, 25 | form: Form, 26 | modifier: Modifier? = Modifier, 27 | fieldState: FieldState, 28 | isEnabled: Boolean = true, 29 | imeAction: ImeAction = ImeAction.Next, 30 | formatter: ((raw: T?) -> String)? = null, 31 | private val isSearchable: Boolean = true, 32 | changed: ((v: T?) -> Unit)? = null 33 | ) : Field( 34 | label = label, 35 | form = form, 36 | fieldState = fieldState, 37 | isEnabled = isEnabled, 38 | modifier = modifier, 39 | imeAction = imeAction, 40 | formatter = formatter, 41 | changed = changed 42 | ) { 43 | 44 | @Composable 45 | override fun Field() { 46 | this.updateComposableValue() 47 | if (!fieldState.isVisible()) { 48 | return 49 | } 50 | 51 | var isDialogVisible by remember { mutableStateOf(false) } 52 | val focusRequester = FocusRequester() 53 | val focusManager = LocalFocusManager.current 54 | 55 | TextFieldComponent( 56 | modifier = modifier ?: Modifier, 57 | isEnabled = isEnabled, 58 | label = label, 59 | text = fieldState.selectedOptionText() ?: "", 60 | hasError = fieldState.hasError(), 61 | errorText = fieldState.errorText, 62 | isReadOnly = true, 63 | trailingIcon = { 64 | Icon( 65 | if (isDialogVisible) Icons.Filled.KeyboardArrowUp else Icons.Filled.KeyboardArrowDown, 66 | null 67 | ) 68 | }, 69 | focusRequester = focusRequester, 70 | focusChanged = { 71 | isDialogVisible = it.isFocused 72 | } 73 | ) 74 | 75 | if (isDialogVisible) { 76 | SingleSelectDialogComponent( 77 | title = label, 78 | optionsList = fieldState.options!!, 79 | optionItemFormatter = fieldState.optionItemFormatter, 80 | defaultSelected = fieldState.state.value, 81 | submitButtonText = stringResource(id = android.R.string.ok), 82 | onSubmitButtonClick = { 83 | isDialogVisible = false 84 | this.onChange(it, form) 85 | focusManager.clearFocus() 86 | }, 87 | onDismissRequest = { 88 | isDialogVisible = false 89 | focusManager.clearFocus() 90 | }, 91 | search = if (isSearchable) { 92 | { options, query -> 93 | options.filter { c -> c?.searchFilter(query) == true } 94 | } 95 | } else { 96 | null 97 | } 98 | ) 99 | } 100 | } 101 | 102 | } 103 | -------------------------------------------------------------------------------- /app/src/main/java/com/edorex/mobile/composeForm/MainForm.kt: -------------------------------------------------------------------------------- 1 | package com.edorex.mobile.composeForm 2 | 3 | import androidx.compose.runtime.mutableStateOf 4 | import ch.benlu.composeform.* 5 | import ch.benlu.composeform.validators.* 6 | import com.edorex.mobile.composeForm.di.ResourcesProvider 7 | import com.edorex.mobile.composeForm.models.Country 8 | import java.util.* 9 | 10 | class MainForm(resourcesProvider: ResourcesProvider): Form() { 11 | override fun self(): Form { 12 | return this 13 | } 14 | 15 | @FormField 16 | val name = FieldState( 17 | state = mutableStateOf(null), 18 | validators = mutableListOf( 19 | NotEmptyValidator(), 20 | MinLengthValidator( 21 | minLength = 3, 22 | errorText = resourcesProvider.getString(R.string.error_min_length) 23 | ) 24 | ) 25 | ) 26 | 27 | @FormField 28 | val lastName = FieldState( 29 | state = mutableStateOf(null) 30 | ) 31 | 32 | @FormField 33 | val password = FieldState( 34 | state = mutableStateOf(null), 35 | validators = mutableListOf( 36 | NotEmptyValidator(), 37 | MinLengthValidator( 38 | minLength = 8, 39 | errorText = resourcesProvider.getString(R.string.error_min_length) 40 | ) 41 | ) 42 | ) 43 | 44 | @FormField 45 | val passwordConfirm = FieldState( 46 | state = mutableStateOf(null), 47 | isVisible = { password.state.value != null && password.state.value!!.isNotEmpty() }, 48 | validators = mutableListOf( 49 | IsEqualValidator({ password.state.value }) 50 | ) 51 | ) 52 | 53 | @FormField 54 | val email = FieldState( 55 | state = mutableStateOf(null), 56 | validators = mutableListOf( 57 | EmailValidator() 58 | ) 59 | ) 60 | 61 | @FormField 62 | val country = FieldState( 63 | state = mutableStateOf(null), 64 | options = mutableListOf( 65 | Country(code = "CH", name = "Switzerland"), 66 | Country(code = "DE", name = "Germany"), 67 | Country(code = "FR", name = "France"), 68 | Country(code = "US", name = "United States"), 69 | Country(code = "ES", name = "Spain"), 70 | Country(code = "BR", name = "Brazil"), 71 | Country(code = "CN", name = "China"), 72 | ), 73 | optionItemFormatter = { "${it?.name} (${it?.code})" }, 74 | validators = mutableListOf( 75 | NotEmptyValidator() 76 | ) 77 | ) 78 | 79 | @FormField 80 | val countryNotSearchable = FieldState( 81 | state = mutableStateOf(null), 82 | options = mutableListOf( 83 | null, 84 | Country(code = "CH", name = "Switzerland"), 85 | Country(code = "DE", name = "Germany") 86 | ) 87 | ) { 88 | if (it != null) { 89 | "${it.name} (${it.code})" 90 | } else { 91 | "All" 92 | } 93 | } 94 | 95 | @FormField 96 | val startDate = FieldState( 97 | state = mutableStateOf(null), 98 | validators = mutableListOf( 99 | NotEmptyValidator() 100 | ) 101 | ) 102 | 103 | @FormField 104 | val endDate = FieldState( 105 | state = mutableStateOf(null), 106 | validators = mutableListOf( 107 | NotEmptyValidator(), 108 | DateValidator( 109 | minDateTime = {startDate.state.value?.time ?: 0}, 110 | errorText = resourcesProvider.getString(R.string.error_date_after_start_date) 111 | ) 112 | ) 113 | ) 114 | 115 | @FormField 116 | val agreeWithTerms = FieldState( 117 | state = mutableStateOf(null), 118 | validators = mutableListOf( 119 | IsEqualValidator({ true }) 120 | ) 121 | ) 122 | } -------------------------------------------------------------------------------- /composeform/src/main/java/ch/benlu/composeform/components/SingleSelectDialogComponent.kt: -------------------------------------------------------------------------------- 1 | package ch.benlu.composeform.components 2 | 3 | import androidx.compose.foundation.layout.* 4 | import androidx.compose.foundation.lazy.LazyColumn 5 | import androidx.compose.foundation.lazy.items 6 | import androidx.compose.foundation.shape.RoundedCornerShape 7 | import androidx.compose.material.* 8 | import androidx.compose.material.icons.Icons 9 | import androidx.compose.material.icons.filled.Search 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.runtime.mutableStateOf 12 | import androidx.compose.runtime.remember 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.res.stringResource 15 | import androidx.compose.ui.unit.dp 16 | import androidx.compose.ui.window.Dialog 17 | 18 | @Composable 19 | fun SingleSelectDialogComponent( 20 | title: String, 21 | text: String? = null, 22 | optionsList: MutableList, 23 | defaultSelected: T?, 24 | submitButtonText: String, 25 | onSubmitButtonClick: (T?) -> Unit, 26 | onDismissRequest: () -> Unit, 27 | optionItemFormatter: ((T?) -> String)? = null, 28 | search: ((options: MutableList, query: String) -> List)? = null 29 | ) { 30 | 31 | val selectedOption = 32 | remember { mutableStateOf(optionsList.indexOfFirst { it == defaultSelected }) } 33 | val query = remember { mutableStateOf("") } 34 | 35 | Dialog(onDismissRequest = { onDismissRequest.invoke() }) { 36 | Surface( 37 | modifier = Modifier.width(300.dp), 38 | shape = RoundedCornerShape(10.dp) 39 | ) { 40 | Column { 41 | Column(modifier = Modifier.padding(16.dp)) { 42 | Text(text = title, style = MaterialTheme.typography.h4) 43 | 44 | Spacer(modifier = Modifier.height(16.dp)) 45 | 46 | if (text != null) { 47 | Text(text = text) 48 | Spacer(modifier = Modifier.height(16.dp)) 49 | } 50 | 51 | if (search != null) { 52 | TextFieldComponent( 53 | leadingIcon = { Icon(Icons.Filled.Search, null) }, 54 | onChange = { query.value = it }, 55 | label = stringResource(id = android.R.string.search_go), 56 | isEnabled = true, 57 | text = query.value 58 | ) 59 | } 60 | } 61 | 62 | LazyColumn( 63 | modifier = if (search != null) Modifier.height(240.dp) else Modifier.wrapContentHeight() 64 | ) { 65 | item { 66 | } 67 | items( 68 | items = search?.invoke(optionsList, query.value) ?: optionsList, 69 | key = { i -> 70 | i.toString() 71 | } 72 | ) { item -> 73 | RadioButtonComponent( 74 | label = optionItemFormatter?.invoke(item) ?: item.toString(), 75 | value = item, 76 | selectedValue = optionsList.getOrNull(selectedOption.value), 77 | ) { selectedValue -> 78 | selectedOption.value = 79 | optionsList.indexOfFirst { o -> o == selectedValue } 80 | } 81 | } 82 | } 83 | 84 | Column(modifier = Modifier.padding(16.dp)) { 85 | Spacer(modifier = Modifier.height(16.dp)) 86 | 87 | Row( 88 | modifier = Modifier.fillMaxWidth(), 89 | horizontalArrangement = Arrangement.End 90 | ) { 91 | TextButton( 92 | onClick = { 93 | onDismissRequest.invoke() 94 | }, 95 | shape = MaterialTheme.shapes.medium 96 | ) { 97 | Text(text = stringResource(id = android.R.string.cancel)) 98 | } 99 | Spacer( 100 | modifier = Modifier.width(16.dp) 101 | ) 102 | Button( 103 | onClick = { 104 | if (selectedOption.value >= 0 && optionsList.size > selectedOption.value) { 105 | onSubmitButtonClick.invoke(optionsList[selectedOption.value]) 106 | } 107 | onDismissRequest.invoke() 108 | }, 109 | shape = MaterialTheme.shapes.medium 110 | ) { 111 | Text(text = submitButtonText) 112 | } 113 | } 114 | } 115 | } 116 | } 117 | } 118 | } -------------------------------------------------------------------------------- /composeform/src/main/java/ch/benlu/composeform/components/TextFieldComponent.kt: -------------------------------------------------------------------------------- 1 | package ch.benlu.composeform.components 2 | 3 | import androidx.compose.foundation.interaction.MutableInteractionSource 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.fillMaxWidth 6 | import androidx.compose.foundation.layout.padding 7 | import androidx.compose.foundation.text.KeyboardActions 8 | import androidx.compose.foundation.text.KeyboardOptions 9 | import androidx.compose.material.* 10 | import androidx.compose.material.icons.Icons 11 | import androidx.compose.material.icons.filled.KeyboardArrowDown 12 | import androidx.compose.runtime.* 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.focus.FocusRequester 15 | import androidx.compose.ui.focus.FocusState 16 | import androidx.compose.ui.focus.focusRequester 17 | import androidx.compose.ui.focus.onFocusChanged 18 | import androidx.compose.ui.text.TextStyle 19 | import androidx.compose.ui.text.input.ImeAction 20 | import androidx.compose.ui.text.input.KeyboardType 21 | import androidx.compose.ui.text.input.VisualTransformation 22 | import androidx.compose.ui.text.style.TextOverflow 23 | import androidx.compose.ui.tooling.preview.Preview 24 | import androidx.compose.ui.unit.dp 25 | 26 | @Composable 27 | fun TextFieldComponent( 28 | modifier: Modifier = Modifier, 29 | text: String, 30 | label: String, 31 | leadingIcon: @Composable() (() -> Unit)? = null, 32 | trailingIcon: @Composable() (() -> Unit)? = null, 33 | onChange: (String) -> Unit = {}, 34 | imeAction: ImeAction = ImeAction.Next, 35 | keyboardType: KeyboardType = KeyboardType.Text, 36 | keyBoardActions: KeyboardActions = KeyboardActions(), 37 | isEnabled: Boolean = true, 38 | hasError: Boolean = false, 39 | errorText: MutableList = mutableListOf(), 40 | interactionSource: MutableInteractionSource? = null, 41 | isReadOnly: Boolean = false, 42 | focusChanged: ((focus: FocusState) -> Unit)? = null, 43 | focusRequester: FocusRequester = FocusRequester(), 44 | visualTransformation: VisualTransformation = VisualTransformation.None 45 | ) { 46 | Column(modifier = modifier) { 47 | OutlinedTextField( 48 | modifier = Modifier 49 | .fillMaxWidth() 50 | .focusRequester(focusRequester) 51 | .onFocusChanged { 52 | focusChanged?.invoke(it) 53 | }, 54 | value = text, 55 | onValueChange = { 56 | onChange(it) 57 | }, 58 | leadingIcon = leadingIcon, 59 | trailingIcon = trailingIcon, 60 | keyboardOptions = KeyboardOptions(imeAction = imeAction, keyboardType = keyboardType), 61 | keyboardActions = keyBoardActions, 62 | enabled = isEnabled, 63 | colors = TextFieldDefaults.outlinedTextFieldColors(), 64 | isError = hasError, 65 | label = { 66 | Text( 67 | maxLines = 1, 68 | overflow = TextOverflow.Ellipsis, 69 | text = label 70 | ) 71 | }, 72 | readOnly = isReadOnly, 73 | interactionSource = interactionSource ?: remember { MutableInteractionSource() }, 74 | visualTransformation = visualTransformation, 75 | placeholder = null 76 | ) 77 | if (hasError) { 78 | Text( 79 | text = errorText.joinToString("\n"), 80 | modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), 81 | style = TextStyle.Default.copy(color = MaterialTheme.colors.error) 82 | ) 83 | } 84 | } 85 | } 86 | 87 | @Preview 88 | @Composable 89 | fun FormTextFieldPreview() { 90 | Surface { 91 | Column { 92 | TextFieldComponent( 93 | modifier = Modifier.padding(bottom = 8.dp), 94 | text = "My Value", 95 | label = "My Label", 96 | onChange = {}, 97 | keyBoardActions = KeyboardActions.Default, 98 | isEnabled = true, 99 | ) 100 | TextFieldComponent( 101 | modifier = Modifier.padding(bottom = 8.dp), 102 | text = "", 103 | label = "My Label", 104 | onChange = {}, 105 | keyBoardActions = KeyboardActions.Default, 106 | isEnabled = true, 107 | hasError = true, 108 | errorText = mutableListOf("Should not be empty.") 109 | ) 110 | TextFieldComponent( 111 | modifier = Modifier.padding(bottom = 8.dp), 112 | text = "", 113 | label = "My Label which is very very very long and should be ellipsized", 114 | onChange = {}, 115 | keyBoardActions = KeyboardActions.Default, 116 | isEnabled = true, 117 | hasError = true, 118 | errorText = mutableListOf("Should not be empty.") 119 | ) 120 | TextFieldComponent( 121 | modifier = Modifier.padding(bottom = 8.dp), 122 | text = "My Picker", 123 | label = "My Label", 124 | onChange = {}, 125 | keyBoardActions = KeyboardActions.Default, 126 | isEnabled = false, 127 | isReadOnly = true, 128 | trailingIcon = { Icon(Icons.Filled.KeyboardArrowDown, null) } 129 | ) 130 | TextFieldComponent( 131 | modifier = Modifier.padding(bottom = 8.dp), 132 | text = "My Picker in Confirm", 133 | label = "My Label", 134 | onChange = {}, 135 | keyBoardActions = KeyboardActions.Default, 136 | isEnabled = false, 137 | isReadOnly = true, 138 | trailingIcon = { Icon(Icons.Filled.KeyboardArrowDown, null) } 139 | ) 140 | } 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 15 | 20 | 25 | 30 | 35 | 40 | 45 | 50 | 55 | 60 | 65 | 70 | 75 | 80 | 85 | 90 | 95 | 100 | 105 | 110 | 115 | 120 | 125 | 130 | 135 | 140 | 145 | 150 | 155 | 160 | 165 | 170 | 171 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # 4 | # Copyright 2015 the original author or authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | ## 21 | ## Gradle start up script for UN*X 22 | ## 23 | ############################################################################## 24 | 25 | # Attempt to set APP_HOME 26 | # Resolve links: $0 may be a link 27 | PRG="$0" 28 | # Need this for relative symlinks. 29 | while [ -h "$PRG" ] ; do 30 | ls=`ls -ld "$PRG"` 31 | link=`expr "$ls" : '.*-> \(.*\)$'` 32 | if expr "$link" : '/.*' > /dev/null; then 33 | PRG="$link" 34 | else 35 | PRG=`dirname "$PRG"`"/$link" 36 | fi 37 | done 38 | SAVED="`pwd`" 39 | cd "`dirname \"$PRG\"`/" >/dev/null 40 | APP_HOME="`pwd -P`" 41 | cd "$SAVED" >/dev/null 42 | 43 | APP_NAME="Gradle" 44 | APP_BASE_NAME=`basename "$0"` 45 | 46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 48 | 49 | # Use the maximum available, or set MAX_FD != -1 to use that value. 50 | MAX_FD="maximum" 51 | 52 | warn () { 53 | echo "$*" 54 | } 55 | 56 | die () { 57 | echo 58 | echo "$*" 59 | echo 60 | exit 1 61 | } 62 | 63 | # OS specific support (must be 'true' or 'false'). 64 | cygwin=false 65 | msys=false 66 | darwin=false 67 | nonstop=false 68 | case "`uname`" in 69 | CYGWIN* ) 70 | cygwin=true 71 | ;; 72 | Darwin* ) 73 | darwin=true 74 | ;; 75 | MINGW* ) 76 | msys=true 77 | ;; 78 | NONSTOP* ) 79 | nonstop=true 80 | ;; 81 | esac 82 | 83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 84 | 85 | 86 | # Determine the Java command to use to start the JVM. 87 | if [ -n "$JAVA_HOME" ] ; then 88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 89 | # IBM's JDK on AIX uses strange locations for the executables 90 | JAVACMD="$JAVA_HOME/jre/sh/java" 91 | else 92 | JAVACMD="$JAVA_HOME/bin/java" 93 | fi 94 | if [ ! -x "$JAVACMD" ] ; then 95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 96 | 97 | Please set the JAVA_HOME variable in your environment to match the 98 | location of your Java installation." 99 | fi 100 | else 101 | JAVACMD="java" 102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 103 | 104 | Please set the JAVA_HOME variable in your environment to match the 105 | location of your Java installation." 106 | fi 107 | 108 | # Increase the maximum file descriptors if we can. 109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 110 | MAX_FD_LIMIT=`ulimit -H -n` 111 | if [ $? -eq 0 ] ; then 112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 113 | MAX_FD="$MAX_FD_LIMIT" 114 | fi 115 | ulimit -n $MAX_FD 116 | if [ $? -ne 0 ] ; then 117 | warn "Could not set maximum file descriptor limit: $MAX_FD" 118 | fi 119 | else 120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 121 | fi 122 | fi 123 | 124 | # For Darwin, add options to specify how the application appears in the dock 125 | if $darwin; then 126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 127 | fi 128 | 129 | # For Cygwin or MSYS, switch paths to Windows format before running java 130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then 131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 133 | 134 | JAVACMD=`cygpath --unix "$JAVACMD"` 135 | 136 | # We build the pattern for arguments to be converted via cygpath 137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 138 | SEP="" 139 | for dir in $ROOTDIRSRAW ; do 140 | ROOTDIRS="$ROOTDIRS$SEP$dir" 141 | SEP="|" 142 | done 143 | OURCYGPATTERN="(^($ROOTDIRS))" 144 | # Add a user-defined pattern to the cygpath arguments 145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 147 | fi 148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 149 | i=0 150 | for arg in "$@" ; do 151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 153 | 154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 156 | else 157 | eval `echo args$i`="\"$arg\"" 158 | fi 159 | i=`expr $i + 1` 160 | done 161 | case $i in 162 | 0) set -- ;; 163 | 1) set -- "$args0" ;; 164 | 2) set -- "$args0" "$args1" ;; 165 | 3) set -- "$args0" "$args1" "$args2" ;; 166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;; 167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 172 | esac 173 | fi 174 | 175 | # Escape application args 176 | save () { 177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 178 | echo " " 179 | } 180 | APP_ARGS=`save "$@"` 181 | 182 | # Collect all arguments for the java command, following the shell quoting and substitution rules 183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 184 | 185 | exec "$JAVACMD" "$@" 186 | -------------------------------------------------------------------------------- /app/src/main/java/com/edorex/mobile/composeForm/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.edorex.mobile.composeForm 2 | 3 | import android.annotation.SuppressLint 4 | import android.os.Bundle 5 | import android.util.Log 6 | import androidx.activity.ComponentActivity 7 | import androidx.activity.compose.setContent 8 | import androidx.compose.foundation.layout.* 9 | import androidx.compose.foundation.rememberScrollState 10 | import androidx.compose.foundation.verticalScroll 11 | import androidx.compose.material.* 12 | import androidx.compose.runtime.Composable 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.text.input.KeyboardType 15 | import androidx.compose.ui.tooling.preview.Preview 16 | import androidx.compose.ui.unit.dp 17 | import androidx.hilt.navigation.compose.hiltViewModel 18 | import ch.benlu.composeform.fields.* 19 | import ch.benlu.composeform.formatters.dateLong 20 | import ch.benlu.composeform.formatters.dateShort 21 | import com.edorex.mobile.composeForm.models.Country 22 | import com.edorex.mobile.composeForm.ui.theme.ComposeFormTheme 23 | import dagger.hilt.android.AndroidEntryPoint 24 | 25 | @AndroidEntryPoint 26 | class MainActivity : ComponentActivity() { 27 | override fun onCreate(savedInstanceState: Bundle?) { 28 | super.onCreate(savedInstanceState) 29 | setContent { 30 | ComposeFormTheme { 31 | // A surface container using the 'background' color from the theme 32 | Surface( 33 | modifier = Modifier.fillMaxSize(), 34 | color = MaterialTheme.colors.background 35 | ) { 36 | FormPage() 37 | } 38 | } 39 | } 40 | } 41 | } 42 | 43 | @SuppressLint("UnusedMaterialScaffoldPaddingParameter") 44 | @Composable 45 | fun FormPage() { 46 | val viewModel = hiltViewModel() 47 | 48 | Scaffold( 49 | content = { 50 | Column(modifier = Modifier.padding(16.dp)) { 51 | Row(modifier = Modifier 52 | .weight(1f) 53 | .verticalScroll(rememberScrollState())) { 54 | 55 | Column { 56 | TextField( 57 | modifier = Modifier.padding(bottom = 8.dp), 58 | label = "Name", 59 | form = viewModel.form, 60 | fieldState = viewModel.form.name, 61 | changed = { 62 | // log the name to show tat changed is called 63 | Log.d("Form", "Name changed: $it") 64 | // clear countries (for no reason - just to show that options list is now mutable) 65 | viewModel.form.country.options = mutableListOf() 66 | } 67 | ).Field() 68 | 69 | TextField( 70 | modifier = Modifier.padding(bottom = 8.dp), 71 | label = "Last Name", 72 | form = viewModel.form, 73 | fieldState = viewModel.form.lastName, 74 | isEnabled = false, 75 | ).Field() 76 | 77 | TextField( 78 | modifier = Modifier.padding(bottom = 8.dp), 79 | label = "E-Mail", 80 | form = viewModel.form, 81 | fieldState = viewModel.form.email, 82 | keyboardType = KeyboardType.Email 83 | ).Field() 84 | 85 | PasswordField( 86 | modifier = Modifier.padding(bottom = 8.dp), 87 | label = "Password", 88 | form = viewModel.form, 89 | fieldState = viewModel.form.password 90 | ).Field() 91 | 92 | PasswordField( 93 | modifier = Modifier.padding(bottom = 8.dp), 94 | label = "Password Confirm", 95 | form = viewModel.form, 96 | fieldState = viewModel.form.passwordConfirm 97 | ).Field() 98 | 99 | PickerField( 100 | modifier = Modifier.padding(bottom = 8.dp), 101 | label = "Country", 102 | form = viewModel.form, 103 | fieldState = viewModel.form.country 104 | ).Field() 105 | 106 | PickerField( 107 | modifier = Modifier.padding(bottom = 8.dp), 108 | label = "Country Not searchable", 109 | form = viewModel.form, 110 | fieldState = viewModel.form.countryNotSearchable, 111 | isSearchable = false 112 | ).Field() 113 | 114 | DateField( 115 | modifier = Modifier.padding(bottom = 8.dp), 116 | label = "Start Date", 117 | form = viewModel.form, 118 | fieldState = viewModel.form.startDate, 119 | formatter = ::dateShort 120 | ).Field() 121 | 122 | DateField( 123 | modifier = Modifier.padding(bottom = 8.dp), 124 | label = "End Date", 125 | form = viewModel.form, 126 | fieldState = viewModel.form.endDate, 127 | themeResId = R.style.customDatePickerStyle, 128 | formatter = ::dateLong 129 | ).Field() 130 | 131 | CheckboxField( 132 | modifier = Modifier.padding(bottom = 8.dp), 133 | fieldState = viewModel.form.agreeWithTerms, 134 | label = "I agree to Terms & Conditions", 135 | form = viewModel.form 136 | ).Field() 137 | } 138 | } 139 | 140 | ButtonRow(nextClicked = { 141 | viewModel.validate() 142 | }) 143 | 144 | } 145 | } 146 | ) 147 | } 148 | 149 | @Composable 150 | fun ButtonRow(nextClicked: () -> Unit) { 151 | Row { 152 | Button( 153 | enabled = false, 154 | modifier = Modifier.weight(1f), 155 | onClick = { 156 | // nothing 157 | } 158 | ) { 159 | Text("Back") 160 | } 161 | Spacer(modifier = Modifier.width(16.dp)) 162 | Button( 163 | modifier = Modifier.weight(1f), 164 | onClick = { 165 | nextClicked() 166 | } 167 | ) { 168 | Text("Validate") 169 | } 170 | } 171 | } 172 | 173 | @Preview 174 | @Composable 175 | fun FormPagePreview() { 176 | Surface( 177 | modifier = Modifier.fillMaxSize(), 178 | color = MaterialTheme.colors.background 179 | ) { 180 | FormPage() 181 | } 182 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Android Compose Form Library 2 | ![https://jitpack.io/#benjamin-luescher/compose-form](https://jitpack.io/v/benjamin-luescher/compose-form.svg) 3 | 4 | This library provides an easy-to-use and customizable solution for building forms in Android Jetpack Compose. It includes form fields such as text input, pickers, checkbox, and more, with built-in validators to ensure accurate user input. Data binding is also supported, making it easy to work with form data in your code. 5 | 6 | The library uses reflection, to provide more flexibility in your form design. Whether you're building a complex registration form or a simple feedback form, this library has you covered. 7 | 8 | ![ComposeForm](/images/logo.png "ComposeForm") 9 | 10 | ## Getting Started 11 | To start using the library in your Android Compose project, follow these steps: 12 | 1. Add the JitPack repository to your settings.gradle file 13 | ```kotlin 14 | dependencyResolutionManagement { 15 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) 16 | repositories { 17 | // ... 18 | maven { url "https://jitpack.io" } 19 | } 20 | } 21 | ``` 22 | > Note: In older Android projects, the repositories are defined in the root build.gradle file. 23 | 24 | 2. Add the dependency in your build.gradle file. 25 | ```kotlin 26 | implementation 'com.github.benjamin-luescher:compose-form:0.2.8' 27 | ``` 28 | 29 | ## Easy example 30 | In a first example we create a simple form with two text fields. The form will look like this: 31 | 32 | ![ComposeForm Simple](/screenshots/png/simple-form.png "Simple Form") 33 | 34 | 1. Create a your form class with your form field annotations (`@FormField`) 35 | ```kotlin 36 | class MainForm(resourcesProvider: ResourcesProvider): Form() { 37 | override fun self(): Form { 38 | return this 39 | } 40 | 41 | @FormField 42 | val name = FieldState( 43 | state = mutableStateOf(null), 44 | validators = mutableListOf(NotEmptyValidator()) 45 | ) 46 | 47 | @FormField 48 | val lastName = FieldState( 49 | state = mutableStateOf(null) 50 | ) 51 | } 52 | ``` 53 | 2. Create a ViewModel for your form. 54 | ```kotlin 55 | @HiltViewModel 56 | class MainViewModel @Inject constructor( 57 | resourcesProvider: ResourcesProvider 58 | ): ViewModel() { 59 | var form = MainForm(resourcesProvider) 60 | 61 | fun validate() { 62 | form.validate(true) 63 | Log.d("MainViewModel", "Validate (form is valid: ${form.isValid})") 64 | } 65 | } 66 | ``` 67 | 3. Add the fields in your composable UI. 68 | ```kotlin 69 | Column { 70 | TextField( 71 | label = "Name", 72 | form = viewModel.form, 73 | fieldState = viewModel.form.name, 74 | ).Field() 75 | 76 | TextField( 77 | label = "Last Name", 78 | form = viewModel.form, 79 | fieldState = viewModel.form.lastName 80 | ).Field() 81 | } 82 | ``` 83 | 84 | ## Extended Form example 85 | We now try to make a more complex form with different validators, date fields, password fields and 86 | searchable pickers. This is how the form will look like: 87 | 88 | ![ComposeForm Extended](/screenshots/gif/composeform-extended.gif "Extended Form") 89 | 90 | 1. Create a form class with form fields. Define form fields by the `@FormField` annotation. 91 | ```kotlin 92 | // in this example we have a separate data class `Country` for a country picker. 93 | data class Country( 94 | val code: String, 95 | val name: String 96 | ): PickerValue() { 97 | override fun searchFilter(query: String): Boolean { 98 | return this.name.startsWith(query) 99 | } 100 | } 101 | 102 | class MainForm(resourcesProvider: ResourcesProvider): Form() { 103 | override fun self(): Form { 104 | return this 105 | } 106 | 107 | @FormField 108 | val name = FieldState( 109 | state = mutableStateOf(null), 110 | validators = mutableListOf( 111 | NotEmptyValidator(), 112 | MinLengthValidator( 113 | minLength = 3, 114 | errorText = resourcesProvider.getString(R.string.error_min_length) 115 | ) 116 | ) 117 | ) 118 | 119 | @FormField 120 | val lastName = FieldState( 121 | state = mutableStateOf(null) 122 | ) 123 | 124 | @FormField 125 | val password = FieldState( 126 | state = mutableStateOf(null), 127 | validators = mutableListOf( 128 | NotEmptyValidator(), 129 | MinLengthValidator( 130 | minLength = 8, 131 | errorText = resourcesProvider.getString(R.string.error_min_length) 132 | ) 133 | ) 134 | ) 135 | 136 | @FormField 137 | val passwordConfirm = FieldState( 138 | state = mutableStateOf(null), 139 | validators = mutableListOf( 140 | IsEqualValidator({ password.state.value }) 141 | ) 142 | ) 143 | 144 | @FormField 145 | val email = FieldState( 146 | state = mutableStateOf(null), 147 | validators = mutableListOf( 148 | EmailValidator() 149 | ) 150 | ) 151 | 152 | @FormField 153 | val country = FieldState( 154 | state = mutableStateOf(null), 155 | options = mutableListOf( 156 | Country(code = "CH", name = "Switzerland"), 157 | Country(code = "DE", name = "Germany"), 158 | Country(code = "FR", name = "France"), 159 | Country(code = "US", name = "United States"), 160 | Country(code = "ES", name = "Spain"), 161 | Country(code = "BR", name = "Brazil"), 162 | Country(code = "CN", name = "China"), 163 | ), 164 | optionItemFormatter = { "${it?.name}" }, 165 | validators = mutableListOf( 166 | NotEmptyValidator() 167 | ) 168 | ) 169 | 170 | @FormField 171 | val startDate = FieldState( 172 | state = mutableStateOf(null), 173 | validators = mutableListOf( 174 | NotEmptyValidator() 175 | ) 176 | ) 177 | 178 | @FormField 179 | val endDate = FieldState( 180 | state = mutableStateOf(null), 181 | validators = mutableListOf( 182 | NotEmptyValidator(), 183 | DateValidator( 184 | minDateTime = {startDate.state.value?.time ?: 0}, 185 | errorText = resourcesProvider.getString(R.string.error_date_after_start_date) 186 | ) 187 | ) 188 | ) 189 | 190 | @FormField 191 | val agreeWithTerms = FieldState( 192 | state = mutableStateOf(null), 193 | validators = mutableListOf( 194 | IsEqualValidator({ true }) 195 | ) 196 | ) 197 | } 198 | ``` 199 | 2. Create a ViewModel for your form. 200 | ```kotlin 201 | @HiltViewModel 202 | class MainViewModel @Inject constructor( 203 | resourcesProvider: ResourcesProvider 204 | ): ViewModel() { 205 | var form = MainForm(resourcesProvider) 206 | 207 | fun validate() { 208 | form.validate(true) 209 | Log.d("MainViewModel", "Validate (form is valid: ${form.isValid})") 210 | } 211 | } 212 | ``` 213 | 3. Add the fields in your composable UI. 214 | ```kotlin 215 | Column { 216 | TextField( 217 | modifier = Modifier.padding(bottom = 8.dp), 218 | label = "Name", 219 | form = viewModel.form, 220 | fieldState = viewModel.form.name, 221 | ).Field() 222 | 223 | TextField( 224 | modifier = Modifier.padding(bottom = 8.dp), 225 | label = "E-Mail", 226 | form = viewModel.form, 227 | fieldState = viewModel.form.email, 228 | keyboardType = KeyboardType.Email 229 | ).Field() 230 | 231 | PasswordField( 232 | modifier = Modifier.padding(bottom = 8.dp), 233 | label = "Password", 234 | form = viewModel.form, 235 | fieldState = viewModel.form.password 236 | ).Field() 237 | 238 | PasswordField( 239 | modifier = Modifier.padding(bottom = 8.dp), 240 | label = "Password Confirm", 241 | form = viewModel.form, 242 | fieldState = viewModel.form.passwordConfirm 243 | ).Field() 244 | 245 | TextField( 246 | modifier = Modifier.padding(bottom = 8.dp), 247 | label = "Last Name", 248 | form = viewModel.form, 249 | fieldState = viewModel.form.lastName, 250 | isEnabled = false, 251 | ).Field() 252 | 253 | PickerField( 254 | modifier = Modifier.padding(bottom = 8.dp), 255 | label = "Country", 256 | form = viewModel.form, 257 | fieldState = viewModel.form.country 258 | ).Field() 259 | 260 | DateField( 261 | modifier = Modifier.padding(bottom = 8.dp), 262 | label = "Start Date", 263 | form = viewModel.form, 264 | fieldState = viewModel.form.startDate, 265 | formatter = ::dateShort 266 | ).Field() 267 | 268 | DateField( 269 | modifier = Modifier.padding(bottom = 8.dp), 270 | label = "End Date", 271 | form = viewModel.form, 272 | fieldState = viewModel.form.endDate, 273 | formatter = ::dateLong 274 | ).Field() 275 | 276 | CheckboxField( 277 | modifier = Modifier.padding(bottom = 8.dp), 278 | fieldState = viewModel.form.agreeWithTerms, 279 | label = "I agree to Terms & Conditions", 280 | form = viewModel.form 281 | ).Field() 282 | } 283 | ``` 284 | 285 | ## Features 286 | * A variety of form fields to choose from, including text input, pickers, checkbox, and more 287 | * Built-in validators to ensure accurate user input 288 | * Data binding for easy management of form data in your code 289 | 290 | ## License 291 | This library is licensed under the MIT License. 292 | --------------------------------------------------------------------------------