diff --git a/build.gradle.kts b/build.gradle.kts
index 722701adf..a43592830 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -22,5 +22,17 @@ plugins {
alias(libs.plugins.android.library) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.gradle.secrets) apply false
- alias(libs.plugins.kotlin.serialization) apply false
+ alias(libs.plugins.kapt) apply false
+ alias(libs.plugins.ksp) apply false
+ alias(libs.plugins.hilt) apply false
+}
+
+allprojects{
+ // kapt compiler cannot figure out that it needs to target the same bytecode as kotlin and java compilers
+ // without this. Furthermore, KaptGenerateStubs is unresolved in module gradle.
+ tasks.withType(org.jetbrains.kotlin.gradle.tasks.KaptGenerateStubs::class).all {
+ kotlinOptions {
+ jvmTarget = JavaVersion.VERSION_1_8.toString()
+ }
+ }
}
diff --git a/gradle.properties b/gradle.properties
index 1f59a6cd5..519c89007 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -24,7 +24,7 @@
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
-org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+org.gradle.jvmargs=-Xmx6g -XX:MaxMetaspaceSize=1g -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
@@ -39,6 +39,7 @@ kotlin.code.style=official
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true
+#android.enableBuildConfigAsBytecode=true
artifactoryUrl=https://olympus.esri.com/artifactory/arcgisruntime-snapshot-local
artifactoryGroupId=com.esri
artifactoryArtifactBaseId=arcgis-maps-kotlin-toolkit
@@ -53,7 +54,5 @@ buildNumber=0000-snapshot
ignoreBuildNumber=false
# these versions define the dependency of the ArcGIS Maps SDK for Kotlin dependency
# and are generally not overridden at the command line unless a special build is requested.
-sdkVersionNumber=200.2.0
-sdkBuildNumber=
-
-
+sdkVersionNumber=200.3.0
+sdkBuildNumber=4057
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index dd4d536bc..97f8d40e3 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -2,22 +2,30 @@
androidGradlePlugin = "8.1.1"
androidxActivity = "1.5.1"
androidXBrowser = "1.5.0"
-androidxCompose = "1.3.0"
+androidxCompose = "1.5.4"
androidxComposeCompiler = "1.4.6"
androidxCore = "1.9.0"
androidxCoreTesting = "2.1.0"
androidxEspresso = "3.4.0"
+androidxHiltNavigationCompose = "1.0.0"
androidxLifecycle = "2.3.1"
androidxLifecycleRuntimeCompose = "2.6.0-beta01"
+androidxMaterialIcons = "1.4.3"
androidxTestExt = "1.1.2"
androidxViewmodelCompose = "2.6.1"
-compileSdk = "33"
+androidxWindow = "1.2.0"
+coil = "2.4.0"
+compileSdk = "34"
compose-material3 = "1.1.0"
+compose-navigation = "2.7.5"
+hilt = "2.44.2"
+hiltExt = "1.0.0"
junit = "4.13.2"
kotlin = "1.8.20"
-minSdk = "26"
+ksp = "1.8.20-1.0.11"
kotlinxCoroutinesTest = "1.6.3"
-kotlinxSerializationJson = "1.5.0"
+minSdk = "26"
+room = "2.5.2"
truth = "1.1.4"
[libraries]
@@ -28,29 +36,41 @@ androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref =
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui", version.ref = "androidxCompose" }
androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime", version.ref = "androidxCompose" }
androidx-compose-material3 = { module = "androidx.compose.material3:material3", version.ref = "compose-material3" }
+androidx-compose-navigation = { group = "androidx.navigation", name = "navigation-compose", version.ref = "compose-navigation" }
androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics", version.ref = "androidxCompose" }
androidx-compose-ui-test = { group = "androidx.compose.ui", name = "ui-test-junit4", version.ref = "androidxCompose" }
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling", version.ref = "androidxCompose" }
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview", version.ref = "androidxCompose" }
androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest", version.ref = "androidxCompose" }
+androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "androidxHiltNavigationCompose" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "androidxLifecycle" }
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "androidxViewmodelCompose" }
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "androidxLifecycleRuntimeCompose"}
androidx-test-ext = { group = "androidx.test.ext", name = "junit-ktx", version.ref = "androidxTestExt" }
androidx-test-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "androidxEspresso" }
+androidx-window-core = { group = "androidx.window", name = "window-core", version.ref = "androidxWindow" }
+androidx-window = { group = "androidx.window", name = "window", version.ref = "androidxWindow" }
+coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
+hilt-android-core = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
+hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" }
+hilt-ext-compiler = { group = "androidx.hilt", name = "hilt-compiler", version.ref = "hiltExt" }
+hilt-ext-work = { group = "androidx.hilt", name = "hilt-work", version.ref = "hiltExt" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
-kotlin-reflect = { group = "org.jetbrains.kotlin", name = "kotlin-reflect", version.ref = "kotlin"}
-kotlinx-serialization-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-core", version.ref = "kotlinxSerializationJson" }
-kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesTest" }
+androidx-material-icons = { group = "androidx.compose.material", name = "material-icons-extended", version.ref = "androidxMaterialIcons"}
+room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
+room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
+room-ext = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
truth = { group = "com.google.truth", name = "truth", version.ref = "truth" }
[plugins]
android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" }
android-library = { id = "com.android.library", version.ref = "androidGradlePlugin" }
+hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
+kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
+ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
gradle-secrets = { id = "com.google.android.libraries.mapsplatform.secrets-gradle-plugin", version = "2.0.1"}
-kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
[bundles]
core = [
@@ -76,12 +96,6 @@ debug = [
"androidx-compose-ui-test-manifest"
]
-serialization = [
- "kotlinx-serialization-core",
- "kotlinx-serialization-json",
- "kotlin-reflect"
-]
-
unitTest = [
"junit",
"kotlinx-coroutines-test",
diff --git a/microapps/CompassApp/app/src/main/java/com/arcgismaps/toolkit/compassapp/screens/MainScreen.kt b/microapps/CompassApp/app/src/main/java/com/arcgismaps/toolkit/compassapp/screens/MainScreen.kt
index b2c78cb0b..5c7b8e040 100644
--- a/microapps/CompassApp/app/src/main/java/com/arcgismaps/toolkit/compassapp/screens/MainScreen.kt
+++ b/microapps/CompassApp/app/src/main/java/com/arcgismaps/toolkit/compassapp/screens/MainScreen.kt
@@ -47,7 +47,7 @@ fun MainScreen() {
// show a composable map using the mapViewModel
ComposableMap(
modifier = Modifier.fillMaxSize(),
- mapInterface = mapViewModel
+ mapState = mapViewModel
) {
Row(modifier = Modifier
.height(IntrinsicSize.Max)
diff --git a/microapps/CompassApp/app/src/main/java/com/arcgismaps/toolkit/compassapp/screens/MapViewModel.kt b/microapps/CompassApp/app/src/main/java/com/arcgismaps/toolkit/compassapp/screens/MapViewModel.kt
index 0dc5305e6..a33d5bc6e 100644
--- a/microapps/CompassApp/app/src/main/java/com/arcgismaps/toolkit/compassapp/screens/MapViewModel.kt
+++ b/microapps/CompassApp/app/src/main/java/com/arcgismaps/toolkit/compassapp/screens/MapViewModel.kt
@@ -22,12 +22,12 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.arcgismaps.mapping.ArcGISMap
import com.arcgismaps.toolkit.composablemap.MapInsets
-import com.arcgismaps.toolkit.composablemap.MapInterface
+import com.arcgismaps.toolkit.composablemap.MapState
class MapViewModel(
arcGISMap: ArcGISMap,
mapInsets: MapInsets = MapInsets()
-) : ViewModel(), MapInterface by MapInterface(arcGISMap, mapInsets)
+) : ViewModel(), MapState by MapState(arcGISMap, mapInsets)
class MapViewModelFactory(
private val arcGISMap: ArcGISMap,
diff --git a/microapps/FeatureEditorApp/.gitignore b/microapps/FeatureEditorApp/.gitignore
new file mode 100644
index 000000000..aa724b770
--- /dev/null
+++ b/microapps/FeatureEditorApp/.gitignore
@@ -0,0 +1,15 @@
+*.iml
+.gradle
+/local.properties
+/.idea/caches
+/.idea/libraries
+/.idea/modules.xml
+/.idea/workspace.xml
+/.idea/navEditor.xml
+/.idea/assetWizardSettings.xml
+.DS_Store
+/build
+/captures
+.externalNativeBuild
+.cxx
+local.properties
diff --git a/microapps/FeatureEditorApp/README.md b/microapps/FeatureEditorApp/README.md
new file mode 100644
index 000000000..45fd25303
--- /dev/null
+++ b/microapps/FeatureEditorApp/README.md
@@ -0,0 +1 @@
+# Feature Editor MicroApp
diff --git a/microapps/FeatureEditorApp/app/.gitignore b/microapps/FeatureEditorApp/app/.gitignore
new file mode 100644
index 000000000..796b96d1c
--- /dev/null
+++ b/microapps/FeatureEditorApp/app/.gitignore
@@ -0,0 +1 @@
+/build
diff --git a/microapps/FeatureEditorApp/app/build.gradle.kts b/microapps/FeatureEditorApp/app/build.gradle.kts
new file mode 100644
index 000000000..1097b962a
--- /dev/null
+++ b/microapps/FeatureEditorApp/app/build.gradle.kts
@@ -0,0 +1,93 @@
+/*
+ *
+ * Copyright 2023 Esri
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+plugins {
+ id("com.android.application")
+ id("org.jetbrains.kotlin.android")
+ id("com.google.android.libraries.mapsplatform.secrets-gradle-plugin")
+}
+
+secrets {
+ defaultPropertiesFileName = "secrets.defaults.properties"
+}
+
+android {
+ namespace = "com.arcgismaps.toolkit.featureeditorapp"
+ compileSdk = libs.versions.compileSdk.get().toInt()
+
+ defaultConfig {
+ applicationId ="com.arcgismaps.toolkit.featureeditorapp"
+ minSdk = libs.versions.minSdk.get().toInt()
+ targetSdk = libs.versions.compileSdk.get().toInt()
+ versionCode = 1
+ versionName = "1.0"
+
+ testInstrumentationRunner ="androidx.test.runner.AndroidJUnitRunner"
+ vectorDrawables {
+ useSupportLibrary = true
+ }
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ //proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"),("proguard-rules.pro"
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_1_8
+ targetCompatibility = JavaVersion.VERSION_1_8
+ }
+ kotlinOptions {
+ jvmTarget = "1.8"
+ }
+ @Suppress("UnstableApiUsage")
+ buildFeatures {
+ compose = true
+ buildConfig = true
+ }
+
+ composeOptions {
+ kotlinCompilerExtensionVersion = libs.versions.androidxComposeCompiler.get()
+ }
+ packaging {
+ resources {
+ excludes += "/META-INF/{AL2.0,LGPL2.1}"
+ }
+ }
+}
+
+//https://youtrack.jetbrains.com/issue/KTIJ-21063
+tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile::class).all {
+ kotlinOptions.freeCompilerArgs += "-Xcontext-receivers"
+}
+
+dependencies {
+ implementation(project(":featureeditor"))
+ implementation(project(":featureforms"))
+ implementation(project(":composable-map"))
+ implementation(arcgis.mapsSdk)
+ implementation(libs.bundles.composeCore)
+ implementation(libs.bundles.core)
+ implementation(libs.androidx.lifecycle.runtime.ktx)
+ implementation(libs.androidx.activity.compose)
+ implementation(libs.androidx.lifecycle.viewmodel.compose)
+ testImplementation(libs.bundles.unitTest)
+ androidTestImplementation(libs.bundles.composeTest)
+ debugImplementation(libs.bundles.debug)
+}
diff --git a/microapps/FeatureEditorApp/app/proguard-rules.pro b/microapps/FeatureEditorApp/app/proguard-rules.pro
new file mode 100644
index 000000000..f1b424510
--- /dev/null
+++ b/microapps/FeatureEditorApp/app/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
diff --git a/microapps/FeatureEditorApp/app/src/main/AndroidManifest.xml b/microapps/FeatureEditorApp/app/src/main/AndroidManifest.xml
new file mode 100644
index 000000000..294f68fee
--- /dev/null
+++ b/microapps/FeatureEditorApp/app/src/main/AndroidManifest.xml
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/microapps/FeatureEditorApp/app/src/main/java/com/arcgismaps/toolkit/featureeditorapp/MainActivity.kt b/microapps/FeatureEditorApp/app/src/main/java/com/arcgismaps/toolkit/featureeditorapp/MainActivity.kt
new file mode 100644
index 000000000..6759377d5
--- /dev/null
+++ b/microapps/FeatureEditorApp/app/src/main/java/com/arcgismaps/toolkit/featureeditorapp/MainActivity.kt
@@ -0,0 +1,64 @@
+/*
+ *
+ * Copyright 2023 Esri
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package com.arcgismaps.toolkit.featureeditorapp
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.lifecycle.lifecycleScope
+import com.arcgismaps.ArcGISEnvironment
+import com.arcgismaps.httpcore.authentication.TokenCredential
+import com.arcgismaps.mapping.ArcGISMap
+import com.arcgismaps.toolkit.composablemap.MapState
+import com.arcgismaps.toolkit.featureeditorapp.screens.FeatureEditorApp
+import com.arcgismaps.toolkit.featureeditorapp.screens.FeatureEditorAppState
+import com.arcgismaps.toolkit.featureeditorapp.ui.theme.FeatureEditorAppTheme
+import kotlinx.coroutines.launch
+
+private const val WEB_MAP_URL =
+ "https://runtimecoretest.maps.arcgis.com/home/item.html?id=df0f27f83eee41b0afe4b6216f80b541"
+
+class MainActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ val state = FeatureEditorAppState()
+
+ lifecycleScope.launch {
+
+ ArcGISEnvironment.authenticationManager.arcGISCredentialStore.add(
+ TokenCredential.create(
+ WEB_MAP_URL,
+ BuildConfig.WEB_MAP_USERNAME,
+ BuildConfig.WEB_MAP_PASSWORD
+ ).getOrThrow()
+ )
+
+ val map = ArcGISMap(WEB_MAP_URL)
+
+ state.setMap(map)
+ }
+
+ setContent {
+ FeatureEditorAppTheme {
+ FeatureEditorApp(state)
+ }
+ }
+ }
+}
diff --git a/microapps/FeatureEditorApp/app/src/main/java/com/arcgismaps/toolkit/featureeditorapp/screens/FeatureEditorApp.kt b/microapps/FeatureEditorApp/app/src/main/java/com/arcgismaps/toolkit/featureeditorapp/screens/FeatureEditorApp.kt
new file mode 100644
index 000000000..7da288514
--- /dev/null
+++ b/microapps/FeatureEditorApp/app/src/main/java/com/arcgismaps/toolkit/featureeditorapp/screens/FeatureEditorApp.kt
@@ -0,0 +1,45 @@
+/*
+ *
+ * Copyright 2023 Esri
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package com.arcgismaps.toolkit.featureeditorapp.screens
+
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import com.arcgismaps.toolkit.composablemap.ComposableMap
+import com.arcgismaps.toolkit.featureeditor.FeatureEditorView
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun FeatureEditorApp(state: FeatureEditorAppState) {
+ Scaffold(topBar = { TopAppBar(title = { Text("Feature Editor App") }) }) {
+
+ FeatureEditorView(
+ featureEditorState = state.featureEditorState,
+ modifier = Modifier.padding(it)
+ ) {
+ // TODO: would be nice to set insets here so editor's stuff doesn't get in way of map content
+ ComposableMap(mapState = state)
+ }
+
+ }
+}
diff --git a/microapps/FeatureEditorApp/app/src/main/java/com/arcgismaps/toolkit/featureeditorapp/screens/FeatureEditorAppState.kt b/microapps/FeatureEditorApp/app/src/main/java/com/arcgismaps/toolkit/featureeditorapp/screens/FeatureEditorAppState.kt
new file mode 100644
index 000000000..f77fec36f
--- /dev/null
+++ b/microapps/FeatureEditorApp/app/src/main/java/com/arcgismaps/toolkit/featureeditorapp/screens/FeatureEditorAppState.kt
@@ -0,0 +1,96 @@
+/*
+ * COPYRIGHT 1995-2023 ESRI
+ *
+ * TRADE SECRETS: ESRI PROPRIETARY AND CONFIDENTIAL
+ * Unpublished material - all rights reserved under the
+ * Copyright Laws of the United States.
+ *
+ * For additional information, contact:
+ * Environmental Systems Research Institute, Inc.
+ * Attn: Contracts Dept
+ * 380 New York Street
+ * Redlands, California, USA 92373
+ *
+ * email: contracts@esri.com
+ */
+
+package com.arcgismaps.toolkit.featureeditorapp.screens
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.arcgismaps.data.ArcGISFeature
+import com.arcgismaps.mapping.layers.FeatureLayer
+import com.arcgismaps.mapping.view.MapView
+import com.arcgismaps.mapping.view.SingleTapConfirmedEvent
+import com.arcgismaps.mapping.view.geometryeditor.GeometryEditor
+import com.arcgismaps.toolkit.composablemap.MapState
+import com.arcgismaps.toolkit.featureeditor.FeatureEditor
+import com.arcgismaps.toolkit.featureeditor.FeatureEditorState
+import com.arcgismaps.toolkit.featureeditor.FinishState
+import com.arcgismaps.toolkit.featureforms.FeatureFormState
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+
+class FeatureEditorAppState : ViewModel(), MapState by MapState() {
+
+ // NOTE: has to be initialized here rather than in constructor so that we
+ // can access the viewmodel scope to pass to the editor
+ val featureEditorState: FeatureEditorState
+
+ init {
+ val geoemetryEditor = GeometryEditor()
+ setGeometryEditor(geoemetryEditor)
+ featureEditorState = FeatureEditorState(
+ FeatureEditor(geoemetryEditor, viewModelScope),
+ FeatureFormState(),
+ viewModelScope,
+ )
+
+ viewModelScope.launch(start = CoroutineStart.UNDISPATCHED) {
+ featureEditorState.featureEditor.onFinish.collect {
+ when (it) {
+ is FinishState.Discarded -> {} // TODO: display a Snackbar
+ is FinishState.Stopped -> {
+ val table = it.feature.featureTable
+ if (table == null) {
+ // TODO: display something
+ return@collect
+ }
+ // TODO: handle errors here
+ // TODO: does can't update imply can add?
+ if (table.canUpdate(it.feature)) table.updateFeature(it.feature)
+ else table.addFeature(it.feature)
+ }
+ }
+ }
+ }
+ }
+
+ context(MapView, CoroutineScope) override fun onSingleTapConfirmed(singleTapEvent: SingleTapConfirmedEvent) {
+ launch {
+ val identifyResult = identifyLayers(
+ screenCoordinate = singleTapEvent.screenCoordinate,
+ tolerance = 22.0,
+ returnPopupsOnly = false
+ ).getOrNull() ?: return@launch
+
+ val selectedFeature = identifyResult.firstNotNullOfOrNull { result ->
+ result.geoElements.filterIsInstance()
+ .firstOrNull { feature ->
+ (feature.featureTable?.layer as? FeatureLayer)?.featureFormDefinition != null
+ }
+ } ?: return@launch
+
+ selectedFeature.load().getOrNull() ?: return@launch
+
+ featureEditorState.featureEditor.start(selectedFeature)
+
+ // Wait until the editor is stopped again before allowing more tap events so that we don't accidentally
+ // restart the editor by clicking on a new feature.
+ featureEditorState.isStarted.first { isStarted -> !isStarted }
+ }
+ }
+}
diff --git a/microapps/FeatureEditorApp/app/src/main/java/com/arcgismaps/toolkit/featureeditorapp/ui/theme/Color.kt b/microapps/FeatureEditorApp/app/src/main/java/com/arcgismaps/toolkit/featureeditorapp/ui/theme/Color.kt
new file mode 100644
index 000000000..c1f5d476f
--- /dev/null
+++ b/microapps/FeatureEditorApp/app/src/main/java/com/arcgismaps/toolkit/featureeditorapp/ui/theme/Color.kt
@@ -0,0 +1,29 @@
+/*
+ *
+ * Copyright 2023 Esri
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package com.arcgismaps.toolkit.featureeditorapp.ui.theme
+
+import androidx.compose.ui.graphics.Color
+
+val Purple80 = Color(0xFFD0BCFF)
+val PurpleGrey80 = Color(0xFFCCC2DC)
+val Pink80 = Color(0xFFEFB8C8)
+
+val Purple40 = Color(0xFF6650a4)
+val PurpleGrey40 = Color(0xFF625b71)
+val Pink40 = Color(0xFF7D5260)
diff --git a/microapps/FeatureEditorApp/app/src/main/java/com/arcgismaps/toolkit/featureeditorapp/ui/theme/Theme.kt b/microapps/FeatureEditorApp/app/src/main/java/com/arcgismaps/toolkit/featureeditorapp/ui/theme/Theme.kt
new file mode 100644
index 000000000..7dd4dc36b
--- /dev/null
+++ b/microapps/FeatureEditorApp/app/src/main/java/com/arcgismaps/toolkit/featureeditorapp/ui/theme/Theme.kt
@@ -0,0 +1,88 @@
+/*
+ *
+ * Copyright 2023 Esri
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package com.arcgismaps.toolkit.featureeditorapp.ui.theme
+
+import android.app.Activity
+import android.os.Build
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.dynamicDarkColorScheme
+import androidx.compose.material3.dynamicLightColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.SideEffect
+import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalView
+import androidx.core.view.WindowCompat
+
+private val DarkColorScheme = darkColorScheme(
+ primary = Purple80,
+ secondary = PurpleGrey80,
+ tertiary = Pink80
+)
+
+private val LightColorScheme = lightColorScheme(
+ primary = Purple40,
+ secondary = PurpleGrey40,
+ tertiary = Pink40
+
+ /* Other default colors to override
+ background = Color(0xFFFFFBFE),
+ surface = Color(0xFFFFFBFE),
+ onPrimary = Color.White,
+ onSecondary = Color.White,
+ onTertiary = Color.White,
+ onBackground = Color(0xFF1C1B1F),
+ onSurface = Color(0xFF1C1B1F),
+ */
+)
+
+@Composable
+fun FeatureEditorAppTheme(
+ darkTheme: Boolean = isSystemInDarkTheme(),
+ // Dynamic color is available on Android 12+
+ dynamicColor: Boolean = true,
+ content: @Composable () -> Unit
+) {
+ val colorScheme = when {
+ dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
+ val context = LocalContext.current
+ if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
+ }
+
+ darkTheme -> DarkColorScheme
+ else -> LightColorScheme
+ }
+ val view = LocalView.current
+ if (!view.isInEditMode) {
+ SideEffect {
+ val window = (view.context as Activity).window
+ window.statusBarColor = colorScheme.primary.toArgb()
+ WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme
+ }
+ }
+
+ MaterialTheme(
+ colorScheme = colorScheme,
+ typography = Typography,
+ content = content
+ )
+}
diff --git a/microapps/FeatureEditorApp/app/src/main/java/com/arcgismaps/toolkit/featureeditorapp/ui/theme/Type.kt b/microapps/FeatureEditorApp/app/src/main/java/com/arcgismaps/toolkit/featureeditorapp/ui/theme/Type.kt
new file mode 100644
index 000000000..079ab32e0
--- /dev/null
+++ b/microapps/FeatureEditorApp/app/src/main/java/com/arcgismaps/toolkit/featureeditorapp/ui/theme/Type.kt
@@ -0,0 +1,52 @@
+/*
+ *
+ * Copyright 2023 Esri
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package com.arcgismaps.toolkit.featureeditorapp.ui.theme
+
+import androidx.compose.material3.Typography
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.sp
+
+// Set of Material typography styles to start with
+val Typography = Typography(
+ bodyLarge = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 16.sp,
+ lineHeight = 24.sp,
+ letterSpacing = 0.5.sp
+ )
+ /* Other default text styles to override
+ titleLarge = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 22.sp,
+ lineHeight = 28.sp,
+ letterSpacing = 0.sp
+ ),
+ labelSmall = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Medium,
+ fontSize = 11.sp,
+ lineHeight = 16.sp,
+ letterSpacing = 0.5.sp
+ )
+ */
+)
diff --git a/microapps/FeatureEditorApp/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/microapps/FeatureEditorApp/app/src/main/res/drawable-v24/ic_launcher_foreground.xml
new file mode 100644
index 000000000..d4dc53e21
--- /dev/null
+++ b/microapps/FeatureEditorApp/app/src/main/res/drawable-v24/ic_launcher_foreground.xml
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/microapps/FeatureEditorApp/app/src/main/res/drawable/ic_launcher_background.xml b/microapps/FeatureEditorApp/app/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 000000000..4a1d1e128
--- /dev/null
+++ b/microapps/FeatureEditorApp/app/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,188 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/microapps/FeatureEditorApp/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/microapps/FeatureEditorApp/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 000000000..9b6037130
--- /dev/null
+++ b/microapps/FeatureEditorApp/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
diff --git a/microapps/FeatureEditorApp/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/microapps/FeatureEditorApp/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 000000000..9b6037130
--- /dev/null
+++ b/microapps/FeatureEditorApp/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
diff --git a/microapps/FeatureEditorApp/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/microapps/FeatureEditorApp/app/src/main/res/mipmap-hdpi/ic_launcher.webp
new file mode 100644
index 000000000..c209e78ec
Binary files /dev/null and b/microapps/FeatureEditorApp/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ
diff --git a/microapps/FeatureEditorApp/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/microapps/FeatureEditorApp/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
new file mode 100644
index 000000000..b2dfe3d1b
Binary files /dev/null and b/microapps/FeatureEditorApp/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ
diff --git a/microapps/FeatureEditorApp/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/microapps/FeatureEditorApp/app/src/main/res/mipmap-mdpi/ic_launcher.webp
new file mode 100644
index 000000000..4f0f1d64e
Binary files /dev/null and b/microapps/FeatureEditorApp/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ
diff --git a/microapps/FeatureEditorApp/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/microapps/FeatureEditorApp/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
new file mode 100644
index 000000000..62b611da0
Binary files /dev/null and b/microapps/FeatureEditorApp/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ
diff --git a/microapps/FeatureEditorApp/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/microapps/FeatureEditorApp/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
new file mode 100644
index 000000000..948a3070f
Binary files /dev/null and b/microapps/FeatureEditorApp/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ
diff --git a/microapps/FeatureEditorApp/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/microapps/FeatureEditorApp/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
new file mode 100644
index 000000000..1b9a6956b
Binary files /dev/null and b/microapps/FeatureEditorApp/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ
diff --git a/microapps/FeatureEditorApp/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/microapps/FeatureEditorApp/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
new file mode 100644
index 000000000..28d4b77f9
Binary files /dev/null and b/microapps/FeatureEditorApp/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ
diff --git a/microapps/FeatureEditorApp/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/microapps/FeatureEditorApp/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
new file mode 100644
index 000000000..9287f5083
Binary files /dev/null and b/microapps/FeatureEditorApp/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ
diff --git a/microapps/FeatureEditorApp/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/microapps/FeatureEditorApp/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
new file mode 100644
index 000000000..aa7d6427e
Binary files /dev/null and b/microapps/FeatureEditorApp/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ
diff --git a/microapps/FeatureEditorApp/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/microapps/FeatureEditorApp/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
new file mode 100644
index 000000000..9126ae37c
Binary files /dev/null and b/microapps/FeatureEditorApp/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ
diff --git a/microapps/FeatureEditorApp/app/src/main/res/values/colors.xml b/microapps/FeatureEditorApp/app/src/main/res/values/colors.xml
new file mode 100644
index 000000000..1ca186a31
--- /dev/null
+++ b/microapps/FeatureEditorApp/app/src/main/res/values/colors.xml
@@ -0,0 +1,28 @@
+
+
+
+
+ #FFBB86FC
+ #FF6200EE
+ #FF3700B3
+ #FF03DAC5
+ #FF018786
+ #FF000000
+ #FFFFFFFF
+
diff --git a/microapps/FeatureEditorApp/app/src/main/res/values/strings.xml b/microapps/FeatureEditorApp/app/src/main/res/values/strings.xml
new file mode 100644
index 000000000..e1b909fe6
--- /dev/null
+++ b/microapps/FeatureEditorApp/app/src/main/res/values/strings.xml
@@ -0,0 +1,21 @@
+
+
+
+ FeatureEditorApp
+
diff --git a/microapps/FeatureEditorApp/app/src/main/res/values/themes.xml b/microapps/FeatureEditorApp/app/src/main/res/values/themes.xml
new file mode 100644
index 000000000..6f1ab3c15
--- /dev/null
+++ b/microapps/FeatureEditorApp/app/src/main/res/values/themes.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
diff --git a/microapps/FeatureEditorApp/app/src/main/res/xml/backup_rules.xml b/microapps/FeatureEditorApp/app/src/main/res/xml/backup_rules.xml
new file mode 100644
index 000000000..5f78d0eb5
--- /dev/null
+++ b/microapps/FeatureEditorApp/app/src/main/res/xml/backup_rules.xml
@@ -0,0 +1,31 @@
+
+
+
+
+
+
diff --git a/microapps/FeatureEditorApp/app/src/main/res/xml/data_extraction_rules.xml b/microapps/FeatureEditorApp/app/src/main/res/xml/data_extraction_rules.xml
new file mode 100644
index 000000000..4edeec1c5
--- /dev/null
+++ b/microapps/FeatureEditorApp/app/src/main/res/xml/data_extraction_rules.xml
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
diff --git a/microapps/FeatureFormsApp/.gitignore b/microapps/FeatureFormsApp/.gitignore
new file mode 100644
index 000000000..aa724b770
--- /dev/null
+++ b/microapps/FeatureFormsApp/.gitignore
@@ -0,0 +1,15 @@
+*.iml
+.gradle
+/local.properties
+/.idea/caches
+/.idea/libraries
+/.idea/modules.xml
+/.idea/workspace.xml
+/.idea/navEditor.xml
+/.idea/assetWizardSettings.xml
+.DS_Store
+/build
+/captures
+.externalNativeBuild
+.cxx
+local.properties
diff --git a/microapps/FeatureFormsApp/app/.gitignore b/microapps/FeatureFormsApp/app/.gitignore
new file mode 100644
index 000000000..796b96d1c
--- /dev/null
+++ b/microapps/FeatureFormsApp/app/.gitignore
@@ -0,0 +1 @@
+/build
diff --git a/microapps/FeatureFormsApp/app/build.gradle.kts b/microapps/FeatureFormsApp/app/build.gradle.kts
new file mode 100644
index 000000000..7ebffcf12
--- /dev/null
+++ b/microapps/FeatureFormsApp/app/build.gradle.kts
@@ -0,0 +1,94 @@
+plugins {
+ id("com.android.application")
+ id("org.jetbrains.kotlin.android")
+ id("com.google.android.libraries.mapsplatform.secrets-gradle-plugin")
+ id("org.jetbrains.kotlin.kapt")
+ id("com.google.devtools.ksp")
+ id("com.google.dagger.hilt.android")
+}
+
+secrets {
+ defaultPropertiesFileName = "secrets.defaults.properties"
+}
+
+android {
+ namespace = "com.arcgismaps.toolkit.featureformsapp"
+ compileSdk = libs.versions.compileSdk.get().toInt()
+
+ defaultConfig {
+ applicationId ="com.arcgismaps.toolkit.featureformsapp"
+ minSdk = libs.versions.minSdk.get().toInt()
+ targetSdk = libs.versions.compileSdk.get().toInt()
+ versionCode = 1
+ versionName = "1.0"
+
+ testInstrumentationRunner ="androidx.test.runner.AndroidJUnitRunner"
+ vectorDrawables {
+ useSupportLibrary = true
+ }
+ }
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ //proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"),("proguard-rules.pro"
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_1_8
+ targetCompatibility = JavaVersion.VERSION_1_8
+ }
+ kotlinOptions {
+ jvmTarget = "1.8"
+ }
+ @Suppress("UnstableApiUsage")
+ buildFeatures {
+ compose = true
+ buildConfig = true
+ }
+
+ composeOptions {
+ kotlinCompilerExtensionVersion = libs.versions.androidxComposeCompiler.get()
+ }
+ packaging {
+ resources {
+ excludes += "/META-INF/{AL2.0,LGPL2.1}"
+ }
+ }
+}
+
+//https://youtrack.jetbrains.com/issue/KTIJ-21063
+tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile::class).all {
+ kotlinOptions.freeCompilerArgs += "-Xcontext-receivers"
+}
+
+dependencies {
+ implementation(project(":authentication"))
+ implementation(project(":featureforms"))
+ implementation(project(":composable-map"))
+ // sdk
+ implementation(arcgis.mapsSdk)
+ // hilt
+ implementation(libs.hilt.android.core)
+ implementation(libs.androidx.hilt.navigation.compose)
+ kapt(libs.hilt.compiler)
+ // room
+ implementation(libs.room.runtime)
+ annotationProcessor(libs.room.compiler)
+ implementation(libs.room.ext)
+ kapt(libs.room.compiler)
+ // coil
+ implementation(libs.coil.compose)
+ // jetpack window manager
+ implementation(libs.androidx.window)
+ implementation(libs.androidx.window.core)
+ // compose
+ implementation(libs.bundles.composeCore)
+ implementation(libs.bundles.core)
+ implementation(libs.androidx.lifecycle.runtime.ktx)
+ implementation(libs.androidx.activity.compose)
+ implementation(libs.androidx.compose.navigation)
+ implementation(libs.androidx.lifecycle.viewmodel.compose)
+ testImplementation(libs.bundles.unitTest)
+ androidTestImplementation(libs.bundles.composeTest)
+ debugImplementation(libs.bundles.debug)
+}
diff --git a/microapps/FeatureFormsApp/app/proguard-rules.pro b/microapps/FeatureFormsApp/app/proguard-rules.pro
new file mode 100644
index 000000000..f1b424510
--- /dev/null
+++ b/microapps/FeatureFormsApp/app/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
diff --git a/microapps/FeatureFormsApp/app/src/androidTest/java/com/arcgismaps/toolkit/featureformsapp/FeatureFormViewModelTests.kt b/microapps/FeatureFormsApp/app/src/androidTest/java/com/arcgismaps/toolkit/featureformsapp/FeatureFormViewModelTests.kt
new file mode 100644
index 000000000..ca73fdd92
--- /dev/null
+++ b/microapps/FeatureFormsApp/app/src/androidTest/java/com/arcgismaps/toolkit/featureformsapp/FeatureFormViewModelTests.kt
@@ -0,0 +1,61 @@
+package com.arcgismaps.toolkit.featureformsapp
+
+import androidx.compose.material3.Card
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.test.hasContentDescription
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.performClick
+import com.arcgismaps.mapping.ArcGISMap
+import com.arcgismaps.mapping.view.MapView
+import com.arcgismaps.mapping.view.SingleTapConfirmedEvent
+import com.arcgismaps.toolkit.composablemap.ComposableMap
+import com.arcgismaps.toolkit.composablemap.MapState
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.runTest
+import org.junit.Rule
+import org.junit.Test
+
+/**
+ * Tests for the FeatureForms ViewModel's correctness.
+ */
+class FeatureFormViewModelTests {
+ @get:Rule
+ val composeTestRule = createComposeRule()
+
+ /**
+ * Given a ComposableMap
+ * When it is tapped
+ * Then the MapView's onSingleTapConfirmed Flow can be collected upon in the model's viewLogic method.
+ */
+ @OptIn(ExperimentalCoroutinesApi::class)
+ @Test
+ fun testSingleTapIsConsumed() = runTest {
+ val map = ArcGISMap()
+ val mockMapInterface = MockMapInterface(map)
+ composeTestRule.setContent {
+ ComposableMap(mapState = mockMapInterface) {
+ Card(modifier = Modifier.semantics { }) {}
+ }
+ }
+
+ composeTestRule.onNode(hasContentDescription("MapContainer")).performClick()
+ // if this exits, the assertion is met.
+ mockMapInterface.onClick.first {
+ it != null
+ }
+ }
+}
+
+class MockMapInterface(map: ArcGISMap) : MapState by MapState(map) {
+ val onClick: MutableStateFlow = MutableStateFlow(null)
+ context(MapView, CoroutineScope) override fun onSingleTapConfirmed(singleTapEvent: SingleTapConfirmedEvent) {
+ launch {
+ onClick.emit(Unit)
+ }
+ }
+}
diff --git a/microapps/FeatureFormsApp/app/src/main/AndroidManifest.xml b/microapps/FeatureFormsApp/app/src/main/AndroidManifest.xml
new file mode 100644
index 000000000..76f2f589b
--- /dev/null
+++ b/microapps/FeatureFormsApp/app/src/main/AndroidManifest.xml
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/FeatureFormApplication.kt b/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/FeatureFormApplication.kt
new file mode 100644
index 000000000..b2ce2a564
--- /dev/null
+++ b/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/FeatureFormApplication.kt
@@ -0,0 +1,25 @@
+/*
+ *
+ * Copyright 2023 Esri
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package com.arcgismaps.toolkit.featureformsapp
+
+import android.app.Application
+import dagger.hilt.android.HiltAndroidApp
+
+@HiltAndroidApp
+class FeatureFormApplication : Application()
diff --git a/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/MainActivity.kt b/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/MainActivity.kt
new file mode 100644
index 000000000..4705a1fed
--- /dev/null
+++ b/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/MainActivity.kt
@@ -0,0 +1,159 @@
+package com.arcgismaps.toolkit.featureformsapp
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.lifecycleScope
+import androidx.navigation.compose.rememberNavController
+import com.arcgismaps.ArcGISEnvironment
+import com.arcgismaps.httpcore.authentication.ArcGISCredentialStore
+import com.arcgismaps.portal.Portal
+import com.arcgismaps.toolkit.featureformsapp.data.PortalSettings
+import com.arcgismaps.toolkit.featureformsapp.navigation.AppNavigation
+import com.arcgismaps.toolkit.featureformsapp.navigation.NavigationRoute
+import com.arcgismaps.toolkit.featureformsapp.navigation.Navigator
+import com.arcgismaps.toolkit.featureformsapp.ui.theme.FeatureFormsAppTheme
+import dagger.hilt.EntryPoint
+import dagger.hilt.InstallIn
+import dagger.hilt.android.AndroidEntryPoint
+import dagger.hilt.android.EntryPointAccessors
+import dagger.hilt.components.SingletonComponent
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import javax.inject.Inject
+
+@AndroidEntryPoint
+class MainActivity : ComponentActivity() {
+
+ // Interface to get the PortalSettings instance from Hilt
+ @EntryPoint
+ @InstallIn(SingletonComponent::class)
+ interface PortalSettingsFactory {
+ fun getPortalSettings(): PortalSettings
+ }
+
+ @Inject
+ lateinit var navigator: Navigator
+
+ private val appState: MutableStateFlow = MutableStateFlow(AppState.Loading)
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ ArcGISEnvironment.applicationContext = this
+ setContent {
+ FeatureFormsAppTheme {
+ FeatureFormApp(appState.collectAsState().value, navigator)
+ }
+ }
+ lifecycleScope.launch {
+ // fetch the singleton PortalSettings
+ val factory = EntryPointAccessors.fromApplication(
+ this@MainActivity,
+ PortalSettingsFactory::class.java
+ )
+ loadCredentials(factory.getPortalSettings())
+ }
+ }
+
+ private suspend fun loadCredentials(portalSettings: PortalSettings) =
+ withContext(Dispatchers.IO) {
+ // create and set a ArcGISCredentialStore that persists
+ val arcGISCredentialStore = ArcGISCredentialStore.createWithPersistence().getOrThrow()
+ ArcGISEnvironment.authenticationManager.arcGISCredentialStore = arcGISCredentialStore
+ // get the portal settings url
+ val url = portalSettings.getPortalUrl()
+ // check if any credentials are present for this portal
+ val credential =
+ ArcGISEnvironment.authenticationManager.arcGISCredentialStore.getCredential(url)
+ appState.value = if (credential == null) {
+ // if the portal connection type set it Anonymous, then the user has skipped sign in
+ if (portalSettings.getPortalConnection() == Portal.Connection.Anonymous) {
+ AppState.SkipSignIn
+ } else {
+ AppState.NotLoggedIn
+ }
+ } else {
+ AppState.LoggedIn
+ }
+ }
+}
+
+@Composable
+fun FeatureFormApp(appState: AppState, navigator: Navigator) {
+ if (appState is AppState.Loading) {
+ AnimatedLoading({ true }, modifier = Modifier.fillMaxSize())
+ } else {
+ // create a NavController
+ val navController = rememberNavController()
+ // if the user has logged in or skipped sign in, go to the Home screen, else present
+ // login screen
+ val startDestination =
+ if (appState is AppState.LoggedIn || appState is AppState.SkipSignIn) {
+ NavigationRoute.Home.route
+ } else {
+ NavigationRoute.Login.route
+ }
+ AppNavigation(
+ navController = navController,
+ navigator = navigator,
+ startDestination = startDestination
+ )
+ }
+}
+
+@Composable
+fun AnimatedLoading(
+ visibilityProvider: () -> Boolean,
+ modifier: Modifier = Modifier,
+ backgroundColor: Color = MaterialTheme.colorScheme.surface,
+ statusText: String = "",
+) {
+ val visible = visibilityProvider()
+ if (visible) {
+ Surface(
+ modifier = modifier,
+ color = backgroundColor
+ ) {
+ Column(
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(30.dp),
+ strokeWidth = 5.dp
+ )
+ if (statusText.isNotEmpty()) {
+ Spacer(modifier = Modifier.size(10.dp))
+ Text(text = statusText)
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Represents the current app state based on the login state of the user.
+ */
+sealed class AppState {
+ object Loading : AppState()
+ object LoggedIn : AppState()
+ object NotLoggedIn : AppState()
+ object SkipSignIn : AppState()
+}
diff --git a/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/data/PortalItemRepository.kt b/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/data/PortalItemRepository.kt
new file mode 100644
index 000000000..14935af46
--- /dev/null
+++ b/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/data/PortalItemRepository.kt
@@ -0,0 +1,179 @@
+package com.arcgismaps.toolkit.featureformsapp.data
+
+import android.graphics.Bitmap
+import android.util.Log
+import com.arcgismaps.mapping.PortalItem
+import com.arcgismaps.portal.Portal
+import com.arcgismaps.toolkit.featureformsapp.data.local.ItemCacheDao
+import com.arcgismaps.toolkit.featureformsapp.data.local.ItemCacheEntry
+import com.arcgismaps.toolkit.featureformsapp.data.local.ItemData
+import com.arcgismaps.toolkit.featureformsapp.data.network.ItemRemoteDataSource
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.mapLatest
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withContext
+import java.io.File
+import java.io.FileOutputStream
+
+data class PortalItemData(
+ val portalItem: PortalItem,
+ val thumbnailUri: String
+)
+
+/**
+ * A repository to map the data source items into loaded PortalItems. This is the primary repository
+ * to interact with by the UI/domain layer. This also uses the [ItemCacheDao] to provide a caching
+ * mechanism.
+ */
+class PortalItemRepository(
+ private val dispatcher: CoroutineDispatcher,
+ private val remoteDataSource: ItemRemoteDataSource,
+ private val itemCacheDao: ItemCacheDao,
+ private val filesDir: String
+) {
+ // in memory cache of loaded portal items
+ private val portalItems: MutableMap = mutableMapOf()
+
+ // to protect shared state of portalItems
+ private val mutex = Mutex()
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ private val portalItemsFlow: Flow> =
+ itemCacheDao.observeAll().mapLatest { entries ->
+ // map the cache entries into loaded portal items
+ entries.mapNotNull { entry ->
+ val portal = Portal(entry.portalUrl)
+ val portalItem = PortalItem.fromJsonOrNull(entry.json, portal)
+ portalItem?.let {
+ portalItems[portalItem.itemId] = portalItem
+ PortalItemData(portalItem, entry.thumbnailUri)
+ }
+ }
+ }.flowOn(dispatcher)
+
+ /**
+ * Returns the list of loaded PortalItemData as a flow.
+ */
+ fun observe(): Flow> = portalItemsFlow
+
+ /**
+ * Refreshes the underlying data source to fetch the latest content. [forceUpdate] when set to
+ * true, will clear the existing cache.
+ *
+ * This operation is suspending and will wait until the underlying data source has finished
+ * AND the repository has finished loading the portal items.
+ */
+ suspend fun refresh(
+ portalUri: String,
+ connection: Portal.Connection,
+ forceUpdate: Boolean = false
+ ) = withContext(dispatcher) {
+ mutex.withLock {
+ if (forceUpdate) deleteAllCacheEntries()
+ portalItems.clear()
+ // get local items
+ val localItems = getListOfMaps().map { ItemData(it) }
+ // get network items
+ val remoteItems = remoteDataSource.fetchItemData(portalUri, connection)
+ // load the portal items and add them to cache
+ loadAndCachePortalItems(localItems + remoteItems)
+ }
+ }
+
+ suspend fun deleteAll() = withContext(dispatcher) {
+ mutex.withLock {
+ deleteAllCacheEntries()
+ portalItems.clear()
+ }
+ }
+
+ /**
+ * Returns the number of items in the repository.
+ */
+ suspend fun getItemCount(): Int = withContext(dispatcher) {
+ itemCacheDao.getCount()
+ }
+
+ /**
+ * Loads the list of [items] into loaded portal items and adds them to the Cache.
+ */
+ private suspend fun loadAndCachePortalItems(items: List) {
+ val entries = items.mapNotNull { itemData ->
+ val portalItem = PortalItem(itemData.url)
+ // ignore if the portal items fails to load
+ val result = portalItem.load().onFailure {
+ Log.e("PortalItemRepository", "loadAndCachePortalItems: $it")
+ }
+ if (result.isFailure) {
+ null
+ } else {
+ val thumbnailUri = portalItem.thumbnail?.let { thumbnail ->
+ thumbnail.load()
+ thumbnail.image?.bitmap?.let { bitmap ->
+ createThumbnail(
+ portalItem.itemId,
+ bitmap
+ )
+ }
+ } ?: ""
+ ItemCacheEntry(
+ itemData.url,
+ portalItem.toJson(),
+ thumbnailUri,
+ portalItem.portal.url
+ )
+ }
+ }
+ // purge existing items and add the updated items
+ createCacheEntries(entries)
+ }
+
+ /**
+ * Deletes and inserts the list of [entries] using the [ItemCacheDao].
+ */
+ private suspend fun createCacheEntries(entries: List) =
+ withContext(dispatcher) {
+ itemCacheDao.deleteAndInsert(entries)
+ }
+
+ /**
+ * Deletes all entries in the database using the [ItemCacheDao].
+ */
+ private suspend fun deleteAllCacheEntries() =
+ withContext(Dispatchers.IO) {
+ itemCacheDao.deleteAll()
+ val thumbsDir = File("$filesDir/thumbs")
+ if (thumbsDir.exists()) thumbsDir.deleteRecursively()
+ }
+
+ /**
+ * Creates a JPEG thumbnail using the [bitmap] with [name] filename in the local files
+ * directory and returns the absolute path to the file.
+ */
+ private suspend fun createThumbnail(name: String, bitmap: Bitmap): String =
+ withContext(Dispatchers.IO) {
+ val thumbsDir = File("$filesDir/thumbs")
+ if (!thumbsDir.exists()) thumbsDir.mkdirs()
+ val file = File("${thumbsDir.absolutePath}/${name}.jpg")
+ file.createNewFile()
+ FileOutputStream(file).use {
+ bitmap.compress(Bitmap.CompressFormat.JPEG, 100, it)
+ }
+ file.absolutePath
+ }
+
+ operator fun invoke(itemId: String): PortalItem? = portalItems[itemId]
+}
+
+/**
+ * Local data source of a list of portal urls
+ */
+fun getListOfMaps(): List =
+ listOf(
+ "https://www.arcgis.com/home/item.html?id=a95963333bf84055b7115dc60d10443e"
+ )
diff --git a/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/data/PortalSettings.kt b/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/data/PortalSettings.kt
new file mode 100644
index 000000000..9ab03af72
--- /dev/null
+++ b/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/data/PortalSettings.kt
@@ -0,0 +1,76 @@
+/*
+ * Copyright 2023 Esri
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.arcgismaps.toolkit.featureformsapp.data
+
+import android.content.Context
+import android.content.SharedPreferences
+import com.arcgismaps.ArcGISEnvironment
+import com.arcgismaps.portal.Portal
+import com.arcgismaps.toolkit.authentication.signOut
+import com.arcgismaps.toolkit.featureformsapp.R
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+class PortalSettings(
+ context: Context
+) {
+ private val preferences: SharedPreferences =
+ context.getSharedPreferences("portal_settings", Context.MODE_PRIVATE)
+
+ val defaultPortalUrl: String = context.getString(R.string.agol_portal_url)
+
+ private val urlKey = "url"
+ private val connectionKey = "connection"
+
+ fun getPortalConnection() : Portal.Connection {
+ val connection = preferences.getInt(connectionKey, 0)
+ return if (connection == 0) {
+ Portal.Connection.Authenticated
+ } else {
+ Portal.Connection.Anonymous
+ }
+ }
+
+ suspend fun setPortalConnection(connection: Portal.Connection) = withContext(Dispatchers.IO) {
+ with(preferences.edit()) {
+ val value = if (connection is Portal.Connection.Authenticated) {
+ 0
+ } else {
+ 1
+ }
+ putInt(connectionKey, value)
+ commit()
+ }
+ }
+
+ fun getPortalUrl(): String {
+ return preferences.getString(urlKey, "") ?: ""
+ }
+
+ suspend fun setPortalUrl(url: String) = withContext(Dispatchers.IO) {
+ with(preferences.edit()) {
+ putString(urlKey, url)
+ commit()
+ }
+ }
+
+ suspend fun signOut() = withContext(Dispatchers.IO) {
+ setPortalConnection(Portal.Connection.Authenticated)
+ ArcGISEnvironment.authenticationManager.signOut()
+ }
+}
diff --git a/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/data/local/ItemCacheDao.kt b/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/data/local/ItemCacheDao.kt
new file mode 100644
index 000000000..b8bb9b7a0
--- /dev/null
+++ b/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/data/local/ItemCacheDao.kt
@@ -0,0 +1,89 @@
+package com.arcgismaps.toolkit.featureformsapp.data.local
+
+import androidx.room.Dao
+import androidx.room.Database
+import androidx.room.Entity
+import androidx.room.Insert
+import androidx.room.PrimaryKey
+import androidx.room.Query
+import androidx.room.RoomDatabase
+import androidx.room.Transaction
+import androidx.room.Upsert
+import kotlinx.coroutines.flow.Flow
+
+/**
+ * Model to represent a PortalItem Cache entry.
+ */
+@Entity
+data class ItemCacheEntry(
+ @PrimaryKey val itemId: String,
+ val json: String,
+ val thumbnailUri: String,
+ val portalUrl: String
+)
+
+@Dao
+interface ItemCacheDao {
+
+ /**
+ * Insert an item into the itemcacheentry table.
+ *
+ * @param item the ItemCacheEntry type to insert.
+ */
+ @Insert
+ suspend fun insert(item: ItemCacheEntry) : Long
+
+ /**
+ * Observes list of ItemData.
+ *
+ * @return all ItemData.
+ */
+ @Query("SELECT * FROM itemcacheentry")
+ fun observeAll(): Flow>
+
+ /**
+ * fetch an entry by id.
+ *
+ * @param itemId the item id.
+ * @return the cache entry with the item id.
+ */
+ @Query("SELECT * FROM itemcacheentry WHERE itemId = :itemId")
+ suspend fun getById(itemId: String): ItemCacheEntry?
+
+ /**
+ * Get the number of items in the table.
+ *
+ * @return the number of items.
+ */
+ @Query("SELECT COUNT(*) FROM itemcacheentry")
+ suspend fun getCount() : Int
+
+
+ /**
+ * Deletes all existing items in the table and inserts the new list [items].
+ *
+ * @param items the list of items to insert.
+ * @return the list of row id's that were inserted.
+ */
+ @Transaction
+ suspend fun deleteAndInsert(items: List) : List {
+ deleteAll()
+ return items.map {
+ insert(it)
+ }
+ }
+
+ /**
+ * Delete all entries.
+ */
+ @Query("DELETE FROM itemcacheentry")
+ suspend fun deleteAll()
+}
+
+/**
+ * The room database that contains the ItemCacheEntry table.
+ */
+@Database(entities = [ItemCacheEntry::class], version = 1, exportSchema = false)
+abstract class ItemCacheDatabase : RoomDatabase() {
+ abstract fun itemCacheDao() : ItemCacheDao
+}
diff --git a/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/data/local/ItemData.kt b/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/data/local/ItemData.kt
new file mode 100644
index 000000000..084bdd2ff
--- /dev/null
+++ b/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/data/local/ItemData.kt
@@ -0,0 +1,15 @@
+package com.arcgismaps.toolkit.featureformsapp.data.local
+
+import com.arcgismaps.portal.Portal
+
+/**
+ * the data for the item. Just an URL, but abstracted away
+ */
+data class ItemData(val url: String)
+
+/**
+ * The API to use to get the items
+ */
+interface ItemApi {
+ suspend fun fetchItems(portalUri: String, connection : Portal.Connection): List
+}
diff --git a/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/data/network/ItemRemoteDataSource.kt b/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/data/network/ItemRemoteDataSource.kt
new file mode 100644
index 000000000..1caaa0d66
--- /dev/null
+++ b/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/data/network/ItemRemoteDataSource.kt
@@ -0,0 +1,67 @@
+package com.arcgismaps.toolkit.featureformsapp.data.network
+
+import android.util.Log
+import com.arcgismaps.portal.Portal
+import com.arcgismaps.portal.PortalItemType
+import com.arcgismaps.toolkit.featureformsapp.data.local.ItemApi
+import com.arcgismaps.toolkit.featureformsapp.data.local.ItemData
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.withContext
+
+/**
+ * Main data source for accessing the portal item data from the network.
+ */
+class ItemRemoteDataSource(
+ private val dispatcher: CoroutineDispatcher,
+ private val itemApi: ItemApi = object : ItemApi {
+ override suspend fun fetchItems(
+ portalUri: String,
+ connection: Portal.Connection
+ ): List {
+ // create a Portal
+ val portal = Portal(
+ portalUri,
+ connection = connection
+ )
+ // log an exception and return if the portal loading fails
+ portal.load().onFailure {
+ Log.e("ItemRemoteDataSource", "error in fetchItems: ${it.message}")
+ return emptyList()
+ }
+ val user = portal.user ?: return emptyList()
+ // fetch the users content
+ val portalUserContent = user.fetchContent().getOrElse { return emptyList() }
+ // get the specified folder under the users content
+ val folder = portalUserContent.folders.firstOrNull {
+ it.title == portalFolder
+ }
+ return if (folder != null) {
+ // fetch and return content within the specified folder
+ user.fetchContentInFolder(folder.folderId).getOrDefault(emptyList()).filter {
+ // filter the content by WebMaps only
+ it.type == PortalItemType.WebMap
+ }.map {
+ ItemData(it.url)
+ }
+ } else {
+ portalUserContent.items.filter {
+ it.type == PortalItemType.WebMap
+ }.map {
+ ItemData(it.url)
+ }
+ }
+ }
+ }
+) {
+ companion object {
+ /**
+ * Folder under the portal to fetch the portal items from.
+ */
+ const val portalFolder = "Apollo"
+ }
+
+ suspend fun fetchItemData(portalUri: String, connection: Portal.Connection): List =
+ withContext(dispatcher) {
+ itemApi.fetchItems(portalUri, connection)
+ }
+}
diff --git a/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/di/CoroutinesModule.kt b/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/di/CoroutinesModule.kt
new file mode 100644
index 000000000..421a746e5
--- /dev/null
+++ b/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/di/CoroutinesModule.kt
@@ -0,0 +1,76 @@
+/*
+ *
+ * Copyright 2023 Esri
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package com.arcgismaps.toolkit.featureformsapp.di
+
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import javax.inject.Qualifier
+import javax.inject.Singleton
+
+
+/**
+ * Provide an annotation to inject the IO dispatcher
+ */
+@Qualifier
+@Retention(AnnotationRetention.RUNTIME)
+annotation class IoDispatcher
+
+
+/**
+ * Provide an annotation to inject the default dispatcher
+ */
+@Retention(AnnotationRetention.RUNTIME)
+@Qualifier
+annotation class DefaultDispatcher
+
+/**
+ * Provide an injectable supervisor scope
+ */
+@Retention(AnnotationRetention.RUNTIME)
+@Qualifier
+annotation class ApplicationScope
+
+/**
+ * The providers of the scope and dispatchers
+ */
+@Module
+@InstallIn(SingletonComponent::class)
+object CoroutinesModule {
+
+ @Provides
+ @IoDispatcher
+ fun providesIODispatcher(): CoroutineDispatcher = Dispatchers.IO
+
+ @Provides
+ @DefaultDispatcher
+ fun providesDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default
+
+ @Provides
+ @Singleton
+ @ApplicationScope
+ fun providesCoroutineScope(
+ @DefaultDispatcher dispatcher: CoroutineDispatcher
+ ): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher)
+}
diff --git a/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/di/DataModule.kt b/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/di/DataModule.kt
new file mode 100644
index 000000000..2344448d0
--- /dev/null
+++ b/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/di/DataModule.kt
@@ -0,0 +1,86 @@
+/*
+ *
+ * Copyright 2023 Esri
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package com.arcgismaps.toolkit.featureformsapp.di
+
+import android.content.Context
+import com.arcgismaps.toolkit.featureformsapp.data.PortalItemRepository
+import com.arcgismaps.toolkit.featureformsapp.data.PortalSettings
+import com.arcgismaps.toolkit.featureformsapp.data.local.ItemCacheDao
+import com.arcgismaps.toolkit.featureformsapp.data.network.ItemRemoteDataSource
+import com.arcgismaps.toolkit.featureformsapp.navigation.Navigator
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import kotlinx.coroutines.CoroutineDispatcher
+import javax.inject.Qualifier
+import javax.inject.Singleton
+
+/**
+ * Provide an annotation to inject the ItemRemoteDataSource
+ */
+@Qualifier
+@Retention(AnnotationRetention.RUNTIME)
+annotation class ItemRemoteSource
+
+/**
+ * Provide an annotation to inject the PortalItemRepository
+ */
+@Qualifier
+@Retention(AnnotationRetention.SOURCE)
+annotation class PortalItemRepo
+
+
+@Module
+@InstallIn(SingletonComponent::class)
+class DataModule {
+
+ /**
+ * The provider of the ItemRemoteDataSource.
+ */
+ @Provides
+ @ItemRemoteSource
+ internal fun provideItemRemoteDataSource(@IoDispatcher dispatcher: CoroutineDispatcher): ItemRemoteDataSource =
+ ItemRemoteDataSource(dispatcher)
+
+ /**
+ * The provider of the PortalItemRepository.
+ */
+ @Provides
+ @Singleton
+ @PortalItemRepo
+ internal fun providePortalItemRepository(
+ @IoDispatcher dispatcher: CoroutineDispatcher,
+ @ItemRemoteSource remoteDataSource: ItemRemoteDataSource,
+ @ItemCache itemCacheDao: ItemCacheDao,
+ @ApplicationContext context: Context
+ ): PortalItemRepository =
+ PortalItemRepository(dispatcher, remoteDataSource, itemCacheDao, context.filesDir.absolutePath)
+
+ @Singleton
+ @Provides
+ internal fun providePortalSettings(
+ @ApplicationContext context: Context,
+ ): PortalSettings = PortalSettings(context = context)
+
+ @Singleton
+ @Provides
+ internal fun provideNavigator(): Navigator = Navigator()
+}
diff --git a/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/di/PersistenceModule.kt b/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/di/PersistenceModule.kt
new file mode 100644
index 000000000..cb30d5a71
--- /dev/null
+++ b/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/di/PersistenceModule.kt
@@ -0,0 +1,46 @@
+package com.arcgismaps.toolkit.featureformsapp.di
+
+import android.content.Context
+import androidx.room.Room
+import com.arcgismaps.toolkit.featureformsapp.data.local.ItemCacheDao
+import com.arcgismaps.toolkit.featureformsapp.data.local.ItemCacheDatabase
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Qualifier
+import javax.inject.Singleton
+
+/**
+ * Provide an annotation to inject the ItemCache Data Access Object.
+ */
+@Qualifier
+@Retention(AnnotationRetention.RUNTIME)
+annotation class ItemCache
+
+@Module
+@InstallIn(SingletonComponent::class)
+object PersistenceModule {
+
+ /**
+ * The provider of the item cache dao.
+ */
+ @Singleton
+ @Provides
+ @ItemCache
+ fun provideItemCacheDao(database: ItemCacheDatabase): ItemCacheDao = database.itemCacheDao()
+
+ /**
+ * The provider of the ItemCacheDatabase.
+ */
+ @Singleton
+ @Provides
+ fun provideItemCacheDataBase(@ApplicationContext context: Context): ItemCacheDatabase {
+ return Room.databaseBuilder(
+ context,
+ ItemCacheDatabase::class.java,
+ "portal_items.db"
+ ).build()
+ }
+}
diff --git a/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/navigation/Navigator.kt b/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/navigation/Navigator.kt
new file mode 100644
index 000000000..56ecc00de
--- /dev/null
+++ b/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/navigation/Navigator.kt
@@ -0,0 +1,119 @@
+/*
+ * Copyright 2023 Esri
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.arcgismaps.toolkit.featureformsapp.navigation
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.AnimatedVisibilityScope
+import androidx.compose.animation.EnterTransition
+import androidx.compose.animation.ExitTransition
+import androidx.compose.animation.core.MutableTransitionState
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.slideInHorizontally
+import androidx.compose.animation.slideOutHorizontally
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.ui.Modifier
+import androidx.navigation.NamedNavArgument
+import androidx.navigation.NavBackStackEntry
+import androidx.navigation.NavDeepLink
+import androidx.navigation.NavGraphBuilder
+import androidx.navigation.NavHostController
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import com.arcgismaps.toolkit.featureformsapp.screens.browse.MapListScreen
+import com.arcgismaps.toolkit.featureformsapp.screens.login.LoginScreen
+import com.arcgismaps.toolkit.featureformsapp.screens.map.MapScreen
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import java.net.URLEncoder
+import java.nio.charset.StandardCharsets
+
+class Navigator {
+ private val _navigationFlow = MutableSharedFlow(extraBufferCapacity = 1)
+ val navigationFlow = _navigationFlow.asSharedFlow()
+
+ fun navigateTo(route: NavigationRoute) {
+ _navigationFlow.tryEmit(route)
+ }
+}
+
+sealed class NavigationRoute private constructor(val route: String) {
+ object Login : NavigationRoute("login")
+ object Home : NavigationRoute("home")
+ object MapView : NavigationRoute("mapview/{uri}")
+}
+
+@Composable
+fun AppNavigation(
+ navController: NavHostController,
+ navigator: Navigator,
+ startDestination: String,
+) {
+ LaunchedEffect(Unit) {
+ navigator.navigationFlow.collect {
+ navController.navigate(it.route) {
+ if (it == NavigationRoute.Login) {
+ navController.popBackStack()
+ }
+ }
+ }
+ }
+ // create a NavHost with a navigation graph builder
+ NavHost(navController = navController, startDestination = startDestination) {
+ // Login screen
+ composable(
+ NavigationRoute.Login.route,
+ enterTransition = { fadeIn() },
+ exitTransition = { fadeOut() }
+ ) {
+ LoginScreen {
+ // on successful login, go to the map list screen
+ navController.navigate(NavigationRoute.Home.route) {
+ // remove this entry from the nav stack to disable a "back" action
+ navController.popBackStack()
+ }
+ }
+ }
+ // Home screen - shows the list of maps
+ composable(
+ NavigationRoute.Home.route,
+ enterTransition = { fadeIn() },
+ exitTransition = { fadeOut() }
+ ) {
+ MapListScreen { uri ->
+ // encode the uri since it is equivalent to a navigation route
+ val encodedUri = URLEncoder.encode(uri, StandardCharsets.UTF_8.toString())
+ val route = "mapview/$encodedUri"
+ // navigate to the mapview
+ navController.navigate(route)
+ }
+
+ }
+ // MapView Screen - shows the map and the FeatureForms
+ composable(
+ NavigationRoute.MapView.route,
+ enterTransition = { slideInHorizontally { h -> h } },
+ exitTransition = { slideOutHorizontally { h -> h } }
+ ) {
+ MapScreen {
+ // navigate back on back pressed
+ navController.navigateUp()
+ }
+ }
+ }
+}
diff --git a/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/screens/bottomsheet/BottomSheetScaffold.kt b/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/screens/bottomsheet/BottomSheetScaffold.kt
new file mode 100644
index 000000000..3ed97ac89
--- /dev/null
+++ b/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/screens/bottomsheet/BottomSheetScaffold.kt
@@ -0,0 +1,454 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ *
+ * Modifications copyright (C) 2023 Esri Inc
+ */
+
+package com.arcgismaps.toolkit.featureformsapp.screens.bottomsheet
+
+import androidx.compose.foundation.gestures.Orientation
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.requiredHeightIn
+import androidx.compose.foundation.layout.requiredWidth
+import androidx.compose.material3.BottomSheetDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.Surface
+import androidx.compose.material3.contentColorFor
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Alignment.Companion.CenterHorizontally
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.layout.SubcomposeLayout
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.semantics.collapse
+import androidx.compose.ui.semantics.dismiss
+import androidx.compose.ui.semantics.expand
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.unit.Dp
+import kotlinx.coroutines.launch
+import java.lang.Float.max
+import kotlin.math.roundToInt
+
+/**
+ * Material Design standard bottom sheet scaffold.
+ *
+ * Standard bottom sheets co-exist with the screen’s main UI region and allow for simultaneously
+ * viewing and interacting with both regions. They are commonly used to keep a feature or
+ * secondary content visible on screen when content in main UI region is frequently scrolled or
+ * panned.
+ *
+ * 
+ *
+ * This component provides API to put together several material components to construct your
+ * screen, by ensuring proper layout strategy for them and collecting necessary data so these
+ * components will work together correctly.
+ *
+ * A simple example of a standard bottom sheet looks like this:
+ *
+ * @sample androidx.compose.material3.samples.SimpleBottomSheetScaffoldSample
+ *
+ * @param sheetContent the content of the bottom sheet
+ * @param modifier the [Modifier] to be applied to this scaffold
+ * @param scaffoldState the state of the bottom sheet scaffold
+ * @param sheetPeekHeight the height of the bottom sheet when it is collapsed
+ * @param sheetShape the shape of the bottom sheet
+ * @param sheetExpansionHeight the height for the bottom sheet when it is partially expanded and
+ * expanded
+ * @param sheetContainerColor the background color of the bottom sheet
+ * @param sheetContentColor the preferred content color provided by the bottom sheet to its
+ * children. Defaults to the matching content color for [sheetContainerColor], or if that is
+ * not a color from the theme, this will keep the same content color set above the bottom sheet.
+ * @param sheetTonalElevation the tonal elevation of the bottom sheet
+ * @param sheetShadowElevation the shadow elevation of the bottom sheet
+ * @param sheetDragHandle optional visual marker to pull the scaffold's bottom sheet
+ * @param sheetSwipeEnabled whether the sheet swiping is enabled and should react to the user's
+ * input
+ * @param topBar top app bar of the screen, typically a [SmallTopAppBar]
+ * @param snackbarHost component to host [Snackbar]s that are pushed to be shown via
+ * [SnackbarHostState.showSnackbar], typically a [SnackbarHost]
+ * @param containerColor the color used for the background of this scaffold. Use [Color.Transparent]
+ * to have no color.
+ * @param contentColor the preferred color for content inside this scaffold. Defaults to either the
+ * matching content color for [containerColor], or to the current [LocalContentColor] if
+ * [containerColor] is not a color from the theme.
+ * @param content content of the screen. The lambda receives a [PaddingValues] that should be
+ * applied to the content root via [Modifier.padding] and [Modifier.consumeWindowInsets] to
+ * properly offset top and bottom bars. If using [Modifier.verticalScroll], apply this modifier to
+ * the child of the scroll, and not on the scroll itself.
+ */
+@Composable
+@ExperimentalMaterial3Api
+fun BottomSheetScaffold(
+ sheetContent: @Composable ColumnScope.() -> Unit,
+ modifier: Modifier = Modifier,
+ scaffoldState: BottomSheetScaffoldState = rememberBottomSheetScaffoldState(),
+ sheetPeekHeight: Dp = BottomSheetDefaults.SheetPeekHeight,
+ sheetShape: Shape = BottomSheetDefaults.ExpandedShape,
+ sheetExpansionHeight: SheetExpansionHeight = SheetExpansionHeight(),
+ sheetContainerColor: Color = BottomSheetDefaults.ContainerColor,
+ sheetContentColor: Color = contentColorFor(sheetContainerColor),
+ sheetTonalElevation: Dp = BottomSheetDefaults.Elevation,
+ sheetShadowElevation: Dp = BottomSheetDefaults.Elevation,
+ sheetDragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() },
+ sheetSwipeEnabled: Boolean = true,
+ topBar: @Composable (() -> Unit)? = null,
+ snackbarHost: @Composable (SnackbarHostState) -> Unit = { SnackbarHost(it) },
+ containerColor: Color = MaterialTheme.colorScheme.surface,
+ contentColor: Color = contentColorFor(containerColor),
+ content: @Composable (PaddingValues) -> Unit
+) {
+ BottomSheetScaffoldLayout(
+ modifier = modifier,
+ topBar = topBar,
+ body = content,
+ snackbarHost = {
+ snackbarHost(scaffoldState.snackbarHostState)
+ },
+ sheetPeekHeight = sheetPeekHeight,
+ sheetOffset = { scaffoldState.bottomSheetState.requireOffset() },
+ sheetState = scaffoldState.bottomSheetState,
+ containerColor = containerColor,
+ contentColor = contentColor,
+ bottomSheet = { layoutHeight ->
+ StandardBottomSheet(
+ state = scaffoldState.bottomSheetState,
+ peekHeight = sheetPeekHeight,
+ expansionHeight = sheetExpansionHeight,
+ sheetSwipeEnabled = sheetSwipeEnabled,
+ layoutHeight = layoutHeight.toFloat(),
+ sheetWidth = BottomSheetMaxWidth,
+ shape = sheetShape,
+ containerColor = sheetContainerColor,
+ contentColor = sheetContentColor,
+ tonalElevation = sheetTonalElevation,
+ shadowElevation = sheetShadowElevation,
+ dragHandle = sheetDragHandle,
+ content = sheetContent
+ )
+ }
+ )
+}
+
+/**
+ * State of the [BottomSheetScaffold] composable.
+ *
+ * @param bottomSheetState the state of the persistent bottom sheet
+ * @param snackbarHostState the [SnackbarHostState] used to show snackbars inside the scaffold
+ */
+@ExperimentalMaterial3Api
+@Stable
+class BottomSheetScaffoldState(
+ val bottomSheetState: SheetState,
+ val snackbarHostState: SnackbarHostState
+)
+
+/**
+ * Create and [remember] a [BottomSheetScaffoldState].
+ *
+ * @param bottomSheetState the state of the standard bottom sheet. See
+ * [rememberStandardBottomSheetState]
+ * @param snackbarHostState the [SnackbarHostState] used to show snackbars inside the scaffold
+ */
+@Composable
+@ExperimentalMaterial3Api
+fun rememberBottomSheetScaffoldState(
+ bottomSheetState: SheetState = rememberStandardBottomSheetState(),
+ snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }
+): BottomSheetScaffoldState {
+ return remember(bottomSheetState, snackbarHostState) {
+ BottomSheetScaffoldState(
+ bottomSheetState = bottomSheetState,
+ snackbarHostState = snackbarHostState
+ )
+ }
+}
+
+/**
+ * Create and [remember] a [SheetState] for [BottomSheetScaffold].
+ *
+ * @param initialValue the initial value of the state. Should be either [PartiallyExpanded] or
+ * [Expanded] if [skipHiddenState] is true
+ * @param confirmValueChange optional callback invoked to confirm or veto a pending state change
+ * @param [skipHiddenState] whether Hidden state is skipped for [BottomSheetScaffold]
+ */
+@Composable
+@ExperimentalMaterial3Api
+fun rememberStandardBottomSheetState(
+ initialValue: SheetValue = SheetValue.PartiallyExpanded,
+ confirmValueChange: (SheetValue) -> Boolean = { true },
+ skipHiddenState: Boolean = true,
+) = rememberSheetState(false, confirmValueChange, initialValue, skipHiddenState)
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun StandardBottomSheet(
+ state: SheetState,
+ peekHeight: Dp,
+ expansionHeight: SheetExpansionHeight,
+ layoutHeight: Float,
+ sheetWidth: Dp,
+ sheetSwipeEnabled: Boolean = true,
+ shape: Shape = BottomSheetDefaults.ExpandedShape,
+ containerColor: Color = BottomSheetDefaults.ContainerColor,
+ contentColor: Color = contentColorFor(containerColor),
+ tonalElevation: Dp = BottomSheetDefaults.Elevation,
+ shadowElevation: Dp = BottomSheetDefaults.Elevation,
+ dragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() },
+ content: @Composable ColumnScope.() -> Unit
+) {
+ val scope = rememberCoroutineScope()
+ val peekHeightPx = with(LocalDensity.current) { peekHeight.toPx() }
+ val orientation = Orientation.Vertical
+ // Callback that is invoked when the anchors have changed.
+ val anchorChangeHandler = remember(state, scope) {
+ BottomSheetScaffoldAnchorChangeHandler(
+ state = state,
+ animateTo = { target, velocity ->
+ scope.launch {
+ state.swipeableState.animateTo(
+ target, velocity = velocity
+ )
+ }
+ },
+ snapTo = { target ->
+ scope.launch { state.swipeableState.snapTo(target) }
+ }
+ )
+ }
+ Surface(
+ modifier = Modifier
+ .requiredWidth(sheetWidth)
+ .fillMaxWidth()
+ .requiredHeightIn(min = peekHeight)
+ .nestedScroll(
+ remember(state.swipeableState) {
+ ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection(
+ sheetState = state,
+ orientation = orientation,
+ onFling = { scope.launch { state.settle(it) } }
+ )
+ }
+ )
+ .swipeableV2(
+ state = state.swipeableState,
+ orientation = orientation,
+ enabled = sheetSwipeEnabled
+ )
+ .swipeAnchors(
+ state.swipeableState,
+ possibleValues = setOf(
+ SheetValue.Hidden,
+ SheetValue.Minimized,
+ SheetValue.PartiallyExpanded,
+ SheetValue.Expanded
+ ),
+ anchorChangeHandler = anchorChangeHandler
+ ) { value, sheetSize ->
+ when (value) {
+ SheetValue.PartiallyExpanded -> (layoutHeight - peekHeightPx - (sheetSize.height * expansionHeight.partialHeightFraction))
+ SheetValue.Expanded -> if (sheetSize.height == peekHeightPx.roundToInt()) {
+ null
+ } else {
+ max(
+ 0f,
+ (layoutHeight - (sheetSize.height * expansionHeight.fullHeightFraction))
+ )
+ }
+
+ SheetValue.Minimized -> layoutHeight - peekHeightPx
+ SheetValue.Hidden -> layoutHeight
+ }
+ },
+ shape = shape,
+ color = containerColor,
+ contentColor = contentColor,
+ tonalElevation = tonalElevation,
+ shadowElevation = shadowElevation,
+ ) {
+ Column(Modifier.fillMaxWidth()) {
+ if (dragHandle != null) {
+ val partialExpandActionLabel =
+ getString(Strings.BottomSheetPartialExpandDescription)
+ val dismissActionLabel = getString(Strings.BottomSheetDismissDescription)
+ val expandActionLabel = getString(Strings.BottomSheetExpandDescription)
+ Box(
+ Modifier
+ .align(CenterHorizontally)
+ .semantics(mergeDescendants = true) {
+ with(state) {
+ // Provides semantics to interact with the bottomsheet if there is more
+ // than one anchor to swipe to and swiping is enabled.
+ if (swipeableState.anchors.size > 1 && sheetSwipeEnabled) {
+ if (currentValue == SheetValue.PartiallyExpanded) {
+ if (swipeableState.confirmValueChange(SheetValue.Expanded)) {
+ expand(expandActionLabel) {
+ scope.launch { expand() }; true
+ }
+ }
+ } else {
+ if (swipeableState.confirmValueChange(SheetValue.PartiallyExpanded)) {
+ collapse(partialExpandActionLabel) {
+ scope.launch { partialExpand() }; true
+ }
+ }
+ }
+ if (!state.skipHiddenState) {
+ dismiss(dismissActionLabel) {
+ scope.launch { hide() }
+ true
+ }
+ }
+ }
+ }
+ },
+ ) {
+ dragHandle()
+ }
+ }
+ content()
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun BottomSheetScaffoldLayout(
+ modifier: Modifier,
+ topBar: @Composable (() -> Unit)?,
+ body: @Composable (innerPadding: PaddingValues) -> Unit,
+ bottomSheet: @Composable (layoutHeight: Int) -> Unit,
+ snackbarHost: @Composable () -> Unit,
+ sheetPeekHeight: Dp,
+ sheetOffset: () -> Float,
+ sheetState: SheetState,
+ containerColor: Color,
+ contentColor: Color,
+) {
+ SubcomposeLayout { constraints ->
+ val layoutWidth = constraints.maxWidth
+ val layoutHeight = constraints.maxHeight
+ val looseConstraints = constraints.copy(minWidth = 0, minHeight = 0)
+
+ val topBarPlaceable = topBar?.let {
+ subcompose(BottomSheetScaffoldLayoutSlot.TopBar) { topBar() }[0]
+ .measure(looseConstraints)
+ }
+ val topBarHeight = topBarPlaceable?.height ?: 0
+
+ val sheetPlaceable = subcompose(BottomSheetScaffoldLayoutSlot.Sheet) {
+ bottomSheet(layoutHeight - topBarHeight)
+ }[0].measure(looseConstraints.copy(maxHeight = layoutHeight - topBarHeight))
+ val sheetOffsetY = sheetOffset().roundToInt() + topBarHeight
+ val sheetOffsetX = Integer.max(0, (layoutWidth - sheetPlaceable.width) / 2)
+
+ val bodyConstraints = looseConstraints.copy(maxHeight = layoutHeight - topBarHeight)
+ val bodyPlaceable = subcompose(BottomSheetScaffoldLayoutSlot.Body) {
+ Surface(
+ modifier = modifier,
+ color = containerColor,
+ contentColor = contentColor,
+ ) { body(PaddingValues(bottom = sheetPeekHeight)) }
+ }[0].measure(bodyConstraints)
+
+ val snackbarPlaceable = subcompose(BottomSheetScaffoldLayoutSlot.Snackbar, snackbarHost)[0]
+ .measure(looseConstraints)
+ val snackbarOffsetX = (layoutWidth - snackbarPlaceable.width) / 2
+ val snackbarOffsetY = when (sheetState.currentValue) {
+ SheetValue.PartiallyExpanded -> sheetOffsetY - snackbarPlaceable.height
+ SheetValue.Expanded, SheetValue.Hidden, SheetValue.Minimized -> layoutHeight - snackbarPlaceable.height
+ }
+
+ layout(layoutWidth, layoutHeight) {
+ // Placement order is important for elevation
+ bodyPlaceable.placeRelative(0, topBarHeight)
+ topBarPlaceable?.placeRelative(0, 0)
+ sheetPlaceable.placeRelative(sheetOffsetX, sheetOffsetY)
+ snackbarPlaceable.placeRelative(snackbarOffsetX, snackbarOffsetY)
+ }
+ }
+}
+
+@ExperimentalMaterial3Api
+private fun BottomSheetScaffoldAnchorChangeHandler(
+ state: SheetState,
+ animateTo: (target: SheetValue, velocity: Float) -> Unit,
+ snapTo: (target: SheetValue) -> Unit,
+) = AnchorChangeHandler { previousTarget, previousAnchors, newAnchors ->
+ val previousTargetOffset = previousAnchors[previousTarget]
+ val newTarget = when (previousTarget) {
+ SheetValue.Minimized -> SheetValue.PartiallyExpanded
+ SheetValue.Hidden, SheetValue.PartiallyExpanded -> SheetValue.PartiallyExpanded
+ SheetValue.Expanded -> if (newAnchors.containsKey(SheetValue.Expanded)) SheetValue.Expanded else SheetValue.PartiallyExpanded
+ }
+ val newTargetOffset = newAnchors.getValue(newTarget)
+ if (newTargetOffset != previousTargetOffset) {
+ if (state.swipeableState.isAnimationRunning) {
+ // Re-target the animation to the new offset if it changed
+ animateTo(newTarget, state.swipeableState.lastVelocity)
+ } else {
+ // Snap to the new offset value of the target if no animation was running
+ snapTo(newTarget)
+ }
+ }
+}
+
+@Composable
+fun StandardBottomSheetLayout(
+ modifier: Modifier = Modifier,
+ sheetOffset: () -> Float,
+ bottomSheet: @Composable (layoutHeight: Int) -> Unit,
+) {
+ SubcomposeLayout(modifier = modifier) { constraints ->
+ val layoutWidth = constraints.maxWidth
+ val layoutHeight = constraints.maxHeight
+
+ val sheetPlaceable = subcompose(0) {
+ bottomSheet(layoutHeight)
+ }[0].measure(constraints)
+ val sheetOffsetY = sheetOffset().roundToInt()
+ val sheetOffsetX = Integer.max(0, (layoutWidth - sheetPlaceable.width) / 2)
+
+ layout(layoutWidth, layoutHeight) {
+ sheetPlaceable.placeRelative(sheetOffsetX, sheetOffsetY)
+ }
+ }
+}
+
+private enum class BottomSheetScaffoldLayoutSlot { TopBar, Body, Sheet, Snackbar }
+
+/**
+ * Defines the height values as fractions for the PartiallyExpanded and Expanded Sheet Values
+ * of the bottom sheet
+ *
+ * @param partialHeightFraction fractional for the PartiallyExpanded height between [0,1]
+ * @param fullHeightFraction fractional for the Expanded height between [0,1]
+ */
+class SheetExpansionHeight(
+ val partialHeightFraction: Float = 0.5f,
+ val fullHeightFraction: Float = 1f
+)
diff --git a/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/screens/bottomsheet/InternalMutatorMutex.kt b/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/screens/bottomsheet/InternalMutatorMutex.kt
new file mode 100644
index 000000000..6514b63b3
--- /dev/null
+++ b/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/screens/bottomsheet/InternalMutatorMutex.kt
@@ -0,0 +1,166 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ *
+ * Modifications copyright (C) 2023 Esri Inc
+ */
+
+package com.arcgismaps.toolkit.featureformsapp.screens.bottomsheet
+
+import androidx.compose.foundation.MutatePriority
+import androidx.compose.runtime.Stable
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+
+internal typealias InternalAtomicReference =
+ java.util.concurrent.atomic.AtomicReference
+
+/**
+ * Mutual exclusion for UI state mutation over time.
+ *
+ * [mutate] permits interruptible state mutation over time using a standard [MutatePriority].
+ * A [InternalMutatorMutex] enforces that only a single writer can be active at a time for a particular
+ * state resource. Instead of queueing callers that would acquire the lock like a traditional
+ * [Mutex], new attempts to [mutate] the guarded state will either cancel the current mutator or
+ * if the current mutator has a higher priority, the new caller will throw [CancellationException].
+ *
+ * [InternalMutatorMutex] should be used for implementing hoisted state objects that many mutators may
+ * want to manipulate over time such that those mutators can coordinate with one another. The
+ * [InternalMutatorMutex] instance should be hidden as an implementation detail. For example:
+ *
+ */
+@Stable
+internal class InternalMutatorMutex {
+ private class Mutator(val priority: MutatePriority, val job: Job) {
+ fun canInterrupt(other: Mutator) = priority >= other.priority
+
+ fun cancel() = job.cancel()
+ }
+
+ private val currentMutator = InternalAtomicReference(null)
+ private val mutex = Mutex()
+
+ private fun tryMutateOrCancel(mutator: Mutator) {
+ while (true) {
+ val oldMutator = currentMutator.get()
+ if (oldMutator == null || mutator.canInterrupt(oldMutator)) {
+ if (currentMutator.compareAndSet(oldMutator, mutator)) {
+ oldMutator?.cancel()
+ break
+ }
+ } else throw CancellationException("Current mutation had a higher priority")
+ }
+ }
+
+ /**
+ * Enforce that only a single caller may be active at a time.
+ *
+ * If [mutate] is called while another call to [mutate] or [mutateWith] is in progress, their
+ * [priority] values are compared. If the new caller has a [priority] equal to or higher than
+ * the call in progress, the call in progress will be cancelled, throwing
+ * [CancellationException] and the new caller's [block] will be invoked. If the call in
+ * progress had a higher [priority] than the new caller, the new caller will throw
+ * [CancellationException] without invoking [block].
+ *
+ * @param priority the priority of this mutation; [MutatePriority.Default] by default.
+ * Higher priority mutations will interrupt lower priority mutations.
+ * @param block mutation code to run mutually exclusive with any other call to [mutate],
+ * [mutateWith] or [tryMutate].
+ */
+ suspend fun mutate(
+ priority: MutatePriority = MutatePriority.Default,
+ block: suspend () -> R
+ ) = coroutineScope {
+ val mutator = Mutator(priority, coroutineContext[Job]!!)
+
+ tryMutateOrCancel(mutator)
+
+ mutex.withLock {
+ try {
+ block()
+ } finally {
+ currentMutator.compareAndSet(mutator, null)
+ }
+ }
+ }
+
+ /**
+ * Enforce that only a single caller may be active at a time.
+ *
+ * If [mutateWith] is called while another call to [mutate] or [mutateWith] is in progress,
+ * their [priority] values are compared. If the new caller has a [priority] equal to or
+ * higher than the call in progress, the call in progress will be cancelled, throwing
+ * [CancellationException] and the new caller's [block] will be invoked. If the call in
+ * progress had a higher [priority] than the new caller, the new caller will throw
+ * [CancellationException] without invoking [block].
+ *
+ * This variant of [mutate] calls its [block] with a [receiver], removing the need to create
+ * an additional capturing lambda to invoke it with a receiver object. This can be used to
+ * expose a mutable scope to the provided [block] while leaving the rest of the state object
+ * read-only. For example:
+ *
+ * @param receiver the receiver `this` that [block] will be called with
+ * @param priority the priority of this mutation; [MutatePriority.Default] by default.
+ * Higher priority mutations will interrupt lower priority mutations.
+ * @param block mutation code to run mutually exclusive with any other call to [mutate],
+ * [mutateWith] or [tryMutate].
+ */
+ suspend fun mutateWith(
+ receiver: T,
+ priority: MutatePriority = MutatePriority.Default,
+ block: suspend T.() -> R
+ ) = coroutineScope {
+ val mutator = Mutator(priority, coroutineContext[Job]!!)
+
+ tryMutateOrCancel(mutator)
+
+ mutex.withLock {
+ try {
+ receiver.block()
+ } finally {
+ currentMutator.compareAndSet(mutator, null)
+ }
+ }
+ }
+
+ /**
+ * Attempt to mutate synchronously if there is no other active caller.
+ * If there is no other active caller, the [block] will be executed in a lock. If there is
+ * another active caller, this method will return false, indicating that the active caller
+ * needs to be cancelled through a [mutate] or [mutateWith] call with an equal or higher
+ * mutation priority.
+ *
+ * Calls to [mutate] and [mutateWith] will suspend until execution of the [block] has finished.
+ *
+ * @param block mutation code to run mutually exclusive with any other call to [mutate],
+ * [mutateWith] or [tryMutate].
+ * @return true if the [block] was executed, false if there was another active caller and the
+ * [block] was not executed.
+ */
+ fun tryMutate(block: () -> Unit): Boolean {
+ val didLock = mutex.tryLock()
+ if (didLock) {
+ try {
+ block()
+ } finally {
+ mutex.unlock()
+ }
+ }
+ return didLock
+ }
+}
diff --git a/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/screens/bottomsheet/SheetDefaults.kt b/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/screens/bottomsheet/SheetDefaults.kt
new file mode 100644
index 000000000..e75f08f40
--- /dev/null
+++ b/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/screens/bottomsheet/SheetDefaults.kt
@@ -0,0 +1,340 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ *
+ * Modifications copyright (C) 2023 Esri Inc
+ */
+
+package com.arcgismaps.toolkit.featureformsapp.screens.bottomsheet
+
+import androidx.compose.foundation.gestures.Orientation
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.saveable.Saver
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
+import androidx.compose.ui.input.nestedscroll.NestedScrollSource
+import androidx.compose.ui.unit.Velocity
+import androidx.compose.ui.unit.dp
+import kotlinx.coroutines.CancellationException
+
+/**
+ * State of a sheet composable, such as [ModalBottomSheet]
+ *
+ * Contains states relating to it's swipe position as well as animations between state values.
+ *
+ * @param skipPartiallyExpanded Whether the partially expanded state, if the sheet is large
+ * enough, should be skipped. If true, the sheet will always expand to the [Expanded] state and move
+ * to the [Hidden] state if available when hiding the sheet, either programmatically or by user
+ * interaction.
+ * @param initialValue The initial value of the state.
+ * @param confirmValueChange Optional callback invoked to confirm or veto a pending state change.
+ * @param skipHiddenState Whether the hidden state should be skipped. If true, the sheet will always
+ * expand to the [Expanded] state and move to the [PartiallyExpanded] if available, either
+ * programmatically or by user interaction.
+ */
+@Stable
+@ExperimentalMaterial3Api
+class SheetState(
+ internal val skipPartiallyExpanded: Boolean,
+ initialValue: SheetValue = SheetValue.PartiallyExpanded,
+ confirmValueChange: (SheetValue) -> Boolean = { true },
+ internal val skipHiddenState: Boolean = false,
+) {
+ init {
+ if (skipPartiallyExpanded) {
+ require(initialValue != SheetValue.PartiallyExpanded) {
+ "The initial value must not be set to PartiallyExpanded if skipPartiallyExpanded " +
+ "is set to true."
+ }
+ }
+ if (skipHiddenState) {
+ require(initialValue != SheetValue.Hidden) {
+ "The initial value must not be set to Hidden if skipHiddenState is set to true."
+ }
+ }
+ }
+
+ /**
+ * The current value of the state.
+ *
+ * If no swipe or animation is in progress, this corresponds to the state the bottom sheet is
+ * currently in. If a swipe or an animation is in progress, this corresponds the state the sheet
+ * was in before the swipe or animation started.
+ */
+
+ val currentValue: SheetValue get() = swipeableState.currentValue
+
+ /**
+ * The target value of the bottom sheet state.
+ *
+ * If a swipe is in progress, this is the value that the sheet would animate to if the
+ * swipe finishes. If an animation is running, this is the target value of that animation.
+ * Finally, if no swipe or animation is in progress, this is the same as the [currentValue].
+ */
+ val targetValue: SheetValue get() = swipeableState.targetValue
+
+ /**
+ * Whether the modal bottom sheet is visible.
+ */
+ val isVisible: Boolean
+ get() = swipeableState.currentValue != SheetValue.Hidden
+
+ /**
+ * Require the current offset (in pixels) of the bottom sheet.
+ *
+ * The offset will be initialized during the first measurement phase of the provided sheet
+ * content.
+ *
+ * These are the phases:
+ * Composition { -> Effects } -> Layout { Measurement -> Placement } -> Drawing
+ *
+ * During the first composition, an [IllegalStateException] is thrown. In subsequent
+ * compositions, the offset will be derived from the anchors of the previous pass. Always prefer
+ * accessing the offset from a LaunchedEffect as it will be scheduled to be executed the next
+ * frame, after layout.
+ *
+ * @throws IllegalStateException If the offset has not been initialized yet
+ */
+ fun requireOffset(): Float = swipeableState.requireOffset()
+
+ /**
+ * Whether the sheet has an expanded state defined.
+ */
+
+ val hasExpandedState: Boolean
+ get() = swipeableState.hasAnchorForValue(SheetValue.Expanded)
+
+ /**
+ * Whether the modal bottom sheet has a partially expanded state defined.
+ */
+ val hasPartiallyExpandedState: Boolean
+ get() = swipeableState.hasAnchorForValue(SheetValue.PartiallyExpanded)
+
+ /**
+ * Fully expand the bottom sheet with animation and suspend until it is fully expanded or
+ * animation has been cancelled.
+ * *
+ * @throws [CancellationException] if the animation is interrupted
+ */
+ suspend fun expand() {
+ swipeableState.animateTo(SheetValue.Expanded)
+ }
+
+ /**
+ * Animate the bottom sheet and suspend until it is partially expanded or animation has been
+ * cancelled.
+ * @throws [CancellationException] if the animation is interrupted
+ * @throws [IllegalStateException] if [skipPartiallyExpanded] is set to true
+ */
+ suspend fun partialExpand() {
+ check(!skipPartiallyExpanded) {
+ "Attempted to animate to partial expanded when skipPartiallyExpanded was enabled. Set" +
+ " skipPartiallyExpanded to false to use this function."
+ }
+ animateTo(SheetValue.PartiallyExpanded)
+ }
+
+ /**
+ * Expand the bottom sheet with animation and suspend until it is [PartiallyExpanded] if defined
+ * else [Expanded].
+ * @throws [CancellationException] if the animation is interrupted
+ */
+ suspend fun show() {
+ val targetValue = when {
+ hasPartiallyExpandedState -> SheetValue.PartiallyExpanded
+ else -> SheetValue.Expanded
+ }
+ animateTo(targetValue)
+ }
+
+ /**
+ * Hide the bottom sheet with animation and suspend until it is fully hidden or animation has
+ * been cancelled.
+ * @throws [CancellationException] if the animation is interrupted
+ */
+ suspend fun hide() {
+ check(!skipHiddenState) {
+ "Attempted to animate to hidden when skipHiddenState was enabled. Set skipHiddenState" +
+ " to false to use this function."
+ }
+ animateTo(SheetValue.Hidden)
+ }
+
+ /**
+ * Animate to a [targetValue].
+ * If the [targetValue] is not in the set of anchors, the [currentValue] will be updated to the
+ * [targetValue] without updating the offset.
+ *
+ * @throws CancellationException if the interaction interrupted by another interaction like a
+ * gesture interaction or another programmatic interaction like a [animateTo] or [snapTo] call.
+ *
+ * @param targetValue The target value of the animation
+ */
+ internal suspend fun animateTo(
+ targetValue: SheetValue,
+ velocity: Float = swipeableState.lastVelocity
+ ) {
+ swipeableState.animateTo(targetValue, velocity)
+ }
+
+ /**
+ * Snap to a [targetValue] without any animation.
+ *
+ * @throws CancellationException if the interaction interrupted by another interaction like a
+ * gesture interaction or another programmatic interaction like a [animateTo] or [snapTo] call.
+ *
+ * @param targetValue The target value of the animation
+ */
+ internal suspend fun snapTo(targetValue: SheetValue) {
+ swipeableState.snapTo(targetValue)
+ }
+
+ /**
+ * Find the closest anchor taking into account the velocity and settle at it with an animation.
+ */
+ internal suspend fun settle(velocity: Float) {
+ swipeableState.settle(velocity)
+ }
+
+ internal var swipeableState = SwipeableV2State(
+ initialValue = initialValue,
+ animationSpec = SwipeableV2Defaults.AnimationSpec,
+ confirmValueChange = confirmValueChange,
+ )
+
+ internal val offset: Float? get() = swipeableState.offset
+
+ companion object {
+ /**
+ * The default [Saver] implementation for [SheetState].
+ */
+ fun Saver(
+ skipPartiallyExpanded: Boolean,
+ confirmValueChange: (SheetValue) -> Boolean
+ ) = Saver(
+ save = { it.currentValue },
+ restore = { savedValue ->
+ SheetState(skipPartiallyExpanded, savedValue, confirmValueChange)
+ }
+ )
+ }
+}
+
+/**
+ * Possible values of [SheetState].
+ */
+@ExperimentalMaterial3Api
+enum class SheetValue {
+ /**
+ * The sheet is not visible.
+ */
+ Hidden,
+
+ /**
+ * The sheet is visible at full height.
+ */
+ Expanded,
+
+ /**
+ * The sheet is partially visible.
+ */
+ PartiallyExpanded,
+
+ /**
+ * The sheet is visible only up to its peek height.
+ */
+ Minimized
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+internal fun ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection(
+ sheetState: SheetState,
+ orientation: Orientation,
+ onFling: (velocity: Float) -> Unit
+): NestedScrollConnection = object : NestedScrollConnection {
+ override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
+ val delta = available.toFloat()
+ return if (delta < 0 && source == NestedScrollSource.Drag) {
+ sheetState.swipeableState.dispatchRawDelta(delta).toOffset()
+ } else {
+ Offset.Zero
+ }
+ }
+
+ override fun onPostScroll(
+ consumed: Offset,
+ available: Offset,
+ source: NestedScrollSource
+ ): Offset {
+ return if (source == NestedScrollSource.Drag) {
+ sheetState.swipeableState.dispatchRawDelta(available.toFloat()).toOffset()
+ } else {
+ Offset.Zero
+ }
+ }
+
+ override suspend fun onPreFling(available: Velocity): Velocity {
+ val toFling = available.toFloat()
+ val currentOffset = sheetState.requireOffset()
+ return if (toFling < 0 && currentOffset > sheetState.swipeableState.minOffset) {
+ onFling(toFling)
+ // since we go to the anchor with tween settling, consume all for the best UX
+ available
+ } else {
+ Velocity.Zero
+ }
+ }
+
+ override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
+ onFling(available.toFloat())
+ return available
+ }
+
+ private fun Float.toOffset(): Offset = Offset(
+ x = if (orientation == Orientation.Horizontal) this else 0f,
+ y = if (orientation == Orientation.Vertical) this else 0f
+ )
+
+ @JvmName("velocityToFloat")
+ private fun Velocity.toFloat() = if (orientation == Orientation.Horizontal) x else y
+
+ @JvmName("offsetToFloat")
+ private fun Offset.toFloat(): Float = if (orientation == Orientation.Horizontal) x else y
+}
+
+@Composable
+@ExperimentalMaterial3Api
+internal fun rememberSheetState(
+ skipPartiallyExpanded: Boolean = false,
+ confirmValueChange: (SheetValue) -> Boolean = { true },
+ initialValue: SheetValue = SheetValue.PartiallyExpanded,
+ skipHiddenState: Boolean = false,
+): SheetState {
+ return rememberSaveable(
+ skipPartiallyExpanded, confirmValueChange,
+ saver = SheetState.Saver(
+ skipPartiallyExpanded = skipPartiallyExpanded,
+ confirmValueChange = confirmValueChange
+ )
+ ) {
+ SheetState(skipPartiallyExpanded, initialValue, confirmValueChange, skipHiddenState)
+ }
+}
+
+private val DragHandleVerticalPadding = 22.dp
+internal val BottomSheetMaxWidth = 640.dp
diff --git a/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/screens/bottomsheet/SheetLayout.kt b/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/screens/bottomsheet/SheetLayout.kt
new file mode 100644
index 000000000..a6a283797
--- /dev/null
+++ b/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/screens/bottomsheet/SheetLayout.kt
@@ -0,0 +1,92 @@
+/*
+ * Copyright 2023 Esri
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.arcgismaps.toolkit.featureformsapp.screens.bottomsheet
+
+import android.content.res.Configuration
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.SubcomposeLayout
+import androidx.compose.ui.platform.LocalConfiguration
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.Constraints.Companion.Infinity
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.window.core.layout.WindowSizeClass
+import androidx.window.core.layout.WindowWidthSizeClass
+import kotlin.math.roundToInt
+
+/**
+ * A custom layout that places the [sheetContent] in the center of the screen if the current
+ * orientation is portrait. The [sheetContent] is shown as a side sheet on the right side of the
+ * screen if the orientation is landscape and the [WindowSizeClass.windowWidthSizeClass] is
+ * [WindowWidthSizeClass.EXPANDED] as provided by [windowSizeClass].
+ *
+ * @param windowSizeClass The current [WindowSizeClass].
+ * @param sheetOffsetY An offset in pixels for the [sheetContent] in the Y axis.
+ * @param modifier The [Modifier]
+ * @param maxWidth A maximum width if specified will be enforced only when the orientation is portrait
+ * and the [WindowSizeClass.windowWidthSizeClass] is not [WindowWidthSizeClass.EXPANDED]. Otherwise
+ * this is set to [Infinity] which indicates to the maximum width available.
+ * @param sheetContent The sheet content lambda which is passed the width and height of the layout in pixels.
+ */
+@Composable
+fun SheetLayout(
+ windowSizeClass: WindowSizeClass,
+ sheetOffsetY: () -> Float,
+ modifier: Modifier = Modifier,
+ maxWidth: Dp = Infinity.dp,
+ sheetContent: @Composable (Int, Int) -> Unit
+) {
+ val configuration = LocalConfiguration.current
+ val showAsSideSheet = windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.EXPANDED
+ && configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
+ // convert the max width from dp into pixels
+ val maxWidthInPx = with(LocalDensity.current) {
+ maxWidth.roundToPx()
+ }
+
+ SubcomposeLayout(modifier = modifier) { constraints ->
+ val layoutWidth = if (showAsSideSheet) {
+ // set the max width to 40% of the available size
+ constraints.maxWidth * 2/5
+ } else {
+ // set the max width to the lesser of the available size or the maxWidth
+ Integer.min(constraints.maxWidth, maxWidthInPx)
+ }
+ // use all the available height
+ val layoutHeight = constraints.maxHeight
+ // measure the sheet content with the constraints
+ val sheetPlaceable = subcompose(0) {
+ sheetContent(layoutWidth, layoutHeight)
+ }[0].measure(
+ constraints.copy(
+ maxWidth = layoutWidth,
+ maxHeight = layoutHeight
+ )
+ )
+ val sheetOffsetX = if (showAsSideSheet) {
+ // anchor on right edge of the screen
+ Integer.max(0, (constraints.maxWidth - sheetPlaceable.width))
+ } else {
+ // anchor in the center of the screen
+ Integer.max(0, (constraints.maxWidth - sheetPlaceable.width) / 2)
+ }
+ layout(layoutWidth, layoutHeight) {
+ sheetPlaceable.place(sheetOffsetX, sheetOffsetY().roundToInt())
+ }
+ }
+}
diff --git a/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/screens/bottomsheet/Strings.kt b/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/screens/bottomsheet/Strings.kt
new file mode 100644
index 000000000..6a011f0c5
--- /dev/null
+++ b/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/screens/bottomsheet/Strings.kt
@@ -0,0 +1,64 @@
+/*
+ * Copyright 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ *
+ * Modifications copyright (C) 2023 Esri Inc
+ */
+
+package com.arcgismaps.toolkit.featureformsapp.screens.bottomsheet
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Immutable
+import androidx.compose.ui.R
+import androidx.compose.ui.platform.LocalConfiguration
+import androidx.compose.ui.platform.LocalContext
+
+@Immutable
+@JvmInline
+value class Strings private constructor(
+ @Suppress("unused") private val value: Int = nextId()
+) {
+ companion object {
+ private var id = 0
+ private fun nextId() = id++
+
+ val NavigationMenu = Strings()
+ val CloseDrawer = Strings()
+ val CloseSheet = Strings()
+ val DefaultErrorMessage = Strings()
+ val ExposedDropdownMenu = Strings()
+ val SliderRangeStart = Strings()
+ val SliderRangeEnd = Strings()
+ val BottomSheetPartialExpandDescription = Strings()
+ val BottomSheetDismissDescription = Strings()
+ val BottomSheetExpandDescription = Strings()
+ }
+}
+
+@Composable
+fun getString(string: Strings): String {
+ LocalConfiguration.current
+ val resources = LocalContext.current.resources
+ return when (string) {
+ Strings.NavigationMenu -> resources.getString(R.string.navigation_menu)
+ Strings.CloseDrawer -> resources.getString(R.string.close_drawer)
+ Strings.CloseSheet -> resources.getString(R.string.close_sheet)
+ Strings.DefaultErrorMessage -> resources.getString(R.string.default_error_message)
+ Strings.ExposedDropdownMenu -> resources.getString(R.string.dropdown_menu)
+ Strings.SliderRangeStart -> resources.getString(R.string.range_start)
+ Strings.SliderRangeEnd -> resources.getString(R.string.range_end)
+ else -> ""
+ }
+}
diff --git a/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/screens/bottomsheet/SwipeableV2.kt b/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/screens/bottomsheet/SwipeableV2.kt
new file mode 100644
index 000000000..ebcb8ee53
--- /dev/null
+++ b/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/screens/bottomsheet/SwipeableV2.kt
@@ -0,0 +1,693 @@
+/*
+ * Copyright 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ *
+ * Modifications copyright (C) 2023 Esri Inc
+ */
+
+package com.arcgismaps.toolkit.featureformsapp.screens.bottomsheet
+
+import androidx.compose.animation.core.AnimationSpec
+import androidx.compose.animation.core.SpringSpec
+import androidx.compose.animation.core.animate
+import androidx.compose.foundation.MutatePriority
+import androidx.compose.foundation.gestures.DragScope
+import androidx.compose.foundation.gestures.DraggableState
+import androidx.compose.foundation.gestures.Orientation
+import androidx.compose.foundation.gestures.draggable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.offset
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.Saver
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.LayoutModifier
+import androidx.compose.ui.layout.Measurable
+import androidx.compose.ui.layout.MeasureResult
+import androidx.compose.ui.layout.MeasureScope
+import androidx.compose.ui.layout.OnRemeasuredModifier
+import androidx.compose.ui.platform.InspectorInfo
+import androidx.compose.ui.platform.InspectorValueInfo
+import androidx.compose.ui.platform.debugInspectorInfo
+import androidx.compose.ui.unit.Constraints
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.unit.dp
+import kotlin.math.abs
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.launch
+
+/**
+ * Enable swipe gestures between a set of predefined values.
+ *
+ * When a swipe is detected, the offset of the [SwipeableV2State] will be updated with the swipe
+ * delta. You should use this offset to move your content accordingly (see [Modifier.offset]).
+ * When the swipe ends, the offset will be animated to one of the anchors and when that anchor is
+ * reached, the value of the [SwipeableV2State] will also be updated to the value corresponding to
+ * the new anchor.
+ *
+ * Swiping is constrained between the minimum and maximum anchors.
+ *
+ * @param state The associated [SwipeableV2State].
+ * @param orientation The orientation in which the swipeable can be swiped.
+ * @param enabled Whether this [swipeableV2] is enabled and should react to the user's input.
+ * @param reverseDirection Whether to reverse the direction of the swipe, so a top to bottom
+ * swipe will behave like bottom to top, and a left to right swipe will behave like right to left.
+ * @param interactionSource Optional [MutableInteractionSource] that will passed on to
+ * the internal [Modifier.draggable].
+ */
+@ExperimentalMaterial3Api
+internal fun Modifier.swipeableV2(
+ state: SwipeableV2State,
+ orientation: Orientation,
+ enabled: Boolean = true,
+ reverseDirection: Boolean = false,
+ interactionSource: MutableInteractionSource? = null
+) = draggable(
+ state = state.swipeDraggableState,
+ orientation = orientation,
+ enabled = enabled,
+ interactionSource = interactionSource,
+ reverseDirection = reverseDirection,
+ startDragImmediately = state.isAnimationRunning,
+ onDragStopped = { velocity -> launch { state.settle(velocity) } }
+)
+
+/**
+ * Define anchor points for a given [SwipeableV2State] based on this node's layout size and update
+ * the state with them.
+ *
+ * @param state The associated [SwipeableV2State]
+ * @param possibleValues All possible values the [SwipeableV2State] could be in.
+ * @param anchorChangeHandler A callback to be invoked when the anchors have changed,
+ * `null` by default. Components with custom reconciliation logic should implement this callback,
+ * i.e. to re-target an in-progress animation.
+ * @param calculateAnchor This method will be invoked to calculate the position of all
+ * [possibleValues], given this node's layout size. Return the anchor's offset from the initial
+ * anchor, or `null` to indicate that a value does not have an anchor.
+ */
+@ExperimentalMaterial3Api
+internal fun Modifier.swipeAnchors(
+ state: SwipeableV2State,
+ possibleValues: Set,
+ anchorChangeHandler: AnchorChangeHandler? = null,
+ calculateAnchor: (value: T, layoutSize: IntSize) -> Float?,
+) = this.then(SwipeAnchorsModifier(
+ onDensityChanged = { state.density = it },
+ onSizeChanged = { layoutSize ->
+ val previousAnchors = state.anchors
+ val newAnchors = mutableMapOf()
+ possibleValues.forEach {
+ val anchorValue = calculateAnchor(it, layoutSize)
+ if (anchorValue != null) {
+ newAnchors[it] = anchorValue
+ }
+ }
+ if (previousAnchors != newAnchors) {
+ val previousTarget = state.targetValue
+ val stateRequiresCleanup = state.updateAnchors(newAnchors)
+ if (stateRequiresCleanup) {
+ anchorChangeHandler?.onAnchorsChanged(
+ previousTarget,
+ previousAnchors,
+ newAnchors
+ )
+ }
+ }
+ },
+ inspectorInfo = debugInspectorInfo {
+ name = "swipeAnchors"
+ properties["state"] = state
+ properties["possibleValues"] = possibleValues
+ properties["anchorChangeHandler"] = anchorChangeHandler
+ properties["calculateAnchor"] = calculateAnchor
+ }
+))
+
+/**
+ * State of the [swipeableV2] modifier.
+ *
+ * This contains necessary information about any ongoing swipe or animation and provides methods
+ * to change the state either immediately or by starting an animation. To create and remember a
+ * [SwipeableV2State] use [rememberSwipeableV2State].
+ *
+ * @param initialValue The initial value of the state.
+ * @param animationSpec The default animation that will be used to animate to a new state.
+ * @param confirmValueChange Optional callback invoked to confirm or veto a pending state change.
+ * @param positionalThreshold The positional threshold to be used when calculating the target state
+ * while a swipe is in progress and when settling after the swipe ends. This is the distance from
+ * the start of a transition. It will be, depending on the direction of the interaction, added or
+ * subtracted from/to the origin offset. It should always be a positive value. See the
+ * [fractionalPositionalThreshold] and [fixedPositionalThreshold] methods.
+ * @param velocityThreshold The velocity threshold (in dp per second) that the end velocity has to
+ * exceed in order to animate to the next state, even if the [positionalThreshold] has not been
+ * reached.
+ */
+@Stable
+@ExperimentalMaterial3Api
+internal class SwipeableV2State(
+ initialValue: T,
+ internal val animationSpec: AnimationSpec = SwipeableV2Defaults.AnimationSpec,
+ internal val confirmValueChange: (newValue: T) -> Boolean = { true },
+ internal val positionalThreshold: Density.(totalDistance: Float) -> Float =
+ SwipeableV2Defaults.PositionalThreshold,
+ internal val velocityThreshold: Dp = SwipeableV2Defaults.VelocityThreshold,
+) {
+
+ private val swipeMutex = InternalMutatorMutex()
+
+ internal val swipeDraggableState = object : DraggableState {
+ private val dragScope = object : DragScope {
+ override fun dragBy(pixels: Float) {
+ this@SwipeableV2State.dispatchRawDelta(pixels)
+ }
+ }
+
+ override suspend fun drag(
+ dragPriority: MutatePriority,
+ block: suspend DragScope.() -> Unit
+ ) {
+ swipe(dragPriority) { dragScope.block() }
+ }
+
+ override fun dispatchRawDelta(delta: Float) {
+ this@SwipeableV2State.dispatchRawDelta(delta)
+ }
+ }
+
+ /**
+ * The current value of the [SwipeableV2State].
+ */
+ var currentValue: T by mutableStateOf(initialValue)
+ private set
+
+ /**
+ * The target value. This is the closest value to the current offset (taking into account
+ * positional thresholds). If no interactions like animations or drags are in progress, this
+ * will be the current value.
+ */
+ val targetValue: T by derivedStateOf {
+ animationTarget ?: run {
+ val currentOffset = offset
+ if (currentOffset != null) {
+ computeTarget(currentOffset, currentValue, velocity = 0f)
+ } else currentValue
+ }
+ }
+
+ /**
+ * The current offset, or null if it has not been initialized yet.
+ *
+ * The offset will be initialized during the first measurement phase of the node that the
+ * [swipeableV2] modifier is attached to. These are the phases:
+ * Composition { -> Effects } -> Layout { Measurement -> Placement } -> Drawing
+ * During the first composition, the offset will be null. In subsequent compositions, the offset
+ * will be derived from the anchors of the previous pass.
+ * Always prefer accessing the offset from a LaunchedEffect as it will be scheduled to be
+ * executed the next frame, after layout.
+ *
+ * To guarantee stricter semantics, consider using [requireOffset].
+ */
+ @get:Suppress("AutoBoxing")
+ var offset: Float? by mutableStateOf(null)
+ private set
+
+ /**
+ * Require the current offset.
+ *
+ * @throws IllegalStateException If the offset has not been initialized yet
+ */
+ fun requireOffset(): Float = checkNotNull(offset) {
+ "The offset was read before being initialized. Did you access the offset in a phase " +
+ "before layout, like effects or composition?"
+ }
+
+ /**
+ * Whether an animation is currently in progress.
+ */
+ val isAnimationRunning: Boolean get() = animationTarget != null
+
+ /**
+ * The fraction of the progress going from [currentValue] to [targetValue], within [0f..1f]
+ * bounds.
+ */
+ /*@FloatRange(from = 0f, to = 1f)*/
+ val progress: Float by derivedStateOf {
+ val a = anchors[currentValue] ?: 0f
+ val b = anchors[targetValue] ?: 0f
+ val distance = abs(b - a)
+ if (distance > 1e-6f) {
+ val progress = (this.requireOffset() - a) / (b - a)
+ // If we are very close to 0f or 1f, we round to the closest
+ if (progress < 1e-6f) 0f else if (progress > 1 - 1e-6f) 1f else progress
+ } else 1f
+ }
+
+ /**
+ * The velocity of the last known animation. Gets reset to 0f when an animation completes
+ * successfully, but does not get reset when an animation gets interrupted.
+ * You can use this value to provide smooth reconciliation behavior when re-targeting an
+ * animation.
+ */
+ var lastVelocity: Float by mutableStateOf(0f)
+ private set
+
+ /**
+ * The minimum offset this state can reach. This will be the smallest anchor, or
+ * [Float.NEGATIVE_INFINITY] if the anchors are not initialized yet.
+ */
+ val minOffset by derivedStateOf { anchors.minOrNull() ?: Float.NEGATIVE_INFINITY }
+
+ /**
+ * The maximum offset this state can reach. This will be the biggest anchor, or
+ * [Float.POSITIVE_INFINITY] if the anchors are not initialized yet.
+ */
+ val maxOffset by derivedStateOf { anchors.maxOrNull() ?: Float.POSITIVE_INFINITY }
+
+ private var animationTarget: T? by mutableStateOf(null)
+
+ internal var anchors by mutableStateOf(emptyMap())
+
+ internal var density: Density? = null
+
+ /**
+ * Update the anchors.
+ * If the previous set of anchors was empty, attempt to update the offset to match the initial
+ * value's anchor.
+ *
+ * @return true if the state needs to be adjusted after updating the anchors, e.g. if the
+ * initial value is not found in the initial set of anchors. false if no further updates are
+ * needed.
+ */
+ internal fun updateAnchors(newAnchors: Map): Boolean {
+ val previousAnchorsEmpty = anchors.isEmpty()
+ anchors = newAnchors
+ val initialValueHasAnchor = if (previousAnchorsEmpty) {
+ val initialValue = currentValue
+ val initialValueAnchor = anchors[initialValue]
+ val initialValueHasAnchor = initialValueAnchor != null
+ if (initialValueHasAnchor) trySnapTo(initialValue)
+ initialValueHasAnchor
+ } else true
+ return !initialValueHasAnchor || !previousAnchorsEmpty
+ }
+
+ /**
+ * Whether the [value] has an anchor associated with it.
+ */
+ fun hasAnchorForValue(value: T): Boolean = anchors.containsKey(value)
+
+ /**
+ * Snap to a [targetValue] without any animation.
+ * If the [targetValue] is not in the set of anchors, the [currentValue] will be updated to the
+ * [targetValue] without updating the offset.
+ *
+ * @throws CancellationException if the interaction interrupted by another interaction like a
+ * gesture interaction or another programmatic interaction like a [animateTo] or [snapTo] call.
+ *
+ * @param targetValue The target value of the animation
+ */
+ suspend fun snapTo(targetValue: T) {
+ swipe { snap(targetValue) }
+ }
+
+ /**
+ * Animate to a [targetValue].
+ * If the [targetValue] is not in the set of anchors, the [currentValue] will be updated to the
+ * [targetValue] without updating the offset.
+ *
+ * @throws CancellationException if the interaction interrupted by another interaction like a
+ * gesture interaction or another programmatic interaction like a [animateTo] or [snapTo] call.
+ *
+ * @param targetValue The target value of the animation
+ * @param velocity The velocity the animation should start with, [lastVelocity] by default
+ */
+ suspend fun animateTo(
+ targetValue: T,
+ velocity: Float = lastVelocity,
+ ) {
+ val targetOffset = anchors[targetValue]
+ if (targetOffset != null) {
+ try {
+ swipe {
+ animationTarget = targetValue
+ var prev = offset ?: 0f
+ animate(prev, targetOffset, velocity, animationSpec) { value, velocity ->
+ // Our onDrag coerces the value within the bounds, but an animation may
+ // overshoot, for example a spring animation or an overshooting interpolator
+ // We respect the user's intention and allow the overshoot, but still use
+ // DraggableState's drag for its mutex.
+ offset = value
+ prev = value
+ lastVelocity = velocity
+ }
+ lastVelocity = 0f
+ }
+ } finally {
+ animationTarget = null
+ val endOffset = requireOffset()
+ val endState = anchors
+ .entries
+ .firstOrNull { (_, anchorOffset) -> abs(anchorOffset - endOffset) < 0.5f }
+ ?.key
+ this.currentValue = endState ?: currentValue
+ }
+ } else {
+ currentValue = targetValue
+ }
+ }
+
+ /**
+ * Find the closest anchor taking into account the velocity and settle at it with an animation.
+ */
+ suspend fun settle(velocity: Float) {
+ val previousValue = this.currentValue
+ val targetValue = computeTarget(
+ offset = requireOffset(),
+ currentValue = previousValue,
+ velocity = velocity
+ )
+ if (confirmValueChange(targetValue)) {
+ animateTo(targetValue, velocity)
+ } else {
+ // If the user vetoed the state change, rollback to the previous state.
+ animateTo(previousValue, velocity)
+ }
+ }
+
+ /**
+ * Swipe by the [delta], coerce it in the bounds and dispatch it to the [SwipeableV2State].
+ *
+ * @return The delta the consumed by the [SwipeableV2State]
+ */
+ fun dispatchRawDelta(delta: Float): Float {
+ val currentDragPosition = offset ?: 0f
+ val potentiallyConsumed = currentDragPosition + delta
+ val clamped = potentiallyConsumed.coerceIn(minOffset, maxOffset)
+ val deltaToConsume = clamped - currentDragPosition
+ if (abs(deltaToConsume) >= 0) {
+ offset = ((offset ?: 0f) + deltaToConsume).coerceIn(minOffset, maxOffset)
+ }
+ return deltaToConsume
+ }
+
+ private fun computeTarget(
+ offset: Float,
+ currentValue: T,
+ velocity: Float
+ ): T {
+ val currentAnchors = anchors
+ val currentAnchor = currentAnchors[currentValue]
+ val currentDensity = requireDensity()
+ val velocityThresholdPx = with(currentDensity) { velocityThreshold.toPx() }
+ return if (currentAnchor == offset || currentAnchor == null) {
+ currentValue
+ } else if (currentAnchor < offset) {
+ // Swiping from lower to upper (positive).
+ if (velocity >= velocityThresholdPx) {
+ currentAnchors.closestAnchor(offset, true)
+ } else {
+ val upper = currentAnchors.closestAnchor(offset, true)
+ val distance = abs(currentAnchors.getValue(upper) - currentAnchor)
+ val relativeThreshold = abs(positionalThreshold(currentDensity, distance))
+ val absoluteThreshold = abs(currentAnchor + relativeThreshold)
+ if (offset < absoluteThreshold) currentValue else upper
+ }
+ } else {
+ // Swiping from upper to lower (negative).
+ if (velocity <= -velocityThresholdPx) {
+ currentAnchors.closestAnchor(offset, false)
+ } else {
+ val lower = currentAnchors.closestAnchor(offset, false)
+ val distance = abs(currentAnchor - currentAnchors.getValue(lower))
+ val relativeThreshold = abs(positionalThreshold(currentDensity, distance))
+ val absoluteThreshold = abs(currentAnchor - relativeThreshold)
+ if (offset < 0) {
+ // For negative offsets, larger absolute thresholds are closer to lower anchors
+ // than smaller ones.
+ if (abs(offset) < absoluteThreshold) currentValue else lower
+ } else {
+ if (offset > absoluteThreshold) currentValue else lower
+ }
+ }
+ }
+ }
+
+ private fun requireDensity() = requireNotNull(density) {
+ "SwipeableState did not have a density attached. Are you using Modifier.swipeable with " +
+ "this=$this SwipeableState?"
+ }
+
+ private suspend fun swipe(
+ swipePriority: MutatePriority = MutatePriority.Default,
+ action: suspend () -> Unit
+ ): Unit = coroutineScope { swipeMutex.mutate(swipePriority, action) }
+
+ /**
+ * Attempt to snap synchronously. Snapping can happen synchronously when there is no other swipe
+ * transaction like a drag or an animation is progress. If there is another interaction in
+ * progress, the suspending [snapTo] overload needs to be used.
+ *
+ * @return true if the synchronous snap was successful, or false if we couldn't snap synchronous
+ */
+ internal fun trySnapTo(targetValue: T): Boolean = swipeMutex.tryMutate { snap(targetValue) }
+
+ private fun snap(targetValue: T) {
+ val targetOffset = anchors[targetValue]
+ if (targetOffset != null) {
+ dispatchRawDelta(targetOffset - (offset ?: 0f))
+ currentValue = targetValue
+ animationTarget = null
+ } else {
+ currentValue = targetValue
+ }
+ }
+
+ companion object {
+ /**
+ * The default [Saver] implementation for [SwipeableV2State].
+ */
+ @ExperimentalMaterial3Api
+ fun Saver(
+ animationSpec: AnimationSpec,
+ confirmValueChange: (T) -> Boolean,
+ positionalThreshold: Density.(distance: Float) -> Float,
+ velocityThreshold: Dp
+ ) = Saver, T>(
+ save = { it.currentValue },
+ restore = {
+ SwipeableV2State(
+ initialValue = it,
+ animationSpec = animationSpec,
+ confirmValueChange = confirmValueChange,
+ positionalThreshold = positionalThreshold,
+ velocityThreshold = velocityThreshold
+ )
+ }
+ )
+ }
+}
+
+/**
+ * Create and remember a [SwipeableV2State].
+ *
+ * @param initialValue The initial value.
+ * @param animationSpec The default animation that will be used to animate to a new value.
+ * @param confirmValueChange Optional callback invoked to confirm or veto a pending value change.
+ */
+@Composable
+@ExperimentalMaterial3Api
+internal fun rememberSwipeableV2State(
+ initialValue: T,
+ animationSpec: AnimationSpec = SwipeableV2Defaults.AnimationSpec,
+ confirmValueChange: (newValue: T) -> Boolean = { true }
+): SwipeableV2State {
+ return rememberSaveable(
+ initialValue, animationSpec, confirmValueChange,
+ saver = SwipeableV2State.Saver(
+ animationSpec = animationSpec,
+ confirmValueChange = confirmValueChange,
+ positionalThreshold = SwipeableV2Defaults.PositionalThreshold,
+ velocityThreshold = SwipeableV2Defaults.VelocityThreshold
+ ),
+ ) {
+ SwipeableV2State(
+ initialValue = initialValue,
+ animationSpec = animationSpec,
+ confirmValueChange = confirmValueChange,
+ positionalThreshold = SwipeableV2Defaults.PositionalThreshold,
+ velocityThreshold = SwipeableV2Defaults.VelocityThreshold
+ )
+ }
+}
+
+/**
+ * Expresses a fixed positional threshold of [threshold] dp. This will be the distance from an
+ * anchor that needs to be reached for [SwipeableV2State] to settle to the next closest anchor.
+ *
+ * @see [fractionalPositionalThreshold] for a fractional positional threshold
+ */
+@ExperimentalMaterial3Api
+internal fun fixedPositionalThreshold(threshold: Dp): Density.(distance: Float) -> Float = {
+ threshold.toPx()
+}
+
+/**
+ * Expresses a relative positional threshold of the [fraction] of the distance to the closest anchor
+ * in the current direction. This will be the distance from an anchor that needs to be reached for
+ * [SwipeableV2State] to settle to the next closest anchor.
+ *
+ * @see [fixedPositionalThreshold] for a fixed positional threshold
+ */
+@ExperimentalMaterial3Api
+internal fun fractionalPositionalThreshold(
+ fraction: Float
+): Density.(distance: Float) -> Float = { distance -> distance * fraction }
+
+/**
+ * Contains useful defaults for [swipeableV2] and [SwipeableV2State].
+ */
+@Stable
+@ExperimentalMaterial3Api
+internal object SwipeableV2Defaults {
+ /**
+ * The default animation used by [SwipeableV2State].
+ */
+ @ExperimentalMaterial3Api
+ val AnimationSpec = SpringSpec()
+
+ /**
+ * The default velocity threshold (1.8 dp per millisecond) used by [rememberSwipeableV2State].
+ */
+ @ExperimentalMaterial3Api
+ val VelocityThreshold: Dp = 125.dp
+
+ /**
+ * The default positional threshold (56 dp) used by [rememberSwipeableV2State]
+ */
+ @ExperimentalMaterial3Api
+ val PositionalThreshold: Density.(totalDistance: Float) -> Float =
+ fixedPositionalThreshold(56.dp)
+
+ /**
+ * A [AnchorChangeHandler] implementation that attempts to reconcile an in-progress animation
+ * by re-targeting it if necessary or finding the closest new anchor.
+ * If the previous anchor is not in the new set of anchors, this implementation will snap to the
+ * closest anchor.
+ *
+ * Consider implementing a custom handler for more complex components like sheets.
+ * The [animate] and [snap] lambdas hoist the animation and snap logic. Usually these will just
+ * delegate to [SwipeableV2State].
+ *
+ * @param state The [SwipeableV2State] the change handler will read from
+ * @param animate A lambda that gets invoked to start an animation to a new target
+ * @param snap A lambda that gets invoked to snap to a new target
+ */
+ @ExperimentalMaterial3Api
+ internal fun ReconcileAnimationOnAnchorChangeHandler(
+ state: SwipeableV2State,
+ animate: (target: T, velocity: Float) -> Unit,
+ snap: (target: T) -> Unit
+ ) = AnchorChangeHandler { previousTarget, previousAnchors, newAnchors ->
+ val previousTargetOffset = previousAnchors[previousTarget]
+ val newTargetOffset = newAnchors[previousTarget]
+ if (previousTargetOffset != newTargetOffset) {
+ if (newTargetOffset != null) {
+ animate(previousTarget, state.lastVelocity)
+ } else {
+ snap(newAnchors.closestAnchor(offset = state.requireOffset()))
+ }
+ }
+ }
+}
+
+/**
+ * Defines a callback that is invoked when the anchors have changed.
+ *
+ * Components with custom reconciliation logic should implement this callback, for example to
+ * re-target an in-progress animation when the anchors change.
+ *
+ * @see SwipeableV2Defaults.ReconcileAnimationOnAnchorChangeHandler for a default implementation
+ */
+@ExperimentalMaterial3Api
+internal fun interface AnchorChangeHandler {
+
+ /**
+ * Callback that is invoked when the anchors have changed, after the [SwipeableV2State] has been
+ * updated with them. Use this hook to re-launch animations or interrupt them if needed.
+ *
+ * @param previousTargetValue The target value before the anchors were updated
+ * @param previousAnchors The previously set anchors
+ * @param newAnchors The newly set anchors
+ */
+ fun onAnchorsChanged(
+ previousTargetValue: T,
+ previousAnchors: Map,
+ newAnchors: Map
+ )
+}
+
+@Stable
+private class SwipeAnchorsModifier(
+ private val onDensityChanged: (density: Density) -> Unit,
+ private val onSizeChanged: (layoutSize: IntSize) -> Unit,
+ inspectorInfo: InspectorInfo.() -> Unit,
+) : LayoutModifier, OnRemeasuredModifier, InspectorValueInfo(inspectorInfo) {
+
+ private var lastDensity: Float = -1f
+ private var lastFontScale: Float = -1f
+
+ override fun MeasureScope.measure(
+ measurable: Measurable,
+ constraints: Constraints
+ ): MeasureResult {
+ if (density != lastDensity || fontScale != lastFontScale) {
+ onDensityChanged(Density(density, fontScale))
+ lastDensity = density
+ lastFontScale = fontScale
+ }
+ val placeable = measurable.measure(constraints)
+ return layout(placeable.width, placeable.height) { placeable.place(0, 0) }
+ }
+
+ override fun onRemeasured(size: IntSize) {
+ onSizeChanged(size)
+ }
+
+ override fun toString() = "SwipeAnchorsModifierImpl(updateDensity=$onDensityChanged, " +
+ "onSizeChanged=$onSizeChanged)"
+}
+
+private fun Map.closestAnchor(
+ offset: Float = 0f,
+ searchUpwards: Boolean = false
+): T {
+ require(isNotEmpty()) { "The anchors were empty when trying to find the closest anchor" }
+ return minBy { (_, anchor) ->
+ val delta = if (searchUpwards) anchor - offset else offset - anchor
+ if (delta < 0) Float.POSITIVE_INFINITY else delta
+ }.key
+}
+
+private fun Map.minOrNull() = minOfOrNull { (_, offset) -> offset }
+private fun Map.maxOrNull() = maxOfOrNull { (_, offset) -> offset }
diff --git a/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/screens/browse/MapListScreen.kt b/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/screens/browse/MapListScreen.kt
new file mode 100644
index 000000000..3a65ca8cc
--- /dev/null
+++ b/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/screens/browse/MapListScreen.kt
@@ -0,0 +1,409 @@
+package com.arcgismaps.toolkit.featureformsapp.screens.browse
+
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.ExperimentalAnimationApi
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.with
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.layout.wrapContentSize
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.AccountCircle
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.ExitToApp
+import androidx.compose.material.icons.filled.Refresh
+import androidx.compose.material.icons.outlined.Close
+import androidx.compose.material.icons.outlined.Search
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextField
+import androidx.compose.material3.TextFieldDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.DpOffset
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import coil.compose.AsyncImage
+import com.arcgismaps.toolkit.featureformsapp.AnimatedLoading
+import com.arcgismaps.toolkit.featureformsapp.R
+import java.time.Instant
+import java.time.ZoneId
+import java.time.format.DateTimeFormatter
+import java.util.Locale
+
+/**
+ * Displays a list of PortalItems using the [mapListViewModel]. Provides a callback [onItemClick]
+ * when an item is tapped.
+ */
+@OptIn(ExperimentalAnimationApi::class)
+@Composable
+fun MapListScreen(
+ modifier: Modifier = Modifier,
+ mapListViewModel: MapListViewModel = hiltViewModel(),
+ onItemClick: (String) -> Unit = {}
+) {
+ val uiState by mapListViewModel.uiState.collectAsState()
+ val lazyListState = rememberLazyListState()
+ var showSignOutProgress by rememberSaveable {
+ mutableStateOf(false)
+ }
+ Box(modifier = modifier.fillMaxSize()) {
+ AppSearchBar(
+ uiState.searchText,
+ isLoading = uiState.isLoading,
+ username = mapListViewModel.getUsername(),
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ onQueryChange = mapListViewModel::filterPortalItems,
+ onRefresh = mapListViewModel::refresh,
+ onSignOut = {
+ showSignOutProgress = true
+ mapListViewModel.signOut()
+ }
+ )
+ // use a cross fade animation to show a loading indicator when the data is loading
+ // and transition to the list of portalItems once loaded
+ AnimatedContent(
+ targetState = uiState.isLoading,
+ modifier = Modifier.padding(top = 88.dp),
+ transitionSpec = {
+ fadeIn(animationSpec = tween(1000)) with fadeOut()
+ },
+ label = "list fade"
+ ) { state ->
+ when (state) {
+ true -> Box(modifier = modifier.fillMaxSize()) {
+ LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
+ Text(
+ text = "Loading...",
+ modifier = Modifier.align(Alignment.Center),
+ style = MaterialTheme.typography.titleMedium
+ )
+ }
+
+ false -> if (uiState.data.isNotEmpty()) {
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ state = lazyListState,
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ items(
+ uiState.data
+ ) { item ->
+ MapListItem(
+ title = item.portalItem.title,
+ lastModified = item.portalItem.modified?.format("MMM dd yyyy")
+ ?: "",
+ shareType = item.portalItem.access.encoding.uppercase(Locale.getDefault()),
+ thumbnailUri = item.thumbnailUri.ifEmpty { null },
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(100.dp)
+ ) {
+ onItemClick(item.portalItem.itemId)
+ }
+ }
+ }
+ } else if (!uiState.isLoading) {
+ Column(
+ modifier = Modifier.fillMaxSize(),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text(text = "Nothing to show.")
+ }
+ }
+ }
+ }
+ }
+ AnimatedLoading(
+ visibilityProvider = {
+ showSignOutProgress
+ },
+ modifier = Modifier.fillMaxSize(),
+ statusText = if (mapListViewModel.getUsername().isEmpty()) "Loading.." else "Signing out.."
+ )
+}
+
+/**
+ * A list item row for a PortalItem that shows the [title], [lastModified] and thumbnail. Provides
+ * an [onClick] callback when the item is tapped.
+ */
+@Composable
+fun MapListItem(
+ title: String,
+ lastModified: String,
+ shareType: String,
+ modifier: Modifier = Modifier,
+ thumbnailUri: String? = null,
+ onClick: () -> Unit = {}
+) {
+ Row(
+ modifier = modifier.clickable { onClick() },
+ horizontalArrangement = Arrangement.Start,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Spacer(modifier = Modifier.width(20.dp))
+ Box {
+ thumbnailUri?.let {
+ AsyncImage(
+ model = it,
+ contentDescription = null,
+ modifier = Modifier
+ .fillMaxHeight(0.8f)
+ .aspectRatio(16 / 9f)
+ .clip(RoundedCornerShape(15.dp)),
+ contentScale = ContentScale.Crop
+ )
+ } // if thumbnail is empty then use the default map placeholder
+ ?: Image(
+ painter = painterResource(id = R.drawable.ic_default_map),
+ contentDescription = null,
+ modifier = Modifier
+ .fillMaxHeight(0.8f)
+ .aspectRatio(16 / 9f)
+ .clip(RoundedCornerShape(15.dp)),
+ contentScale = ContentScale.Crop
+ )
+ Box(
+ modifier = Modifier
+ .padding(5.dp)
+ .clip(RoundedCornerShape(10.dp))
+ .wrapContentSize()
+ .background(MaterialTheme.colorScheme.primaryContainer)
+ ) {
+ Text(
+ text = shareType,
+ modifier = Modifier.padding(5.dp),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onPrimaryContainer
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.width(20.dp))
+ Column(verticalArrangement = Arrangement.spacedBy(5.dp)) {
+ Text(text = title, style = MaterialTheme.typography.bodyLarge)
+ Text(text = "Last Updated: $lastModified", style = MaterialTheme.typography.labelSmall)
+ }
+ }
+}
+
+@Composable
+fun AppSearchBar(
+ query: String,
+ isLoading: Boolean,
+ username: String,
+ modifier: Modifier = Modifier,
+ onQueryChange: (String) -> Unit = {},
+ onRefresh: (Boolean) -> Unit = {},
+ onSignOut: () -> Unit = {}
+) {
+ val focusManager = LocalFocusManager.current
+ var active by remember { mutableStateOf(false) }
+ var expanded by remember { mutableStateOf(false) }
+
+ TextField(
+ value = query,
+ onValueChange = {
+ onQueryChange(it)
+ },
+ modifier = modifier
+ .onFocusChanged {
+ if (it.hasFocus) {
+ active = true
+ }
+ }
+ .height(56.dp),
+ placeholder = {
+ Text(text = "Search Maps")
+ },
+ leadingIcon = {
+ Icon(imageVector = Icons.Outlined.Search, contentDescription = null)
+ },
+ trailingIcon = {
+ Row(
+ modifier = Modifier
+ .widthIn(80.dp)
+ .padding(end = 16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.End
+ ) {
+ if (query.isNotEmpty()) {
+ IconButton(onClick = { onQueryChange("") }) {
+ Icon(
+ imageVector = Icons.Outlined.Close,
+ contentDescription = null
+ )
+ }
+ }
+ IconButton(onClick = {
+ expanded = !expanded
+ active = false
+ }) {
+ Icon(
+ imageVector = Icons.Default.AccountCircle,
+ contentDescription = null,
+ modifier = Modifier.size(24.dp)
+ )
+ }
+ MaterialTheme(
+ shapes = MaterialTheme.shapes.copy(
+ extraSmall = RoundedCornerShape(
+ 16.dp
+ )
+ )
+ ) {
+ DropdownMenu(
+ expanded = expanded,
+ onDismissRequest = { expanded = false },
+ modifier = Modifier.widthIn(150.dp),
+ offset = DpOffset(0.dp, 10.dp)
+ ) {
+ DropdownMenuItem(
+ text = {
+ Text(
+ text = if (username.isEmpty()) {
+ "Not logged in"
+ } else {
+ "Logged in as $username"
+ }
+ )
+ },
+ onClick = { }
+ )
+ DropdownMenuItem(
+ text = { Text(text = "Refresh") },
+ enabled = !isLoading,
+ onClick = {
+ expanded = false
+ onRefresh(false)
+ },
+ leadingIcon = {
+ Icon(imageVector = Icons.Default.Refresh, contentDescription = null)
+ }
+ )
+ DropdownMenuItem(
+ text = { Text(text = "Clear Cache") },
+ enabled = !isLoading,
+ onClick = {
+ expanded = false
+ onRefresh(true)
+ },
+ leadingIcon = {
+ Icon(imageVector = Icons.Default.Delete, contentDescription = null)
+ }
+ )
+ DropdownMenuItem(
+ text = {
+ Text(
+ text = if (username.isEmpty()) {
+ "Sign In"
+ } else {
+ "Sign Out"
+ }
+ )
+ },
+ enabled = !isLoading,
+ onClick = {
+ expanded = false
+ onSignOut()
+ },
+ leadingIcon = {
+ Icon(
+ imageVector = Icons.Default.ExitToApp,
+ contentDescription = null
+ )
+ }
+ )
+ }
+ }
+ }
+ },
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
+ keyboardActions = KeyboardActions(onSearch = {
+ active = false
+ }),
+ shape = RoundedCornerShape(30.dp),
+ colors = TextFieldDefaults.colors(
+ focusedIndicatorColor = Color.Transparent,
+ unfocusedIndicatorColor = Color.Transparent
+ ),
+ textStyle = MaterialTheme.typography.titleMedium
+ )
+
+ LaunchedEffect(active) {
+ if (!active) {
+ focusManager.clearFocus()
+ }
+ }
+}
+
+/**
+ * Utility function to convert an Instant into a string based on [format]
+ */
+fun Instant.format(format: String): String =
+ DateTimeFormatter.ofPattern(format).withZone(ZoneId.systemDefault()).format(this)
+
+@Preview
+@Composable
+fun MapListItemPreview() {
+ MapListItem(
+ title = "Water Utility",
+ lastModified = "June 1 2023",
+ shareType = "Public",
+ modifier = Modifier.size(width = 485.dp, height = 100.dp)
+ )
+}
+
+@Composable
+@Preview
+fun AppBarPreview() {
+ AppSearchBar("", false, "User")
+}
diff --git a/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/screens/browse/MapListViewModel.kt b/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/screens/browse/MapListViewModel.kt
new file mode 100644
index 000000000..03555cb19
--- /dev/null
+++ b/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/screens/browse/MapListViewModel.kt
@@ -0,0 +1,120 @@
+package com.arcgismaps.toolkit.featureformsapp.screens.browse
+
+import androidx.lifecycle.SavedStateHandle
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.arcgismaps.ArcGISEnvironment
+import com.arcgismaps.toolkit.authentication.AuthenticatorState
+import com.arcgismaps.toolkit.featureformsapp.data.PortalItemData
+import com.arcgismaps.toolkit.featureformsapp.data.PortalItemRepository
+import com.arcgismaps.toolkit.featureformsapp.data.PortalSettings
+import com.arcgismaps.toolkit.featureformsapp.navigation.NavigationRoute
+import com.arcgismaps.toolkit.featureformsapp.navigation.Navigator
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+data class MapListUIState(
+ val isLoading: Boolean,
+ val searchText: String,
+ val data: List
+)
+
+/**
+ * ViewModel class which acts as the data source of PortalItems to load.
+ */
+@HiltViewModel
+class MapListViewModel @Inject constructor(
+ @Suppress("UNUSED_PARAMETER") savedStateHandle: SavedStateHandle,
+ private val portalItemRepository: PortalItemRepository,
+ private val portalSettings: PortalSettings,
+ private val navigator: Navigator
+) : ViewModel() {
+
+ private val authenticatorState = AuthenticatorState()
+
+ // State flow to keep track of current loading state
+ private val _isLoading = MutableStateFlow(false)
+
+ private val _searchText = MutableStateFlow("")
+
+ // State flow that combines the _isLoading and the PortalItemUseCase data flow to create
+ // a MapListUIState
+ val uiState: StateFlow =
+ combine(
+ _isLoading,
+ _searchText,
+ portalItemRepository.observe()
+ ) { isLoading, searchText, portalItemData ->
+ val data = portalItemData.filter {
+ searchText.isEmpty()
+ || it.portalItem.title.uppercase().contains(searchText.uppercase())
+ || it.portalItem.itemId.contains(searchText)
+ }
+ MapListUIState(isLoading, searchText, data)
+ }.stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.WhileSubscribed(1000),
+ initialValue = MapListUIState(true, "", emptyList())
+ )
+
+ init {
+ viewModelScope.launch {
+ // if the data is empty, refresh it
+ // this is used to identify first launch
+ if (portalItemRepository.getItemCount() == 0) {
+ refresh(false)
+ }
+ }
+ viewModelScope.launch {
+ authenticatorState.pendingServerTrustChallenge.collect {
+ it?.trust()
+ }
+ }
+ }
+
+ fun getUsername(): String {
+ val credential =
+ ArcGISEnvironment.authenticationManager.arcGISCredentialStore.getCredential(
+ portalSettings.getPortalUrl()
+ )
+ return credential?.username ?: ""
+ }
+
+ /**
+ * Refreshes the data. [forceUpdate] clears the local cache.
+ */
+ fun refresh(forceUpdate: Boolean) {
+ if (!_isLoading.value) {
+ viewModelScope.launch {
+ _isLoading.emit(true)
+ portalItemRepository.refresh(
+ portalSettings.getPortalUrl(),
+ portalSettings.getPortalConnection(),
+ forceUpdate
+ )
+ _isLoading.emit(false)
+ }
+ }
+ }
+
+ fun filterPortalItems(filterText: String) {
+ _searchText.value = filterText
+ }
+
+ fun signOut() {
+ viewModelScope.launch {
+ portalItemRepository.deleteAll()
+ portalSettings.signOut()
+ // add a artificial delay before navigating screens
+ delay(500)
+ navigator.navigateTo(NavigationRoute.Login)
+ }
+ }
+}
diff --git a/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/screens/login/LoginScreen.kt b/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/screens/login/LoginScreen.kt
new file mode 100644
index 000000000..efc06ff9e
--- /dev/null
+++ b/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/screens/login/LoginScreen.kt
@@ -0,0 +1,266 @@
+/*
+ * Copyright 2023 Esri
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.arcgismaps.toolkit.featureformsapp.screens.login
+
+import android.widget.Toast
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.ExperimentalAnimationApi
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.slideInVertically
+import androidx.compose.animation.slideOutVertically
+import androidx.compose.animation.with
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.wrapContentHeight
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import com.arcgismaps.toolkit.authentication.Authenticator
+import com.arcgismaps.toolkit.authentication.AuthenticatorState
+import com.arcgismaps.toolkit.featureformsapp.AnimatedLoading
+
+@OptIn(ExperimentalAnimationApi::class)
+@Composable
+fun LoginScreen(
+ viewModel: LoginViewModel = hiltViewModel(),
+ onSuccessfulLogin: () -> Unit = {}
+) {
+ val context = LocalContext.current
+ val loginState by viewModel.loginState.collectAsState()
+ var showEnterpriseLogin by rememberSaveable { mutableStateOf(false) }
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.background
+ ) {
+ Column(
+ modifier = Modifier.padding(30.dp),
+ verticalArrangement = Arrangement.Top,
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text(
+ text = "FeatureForms Micro-App",
+ style = MaterialTheme.typography.titleLarge.copy(
+ fontWeight = FontWeight.Bold
+ )
+ )
+ AnimatedContent(
+ targetState = loginState is LoginState.Loading || loginState is LoginState.Success,
+ transitionSpec = {
+ slideInVertically { h -> h } with
+ slideOutVertically(
+ animationSpec = tween()
+ ) { h -> h } + fadeOut()
+ },
+ label = "evaluation loading animation"
+ ) {
+ Column {
+ if (it) {
+ // show a loading indicator if the currently in progress or for a successful login
+ AnimatedLoading(
+ { true },
+ modifier = Modifier.fillMaxSize(),
+ statusText = "Signing in.."
+ )
+ } else {
+ Spacer(modifier = Modifier.weight(1f))
+ LoginOptions(
+ onDefaultLoginTapped = {
+ viewModel.loginWithDefaultCredentials()
+ },
+ onEnterpriseLoginTapped = {
+ showEnterpriseLogin = true
+ },
+ skipSignInTapped = {
+ viewModel.skipSignIn()
+ }
+ )
+ }
+ }
+ }
+ }
+ }
+ EnterpriseLogin(
+ visibilityProvider = { showEnterpriseLogin },
+ authenticatorState = viewModel.authenticatorState,
+ onLoginRequest = {
+ viewModel.loginWithArcGISEnterprise(it)
+ },
+ onCancel = {
+ showEnterpriseLogin = false
+ }
+ )
+ LaunchedEffect(Unit) {
+ viewModel.loginState.collect {
+ if (it is LoginState.Success) {
+ onSuccessfulLogin()
+ } else if (it is LoginState.Failed) {
+ showEnterpriseLogin = false
+ Toast.makeText(context, "Login failed : ${it.message}", Toast.LENGTH_LONG).show()
+ }
+ }
+ }
+}
+
+@Composable
+fun EnterpriseLogin(
+ visibilityProvider: () -> Boolean,
+ authenticatorState: AuthenticatorState,
+ onLoginRequest: (String) -> Unit,
+ onCancel: () -> Unit
+) {
+ val visible = visibilityProvider()
+ if (visible) {
+ var showPortalUrlForm by remember { mutableStateOf(true) }
+ Authenticator(authenticatorState = authenticatorState)
+ if (showPortalUrlForm) {
+ PortalURLForm(
+ onSubmit = {
+ showPortalUrlForm = false
+ onLoginRequest(it)
+ },
+ onCancel = onCancel
+ )
+ }
+ }
+}
+
+@Composable
+fun PortalURLForm(
+ onSubmit: (String) -> Unit,
+ onCancel: () -> Unit
+) {
+ var url by remember { mutableStateOf("") }
+ AlertDialog(
+ onDismissRequest = {},
+ confirmButton = {
+ Button(onClick = {
+ onSubmit(url)
+ }) {
+ Text(text = "Login")
+ }
+ },
+ modifier = Modifier.clip(RoundedCornerShape(15.dp)),
+ dismissButton = {
+ Button(onClick = onCancel) {
+ Text(text = "Cancel")
+ }
+ },
+ title = {
+ Text(text = "Enter the ArcGIS Enterprise Portal URL")
+ },
+ text = {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(20.dp)
+ ) {
+ OutlinedTextField(
+ modifier = Modifier
+ .fillMaxWidth()
+ .wrapContentHeight(),
+ value = url,
+ onValueChange = { url = it },
+ label = { Text(text = "URL", style = MaterialTheme.typography.titleMedium) },
+ placeholder = { Text(text = "Enter the URL") },
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Uri,
+ imeAction = ImeAction.Done
+ )
+ )
+ }
+ }
+ )
+}
+
+@Composable
+fun LoginOptions(
+ modifier: Modifier = Modifier,
+ onDefaultLoginTapped: () -> Unit,
+ onEnterpriseLoginTapped: () -> Unit,
+ skipSignInTapped: () -> Unit
+) {
+ Column(
+ modifier = modifier,
+ verticalArrangement = Arrangement.spacedBy(20.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Button(
+ onClick = onDefaultLoginTapped,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 40.dp)
+ ) {
+ Text(
+ text = "Sign in using built-in Credentials",
+ modifier = Modifier.padding(5.dp),
+ )
+ }
+ Button(
+ onClick = onEnterpriseLoginTapped,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 40.dp)
+ ) {
+ Text(
+ text = "Sign in with ArcGIS Enterprise",
+ modifier = Modifier.padding(5.dp)
+ )
+ }
+ TextButton(onClick = skipSignInTapped) {
+ Text(text = "Skip sign in")
+ }
+ }
+}
+
+@Preview(showSystemUi = true)
+@Composable
+fun EnterpriseLoginPreview() {
+ PortalURLForm(
+ onSubmit = { a ->
+ }
+ ) {
+
+ }
+}
diff --git a/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/screens/login/LoginViewModel.kt b/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/screens/login/LoginViewModel.kt
new file mode 100644
index 000000000..615df146f
--- /dev/null
+++ b/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/screens/login/LoginViewModel.kt
@@ -0,0 +1,112 @@
+/*
+ * Copyright 2023 Esri
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.arcgismaps.toolkit.featureformsapp.screens.login
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.arcgismaps.portal.Portal
+import com.arcgismaps.toolkit.authentication.AuthenticatorState
+import com.arcgismaps.toolkit.featureformsapp.BuildConfig
+import com.arcgismaps.toolkit.featureformsapp.data.PortalSettings
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withTimeoutOrNull
+import javax.inject.Inject
+
+@HiltViewModel
+class LoginViewModel @Inject constructor(
+ private val portalSettings: PortalSettings
+) : ViewModel() {
+
+ private data class Credentials(val username: String = "", val password: String = "")
+
+ val authenticatorState = AuthenticatorState()
+
+ private val _loginState: MutableStateFlow = MutableStateFlow(LoginState.NotLoggedIn)
+ val loginState = _loginState.asStateFlow()
+
+ private var credentials: Credentials? = Credentials()
+
+ init {
+ viewModelScope.launch {
+ launch {
+ authenticatorState.pendingUsernamePasswordChallenge.collect {
+ if (credentials != null) {
+ it?.continueWithCredentials(credentials!!.username, credentials!!.password)
+ }
+ }
+ }
+ }
+ }
+
+ fun loginWithDefaultCredentials() {
+ credentials = Credentials(BuildConfig.webMapUser, BuildConfig.webMapPassword)
+ _loginState.value = LoginState.Loading
+ viewModelScope.launch(Dispatchers.IO) {
+ // set a timeout of 20s
+ val result = withTimeoutOrNull(20000) {
+ authenticatorState.oAuthUserConfiguration = null
+ portalSettings.setPortalUrl(portalSettings.defaultPortalUrl)
+ portalSettings.setPortalConnection(Portal.Connection.Authenticated)
+ val portal =
+ Portal(portalSettings.defaultPortalUrl, Portal.Connection.Authenticated)
+ portal.load().onFailure {
+ _loginState.value = LoginState.Failed(it.message ?: "")
+ }.onSuccess {
+ _loginState.value = LoginState.Success
+ }
+ }
+ if (result == null) {
+ _loginState.value = LoginState.Failed("Operation timed out")
+ }
+ }
+ }
+
+ fun loginWithArcGISEnterprise(url: String) {
+ credentials = null
+ _loginState.value = LoginState.Loading
+ viewModelScope.launch(Dispatchers.IO) {
+ authenticatorState.oAuthUserConfiguration = null
+ portalSettings.setPortalUrl(url)
+ portalSettings.setPortalConnection(Portal.Connection.Authenticated)
+ val portal = Portal(url, Portal.Connection.Authenticated)
+ portal.load().onFailure {
+ _loginState.value = LoginState.Failed(it.message ?: "")
+ }.onSuccess {
+ _loginState.value = LoginState.Success
+ }
+ }
+ }
+
+ fun skipSignIn() {
+ viewModelScope.launch {
+ portalSettings.setPortalUrl(portalSettings.defaultPortalUrl)
+ portalSettings.setPortalConnection(Portal.Connection.Anonymous)
+ _loginState.value = LoginState.Success
+ }
+ }
+}
+
+sealed class LoginState {
+ object Loading : LoginState()
+ object Success : LoginState()
+ data class Failed(val message: String) : LoginState()
+ object NotLoggedIn : LoginState()
+}
diff --git a/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/screens/map/MapScreen.kt b/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/screens/map/MapScreen.kt
new file mode 100644
index 000000000..6f49c0453
--- /dev/null
+++ b/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/screens/map/MapScreen.kt
@@ -0,0 +1,194 @@
+package com.arcgismaps.toolkit.featureformsapp.screens.map
+
+import android.content.Context
+import android.util.Log
+import android.widget.Toast
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.slideInVertically
+import androidx.compose.animation.slideOutVertically
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ArrowBack
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.window.core.layout.WindowSizeClass
+import androidx.window.layout.WindowMetricsCalculator
+import com.arcgismaps.toolkit.composablemap.ComposableMap
+import com.arcgismaps.toolkit.featureforms.EditingTransactionState
+import com.arcgismaps.toolkit.featureforms.FeatureForm
+import com.arcgismaps.toolkit.featureformsapp.R
+import com.arcgismaps.toolkit.featureformsapp.screens.bottomsheet.BottomSheetMaxWidth
+import com.arcgismaps.toolkit.featureformsapp.screens.bottomsheet.SheetExpansionHeight
+import com.arcgismaps.toolkit.featureformsapp.screens.bottomsheet.SheetValue
+import com.arcgismaps.toolkit.featureformsapp.screens.bottomsheet.SheetLayout
+import com.arcgismaps.toolkit.featureformsapp.screens.bottomsheet.StandardBottomSheet
+import com.arcgismaps.toolkit.featureformsapp.screens.bottomsheet.rememberStandardBottomSheetState
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.launch
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun MapScreen(mapViewModel: MapViewModel = hiltViewModel(), onBackPressed: () -> Unit = {}) {
+ // only recompose when showing or hiding the bottom sheet
+ val editingFlow =
+ remember { mapViewModel.transactionState.map { it is EditingTransactionState.Editing } }
+ val inEditingMode by editingFlow.collectAsState(initial = false)
+ val context = LocalContext.current
+ val windowSize = getWindowSize(context)
+
+ Scaffold(
+ modifier = Modifier.fillMaxSize(),
+ topBar = {
+ val scope = rememberCoroutineScope()
+ // show the top bar which changes available actions based on if the FeatureForm is
+ // being shown and is in edit mode
+ TopFormBar(
+ title = mapViewModel.portalItem.title,
+ editingMode = inEditingMode,
+ onClose = {
+ scope.launch { mapViewModel.rollbackEdits(EditingTransactionState.NotEditing) }
+ },
+ onSave = {
+ scope.launch {
+ mapViewModel.commitEdits(EditingTransactionState.NotEditing)
+ .onFailure {
+ Log.w(
+ "Forms",
+ "applying edits from feature form failed with ${it.message}"
+ )
+ launch(Dispatchers.Main) {
+ Toast.makeText(
+ context,
+ "applying edits from feature form failed with ${it.message}",
+ Toast.LENGTH_LONG
+ ).show()
+ }
+ }
+ }
+ }) {
+ onBackPressed()
+ }
+ }
+ ) { padding ->
+ // show the composable map using the mapViewModel
+ ComposableMap(
+ modifier = Modifier
+ .padding(padding)
+ .fillMaxSize(),
+ mapState = mapViewModel
+ )
+ AnimatedVisibility(
+ visible = inEditingMode,
+ enter = slideInVertically { h -> h },
+ exit = slideOutVertically { h -> h },
+ label = "feature form"
+ ) {
+ val bottomSheetState = rememberStandardBottomSheetState(
+ initialValue = SheetValue.PartiallyExpanded,
+ confirmValueChange = { it != SheetValue.Hidden },
+ skipHiddenState = false
+ )
+ SheetLayout(
+ windowSizeClass = windowSize,
+ sheetOffsetY = { bottomSheetState.requireOffset() },
+ modifier = Modifier.padding(padding),
+ maxWidth = BottomSheetMaxWidth,
+ ) { layoutWidth, layoutHeight ->
+ StandardBottomSheet(
+ state = bottomSheetState,
+ peekHeight = 40.dp,
+ expansionHeight = SheetExpansionHeight(0.5f),
+ sheetSwipeEnabled = true,
+ shape = RoundedCornerShape(5.dp),
+ layoutHeight = layoutHeight.toFloat(),
+ sheetWidth = with(LocalDensity.current) { layoutWidth.toDp() }
+ ) {
+ // set bottom sheet content to the FeatureForm
+ FeatureForm(
+ featureFormState = mapViewModel,
+ modifier = Modifier.fillMaxSize()
+ )
+ }
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun TopFormBar(
+ title: String,
+ editingMode: Boolean,
+ onClose: () -> Unit = {},
+ onSave: () -> Unit = {},
+ onBackPressed: () -> Unit = {}
+) {
+ TopAppBar(
+ title = {
+ Text(
+ text = if (editingMode) stringResource(R.string.edit_feature) else title,
+ style = MaterialTheme.typography.titleMedium,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ },
+ navigationIcon = {
+ if (editingMode) {
+ IconButton(onClick = onClose) {
+ Icon(
+ imageVector = Icons.Default.Close,
+ contentDescription = "Close Feature Editor"
+ )
+ }
+ } else {
+ IconButton(onClick = onBackPressed) {
+ Icon(imageVector = Icons.Default.ArrowBack, contentDescription = "Back")
+ }
+ }
+ },
+ actions = {
+ if (editingMode) {
+ IconButton(onClick = onSave) {
+ Icon(imageVector = Icons.Default.Check, contentDescription = "Save Feature")
+ }
+ }
+ }
+ )
+}
+
+fun getWindowSize(context: Context): WindowSizeClass {
+ val metrics = WindowMetricsCalculator.getOrCreate().computeCurrentWindowMetrics(context)
+ val width = metrics.bounds.width()
+ val height = metrics.bounds.height()
+ val density = context.resources.displayMetrics.density
+ return WindowSizeClass.compute(width / density, height / density)
+}
+
+@Preview
+@Composable
+fun TopFormBarPreview() {
+ TopFormBar("Map", false)
+}
diff --git a/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/screens/map/MapViewModel.kt b/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/screens/map/MapViewModel.kt
new file mode 100644
index 000000000..32a8f0591
--- /dev/null
+++ b/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/screens/map/MapViewModel.kt
@@ -0,0 +1,92 @@
+package com.arcgismaps.toolkit.featureformsapp.screens.map
+
+import android.widget.Toast
+import androidx.lifecycle.SavedStateHandle
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.arcgismaps.data.ArcGISFeature
+import com.arcgismaps.mapping.ArcGISMap
+import com.arcgismaps.mapping.PortalItem
+import com.arcgismaps.mapping.featureforms.FeatureForm
+import com.arcgismaps.mapping.layers.FeatureLayer
+import com.arcgismaps.mapping.view.MapView
+import com.arcgismaps.mapping.view.SingleTapConfirmedEvent
+import com.arcgismaps.toolkit.composablemap.MapState
+import com.arcgismaps.toolkit.featureforms.EditingTransactionState
+import com.arcgismaps.toolkit.featureforms.FeatureFormState
+import com.arcgismaps.toolkit.featureformsapp.data.PortalItemRepository
+import com.arcgismaps.toolkit.featureformsapp.di.PortalItemRepo
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+/**
+ * A view model for the FeatureForms MapView UI
+ * @constructor to be invoked by injection
+ */
+@HiltViewModel
+class MapViewModel @Inject constructor(
+ savedStateHandle: SavedStateHandle,
+ private val portalItemRepository: PortalItemRepository
+) : ViewModel(),
+ MapState by MapState(),
+ FeatureFormState by FeatureFormState() {
+ private val itemId: String = savedStateHandle["uri"]!!
+ lateinit var portalItem: PortalItem
+
+ init {
+ viewModelScope.launch {
+ portalItem = portalItemRepository(itemId) ?: return@launch
+ setMap(ArcGISMap(portalItem))
+ }
+ }
+
+ context(MapView, CoroutineScope) override fun onSingleTapConfirmed(singleTapEvent: SingleTapConfirmedEvent) {
+ launch {
+ this@MapView.identifyLayers(
+ screenCoordinate = singleTapEvent.screenCoordinate,
+ tolerance = 22.0,
+ returnPopupsOnly = false
+ ).onSuccess { results ->
+ results.firstNotNullOfOrNull { result ->
+ try {
+ result.geoElements.filterIsInstance()
+ .firstOrNull { feature ->
+ (feature.featureTable?.layer as? FeatureLayer)?.featureFormDefinition != null
+ }
+ } catch (e: Exception) {
+ e.printStackTrace()
+ Toast.makeText(
+ context,
+ "failed to load the FeatureFormDefinition for the feature",
+ Toast.LENGTH_LONG
+ ).show()
+ null
+ }
+ }?.let { feature ->
+ feature.load().onSuccess {
+ try {
+ val featureForm = FeatureForm(
+ feature,
+ (feature.featureTable?.layer as FeatureLayer).featureFormDefinition!!
+ )
+ // update the FeatureFormState's FeatureForm
+ setFeatureForm(featureForm)
+ // set the FeatureFormState to an editing state to bring up the
+ // FeatureForm UI
+ setTransactionState(EditingTransactionState.Editing)
+ } catch (e: Exception) {
+ e.printStackTrace() // for debugging core issues
+ Toast.makeText(
+ context,
+ "failed to create a FeatureForm for the feature and layer",
+ Toast.LENGTH_LONG
+ ).show()
+ }
+ }.onFailure { println("failed to load tapped Feature") }
+ } ?: println("identified features do not have feature forms defined")
+ }.onFailure { println("tap was not on a feature") }
+ }
+ }
+}
diff --git a/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/ui/theme/Color.kt b/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/ui/theme/Color.kt
new file mode 100644
index 000000000..5717b1d16
--- /dev/null
+++ b/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/ui/theme/Color.kt
@@ -0,0 +1,11 @@
+package com.arcgismaps.toolkit.featureformsapp.ui.theme
+
+import androidx.compose.ui.graphics.Color
+
+val Purple80 = Color(0xFFD0BCFF)
+val PurpleGrey80 = Color(0xFFCCC2DC)
+val Pink80 = Color(0xFFEFB8C8)
+
+val Purple40 = Color(0xFF6650a4)
+val PurpleGrey40 = Color(0xFF625b71)
+val Pink40 = Color(0xFF7D5260)
diff --git a/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/ui/theme/Theme.kt b/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/ui/theme/Theme.kt
new file mode 100644
index 000000000..0ffcab121
--- /dev/null
+++ b/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/ui/theme/Theme.kt
@@ -0,0 +1,70 @@
+package com.arcgismaps.toolkit.featureformsapp.ui.theme
+
+import android.app.Activity
+import android.os.Build
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.dynamicDarkColorScheme
+import androidx.compose.material3.dynamicLightColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.SideEffect
+import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalView
+import androidx.core.view.WindowCompat
+
+private val DarkColorScheme = darkColorScheme(
+ primary = Purple80,
+ secondary = PurpleGrey80,
+ tertiary = Pink80
+)
+
+private val LightColorScheme = lightColorScheme(
+ primary = Purple40,
+ secondary = PurpleGrey40,
+ tertiary = Pink40
+
+ /* Other default colors to override
+ background = Color(0xFFFFFBFE),
+ surface = Color(0xFFFFFBFE),
+ onPrimary = Color.White,
+ onSecondary = Color.White,
+ onTertiary = Color.White,
+ onBackground = Color(0xFF1C1B1F),
+ onSurface = Color(0xFF1C1B1F),
+ */
+)
+
+@Composable
+fun FeatureFormsAppTheme(
+ darkTheme: Boolean = isSystemInDarkTheme(),
+ // Dynamic color is available on Android 12+
+ dynamicColor: Boolean = true,
+ content: @Composable () -> Unit
+) {
+ val colorScheme = when {
+ dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
+ val context = LocalContext.current
+ if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
+ }
+
+ darkTheme -> DarkColorScheme
+ else -> LightColorScheme
+ }
+ val view = LocalView.current
+ if (!view.isInEditMode) {
+ SideEffect {
+ val window = (view.context as Activity).window
+ window.statusBarColor = colorScheme.primary.toArgb()
+ WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme
+ }
+ }
+
+ MaterialTheme(
+ colorScheme = colorScheme,
+ typography = Typography,
+ content = content
+ )
+}
diff --git a/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/ui/theme/Type.kt b/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/ui/theme/Type.kt
new file mode 100644
index 000000000..c5fec3d2d
--- /dev/null
+++ b/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/ui/theme/Type.kt
@@ -0,0 +1,34 @@
+package com.arcgismaps.toolkit.featureformsapp.ui.theme
+
+import androidx.compose.material3.Typography
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.sp
+
+// Set of Material typography styles to start with
+val Typography = Typography(
+ bodyLarge = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 16.sp,
+ lineHeight = 24.sp,
+ letterSpacing = 0.5.sp
+ )
+ /* Other default text styles to override
+ titleLarge = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 22.sp,
+ lineHeight = 28.sp,
+ letterSpacing = 0.sp
+ ),
+ labelSmall = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Medium,
+ fontSize = 11.sp,
+ lineHeight = 16.sp,
+ letterSpacing = 0.5.sp
+ )
+ */
+)
diff --git a/microapps/FeatureFormsApp/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/microapps/FeatureFormsApp/app/src/main/res/drawable-v24/ic_launcher_foreground.xml
new file mode 100644
index 000000000..7706ab9e6
--- /dev/null
+++ b/microapps/FeatureFormsApp/app/src/main/res/drawable-v24/ic_launcher_foreground.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/microapps/FeatureFormsApp/app/src/main/res/drawable/ic_default_map.xml b/microapps/FeatureFormsApp/app/src/main/res/drawable/ic_default_map.xml
new file mode 100644
index 000000000..454fad03a
--- /dev/null
+++ b/microapps/FeatureFormsApp/app/src/main/res/drawable/ic_default_map.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
diff --git a/microapps/FeatureFormsApp/app/src/main/res/drawable/ic_launcher_background.xml b/microapps/FeatureFormsApp/app/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 000000000..07d5da9cb
--- /dev/null
+++ b/microapps/FeatureFormsApp/app/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,170 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/microapps/FeatureFormsApp/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/microapps/FeatureFormsApp/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 000000000..b3e26b4c6
--- /dev/null
+++ b/microapps/FeatureFormsApp/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/microapps/FeatureFormsApp/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/microapps/FeatureFormsApp/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 000000000..b3e26b4c6
--- /dev/null
+++ b/microapps/FeatureFormsApp/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/microapps/FeatureFormsApp/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/microapps/FeatureFormsApp/app/src/main/res/mipmap-hdpi/ic_launcher.webp
new file mode 100644
index 000000000..c209e78ec
Binary files /dev/null and b/microapps/FeatureFormsApp/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ
diff --git a/microapps/FeatureFormsApp/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/microapps/FeatureFormsApp/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
new file mode 100644
index 000000000..b2dfe3d1b
Binary files /dev/null and b/microapps/FeatureFormsApp/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ
diff --git a/microapps/FeatureFormsApp/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/microapps/FeatureFormsApp/app/src/main/res/mipmap-mdpi/ic_launcher.webp
new file mode 100644
index 000000000..4f0f1d64e
Binary files /dev/null and b/microapps/FeatureFormsApp/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ
diff --git a/microapps/FeatureFormsApp/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/microapps/FeatureFormsApp/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
new file mode 100644
index 000000000..62b611da0
Binary files /dev/null and b/microapps/FeatureFormsApp/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ
diff --git a/microapps/FeatureFormsApp/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/microapps/FeatureFormsApp/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
new file mode 100644
index 000000000..948a3070f
Binary files /dev/null and b/microapps/FeatureFormsApp/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ
diff --git a/microapps/FeatureFormsApp/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/microapps/FeatureFormsApp/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
new file mode 100644
index 000000000..1b9a6956b
Binary files /dev/null and b/microapps/FeatureFormsApp/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ
diff --git a/microapps/FeatureFormsApp/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/microapps/FeatureFormsApp/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
new file mode 100644
index 000000000..28d4b77f9
Binary files /dev/null and b/microapps/FeatureFormsApp/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ
diff --git a/microapps/FeatureFormsApp/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/microapps/FeatureFormsApp/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
new file mode 100644
index 000000000..9287f5083
Binary files /dev/null and b/microapps/FeatureFormsApp/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ
diff --git a/microapps/FeatureFormsApp/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/microapps/FeatureFormsApp/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
new file mode 100644
index 000000000..aa7d6427e
Binary files /dev/null and b/microapps/FeatureFormsApp/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ
diff --git a/microapps/FeatureFormsApp/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/microapps/FeatureFormsApp/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
new file mode 100644
index 000000000..9126ae37c
Binary files /dev/null and b/microapps/FeatureFormsApp/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ
diff --git a/microapps/FeatureFormsApp/app/src/main/res/values/colors.xml b/microapps/FeatureFormsApp/app/src/main/res/values/colors.xml
new file mode 100644
index 000000000..ca1931bca
--- /dev/null
+++ b/microapps/FeatureFormsApp/app/src/main/res/values/colors.xml
@@ -0,0 +1,10 @@
+
+
+ #FFBB86FC
+ #FF6200EE
+ #FF3700B3
+ #FF03DAC5
+ #FF018786
+ #FF000000
+ #FFFFFFFF
+
diff --git a/microapps/FeatureFormsApp/app/src/main/res/values/strings.xml b/microapps/FeatureFormsApp/app/src/main/res/values/strings.xml
new file mode 100644
index 000000000..bd151f2f6
--- /dev/null
+++ b/microapps/FeatureFormsApp/app/src/main/res/values/strings.xml
@@ -0,0 +1,13 @@
+
+ FeatureFormsApp
+ Edit Feature
+ https://runtimecoretest.maps.arcgis.com/home/item.html?id=df0f27f83eee41b0afe4b6216f80b541
+ https://runtimecoretest.maps.arcgis.com/home/item.html?id=454422bdf7e24fb0ba4ffe0a22f6bf37
+ https://runtimecoretest.maps.arcgis.com/home/item.html?id=c606b1f345044d71881f99d00583f8f7
+ https://runtimecoretest.maps.arcgis.com/home/item.html?id=622c4674d6f64114a1de2e0b8382fcf3
+ https://runtimecoretest.maps.arcgis.com/home/item.html?id=a81d90609e4549479d1f214f28335af2
+ https://runtimecoretest.maps.arcgis.com/home/item.html?id=bb4c5e81740e4e7296943988c78a7ea6
+ https://runtimecoretest.maps.arcgis.com/home/item.html?id=5d69e2301ad14ec8a73b568dfc29450a
+ https://runtimecoretest.maps.arcgis.com/home/item.html?id=0f6864ddc35241649e5ad2ee61a3abe4
+ https://www.arcgis.com
+
diff --git a/microapps/FeatureFormsApp/app/src/main/res/values/themes.xml b/microapps/FeatureFormsApp/app/src/main/res/values/themes.xml
new file mode 100644
index 000000000..c33478734
--- /dev/null
+++ b/microapps/FeatureFormsApp/app/src/main/res/values/themes.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/microapps/FeatureFormsApp/app/src/main/res/xml/backup_rules.xml b/microapps/FeatureFormsApp/app/src/main/res/xml/backup_rules.xml
new file mode 100644
index 000000000..148c18b65
--- /dev/null
+++ b/microapps/FeatureFormsApp/app/src/main/res/xml/backup_rules.xml
@@ -0,0 +1,13 @@
+
+
+
+
diff --git a/microapps/FeatureFormsApp/app/src/main/res/xml/data_extraction_rules.xml b/microapps/FeatureFormsApp/app/src/main/res/xml/data_extraction_rules.xml
new file mode 100644
index 000000000..0c4f95cab
--- /dev/null
+++ b/microapps/FeatureFormsApp/app/src/main/res/xml/data_extraction_rules.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
diff --git a/microapps/TemplateApp/app/src/main/java/com/arcgismaps/toolkit/templateapp/screens/MainScreen.kt b/microapps/TemplateApp/app/src/main/java/com/arcgismaps/toolkit/templateapp/screens/MainScreen.kt
index ce9826b3a..be03031f2 100644
--- a/microapps/TemplateApp/app/src/main/java/com/arcgismaps/toolkit/templateapp/screens/MainScreen.kt
+++ b/microapps/TemplateApp/app/src/main/java/com/arcgismaps/toolkit/templateapp/screens/MainScreen.kt
@@ -34,7 +34,7 @@ fun MainScreen() {
val mapViewModel = viewModel(factory = MapViewModelFactory(map))
ComposableMap(
modifier = Modifier.fillMaxSize(),
- mapInterface = mapViewModel
+ mapState = mapViewModel
)
val templateViewModel = viewModel(
diff --git a/microapps/TemplateApp/app/src/main/java/com/arcgismaps/toolkit/templateapp/screens/MapViewModel.kt b/microapps/TemplateApp/app/src/main/java/com/arcgismaps/toolkit/templateapp/screens/MapViewModel.kt
index 7dd077ee0..47f8f8ee6 100644
--- a/microapps/TemplateApp/app/src/main/java/com/arcgismaps/toolkit/templateapp/screens/MapViewModel.kt
+++ b/microapps/TemplateApp/app/src/main/java/com/arcgismaps/toolkit/templateapp/screens/MapViewModel.kt
@@ -23,13 +23,13 @@ import androidx.lifecycle.ViewModelProvider
import com.arcgismaps.mapping.ArcGISMap
import com.arcgismaps.mapping.view.MapView
import com.arcgismaps.mapping.view.SingleTapConfirmedEvent
-import com.arcgismaps.toolkit.composablemap.MapInterface
-import com.arcgismaps.toolkit.composablemap.MapInterfaceImpl
+import com.arcgismaps.toolkit.composablemap.MapState
+import com.arcgismaps.toolkit.composablemap.MapStateImpl
import kotlinx.coroutines.CoroutineScope
class MapViewModel(
arcGISMap: ArcGISMap
-) : ViewModel(), MapInterface by MapInterfaceImpl(arcGISMap) {
+) : ViewModel(), MapState by MapStateImpl(arcGISMap) {
context(MapView, CoroutineScope) override fun onSingleTapConfirmed(singleTapEvent: SingleTapConfirmedEvent) {
// example of how to add a tap handler with a MapView and CoroutineScope in context.
diff --git a/settings.gradle.kts b/settings.gradle.kts
index c296005a5..2f785ef66 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -22,10 +22,11 @@ import org.gradle.configurationcache.extensions.capitalized
// and also add a companion micro app(Ex: "newComponent-app").
// For mismatching toolkit component and microApp names add them individually at end of this file.
// Refer to "indoors" project with "floor-filter-app" as an example.
-val projects = listOf("template", "authentication", "compass")
+val projects = listOf("template", "featureforms", "authentication", "compass", "featureeditor")
pluginManagement {
repositories {
+ mavenLocal()
gradlePluginPortal()
google()
mavenCentral()
@@ -42,6 +43,7 @@ dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
@Suppress("UnstableApiUsage")
repositories {
+ mavenLocal()
google()
mavenCentral()
maven {
@@ -63,13 +65,14 @@ dependencyResolutionManagement {
} else {
sdkVersionNumber
}
- version("mapsSdk", "$versionAndBuild")
+ version("mapsSdk", versionAndBuild)
library("mapsSdk", "com.esri", "arcgis-maps-kotlin").versionRef("mapsSdk")
}
}
}
var includedProjects = projects.flatMap { listOf(":$it", ":$it-app") }.toTypedArray()
+
include(*includedProjects)
include(":bom")
include(":composable-map")
diff --git a/toolkit/composable-map/src/androidTest/java/com/arcgismaps/toolkit/composablemap/ComposableMapTests.kt b/toolkit/composable-map/src/androidTest/java/com/arcgismaps/toolkit/composablemap/ComposableMapTests.kt
index 17e7f0085..1afea1d78 100644
--- a/toolkit/composable-map/src/androidTest/java/com/arcgismaps/toolkit/composablemap/ComposableMapTests.kt
+++ b/toolkit/composable-map/src/androidTest/java/com/arcgismaps/toolkit/composablemap/ComposableMapTests.kt
@@ -40,10 +40,10 @@ class ComposableMapTests {
@Test
fun testComposableMapLayout() {
- val mockMapInterface = MapInterface(ArcGISMap())
+ val mockMapInterface = MapState(ArcGISMap())
composeTestRule.setContent {
- ComposableMap(mapInterface = mockMapInterface) {
+ ComposableMap(mapState = mockMapInterface) {
Card(modifier = Modifier.semantics { contentDescription = "Card" }) {}
}
}
diff --git a/toolkit/composable-map/src/main/java/com/arcgismaps/toolkit/composablemap/ComposableMap.kt b/toolkit/composable-map/src/main/java/com/arcgismaps/toolkit/composablemap/ComposableMap.kt
index 2eefbe714..d03bd86e1 100644
--- a/toolkit/composable-map/src/main/java/com/arcgismaps/toolkit/composablemap/ComposableMap.kt
+++ b/toolkit/composable-map/src/main/java/com/arcgismaps/toolkit/composablemap/ComposableMap.kt
@@ -41,7 +41,7 @@ import kotlinx.coroutines.launch
@Composable
public fun ComposableMap(
- mapInterface: MapInterface,
+ mapState: MapState,
modifier: Modifier = Modifier,
content: @Composable () -> Unit = {},
) {
@@ -49,56 +49,56 @@ public fun ComposableMap(
val coroutineScope = rememberCoroutineScope()
val lifecycleOwner = LocalLifecycleOwner.current
- val map by mapInterface.map.collectAsState()
- val insets by mapInterface.insets.collectAsState()
+ val map by mapState.map.collectAsState()
+ val insets by mapState.insets.collectAsState()
val mapView = remember {
MapView(context).also { view ->
with(view) {
with(coroutineScope) {
launch {
view.onDown.collect {
- mapInterface.onDown(it)
+ mapState.onDown(it)
}
}
launch {
view.onUp.collect {
- mapInterface.onUp(it)
+ mapState.onUp(it)
}
}
launch {
view.onSingleTapConfirmed.collect {
- mapInterface.onSingleTapConfirmed(it)
+ mapState.onSingleTapConfirmed(it)
}
}
launch {
view.onDoubleTap.collect {
- mapInterface.onDoubleTap(it)
+ mapState.onDoubleTap(it)
}
}
launch {
view.onLongPress.collect {
- mapInterface.onLongPress(it)
+ mapState.onLongPress(it)
}
}
launch {
view.onTwoPointerTap.collect {
- mapInterface.onTwoPointerTap(it)
+ mapState.onTwoPointerTap(it)
}
}
launch {
view.onPan.collect {
- mapInterface.onPan(it)
+ mapState.onPan(it)
}
}
launch {
view.mapRotation.collect {
- mapInterface.onViewpointRotationChanged(it)
+ mapState.onViewpointRotationChanged(it)
}
}
launch {
view.viewpointChanged.collect {
view.getCurrentViewpoint(ViewpointType.CenterAndScale)?.let {
- mapInterface.onViewpointChanged(it)
+ mapState.onViewpointChanged(it)
}
}
}
@@ -117,17 +117,22 @@ public fun ComposableMap(
LaunchedEffect(Unit) {
launch {
- mapInterface.mapRotation.collect(DuplexFlow.Type.Write) {
+ mapState.mapRotation.collect(DuplexFlow.Type.Write) {
mapView.setViewpointRotation(it)
}
}
launch {
- mapInterface.viewpoint.collect(DuplexFlow.Type.Write) {
+ mapState.viewpoint.collect(DuplexFlow.Type.Write) {
it?.let {
mapView.setViewpoint(it)
}
}
}
+ launch {
+ mapState.geometryEditor.collect {
+ mapView.geometryEditor = it
+ }
+ }
}
DisposableEffect(Unit) {
diff --git a/toolkit/composable-map/src/main/java/com/arcgismaps/toolkit/composablemap/MapInterface.kt b/toolkit/composable-map/src/main/java/com/arcgismaps/toolkit/composablemap/MapState.kt
similarity index 85%
rename from toolkit/composable-map/src/main/java/com/arcgismaps/toolkit/composablemap/MapInterface.kt
rename to toolkit/composable-map/src/main/java/com/arcgismaps/toolkit/composablemap/MapState.kt
index f2a821c55..56fd120a5 100644
--- a/toolkit/composable-map/src/main/java/com/arcgismaps/toolkit/composablemap/MapInterface.kt
+++ b/toolkit/composable-map/src/main/java/com/arcgismaps/toolkit/composablemap/MapState.kt
@@ -28,6 +28,7 @@ import com.arcgismaps.mapping.view.PanChangeEvent
import com.arcgismaps.mapping.view.SingleTapConfirmedEvent
import com.arcgismaps.mapping.view.TwoPointerTapEvent
import com.arcgismaps.mapping.view.UpEvent
+import com.arcgismaps.mapping.view.geometryeditor.GeometryEditor
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -125,17 +126,22 @@ public interface MapEvents {
* Sets the given [map] on the [ComposableMap]
*/
public fun setMap(map: ArcGISMap)
+
+ // Don't know if it makes sense to have this on a MapEvents interface, but setMap is here...
+ public fun setGeometryEditor(geometryEditor: GeometryEditor?)
}
/**
* An interface for consumption by [ComposableMap]. This interface represents the state needed
* for [ComposableMap] to re/compose.
*/
-public interface MapInterface : MapEvents {
+public interface MapState : MapEvents {
/**
* The model for [ComposableMap]
*/
- public val map: StateFlow
+ public val map: StateFlow
+
+ public val geometryEditor: StateFlow
/**
* Insets to apply to the Box which contains the [ComposableMap]
@@ -154,22 +160,22 @@ public interface MapInterface : MapEvents {
}
/**
- * Factory function for the default implementation of [MapInterface]
+ * Factory function for the default implementation of [MapState]
*/
-public fun MapInterface(arcGISMap: ArcGISMap, mapInsets: MapInsets = MapInsets()): MapInterface =
- MapInterfaceImpl(arcGISMap, mapInsets)
+public fun MapState(map: ArcGISMap? = null, mapInsets: MapInsets = MapInsets()): MapState =
+ MapStateImpl(map, mapInsets)
/**
- * A default implementation for the [MapInterface]
+ * A default implementation for the [MapState]
*/
-public class MapInterfaceImpl(
- arcGISMap: ArcGISMap,
+public class MapStateImpl(
+ map: ArcGISMap?,
mapInsets: MapInsets = MapInsets()
-) : MapInterface {
+) : MapState {
- private val _map: MutableStateFlow = MutableStateFlow(arcGISMap)
- override val map: StateFlow = _map.asStateFlow()
+ private val _map: MutableStateFlow = MutableStateFlow(map)
+ override val map: StateFlow = _map.asStateFlow()
private val _insets: MutableStateFlow = MutableStateFlow(mapInsets)
override val insets: StateFlow = _insets.asStateFlow()
@@ -180,6 +186,9 @@ public class MapInterfaceImpl(
private val _mapRotation: MutableDuplexFlow = MutableDuplexFlow(0.0)
override val mapRotation: DuplexFlow = _mapRotation
+ private val _geometryEditor = MutableStateFlow(null)
+ override val geometryEditor: StateFlow = _geometryEditor.asStateFlow()
+
override fun setViewpoint(viewpoint: Viewpoint) {
// set the property value using the WRITE flow type
_viewpoint.setValue(viewpoint, DuplexFlow.Type.Write)
@@ -207,4 +216,8 @@ public class MapInterfaceImpl(
override fun setMap(map: ArcGISMap) {
_map.value = map
}
+
+ override fun setGeometryEditor(geometryEditor: GeometryEditor?) {
+ _geometryEditor.value = geometryEditor
+ }
}
diff --git a/toolkit/featureeditor/.gitignore b/toolkit/featureeditor/.gitignore
new file mode 100644
index 000000000..796b96d1c
--- /dev/null
+++ b/toolkit/featureeditor/.gitignore
@@ -0,0 +1 @@
+/build
diff --git a/toolkit/featureeditor/README.md b/toolkit/featureeditor/README.md
new file mode 100644
index 000000000..05775b84c
--- /dev/null
+++ b/toolkit/featureeditor/README.md
@@ -0,0 +1 @@
+# Feature Editor
diff --git a/toolkit/featureeditor/build.gradle.kts b/toolkit/featureeditor/build.gradle.kts
new file mode 100644
index 000000000..5eb08961d
--- /dev/null
+++ b/toolkit/featureeditor/build.gradle.kts
@@ -0,0 +1,76 @@
+/*
+ *
+ * Copyright 2023 Esri
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+plugins {
+ id("com.android.library")
+ id("org.jetbrains.kotlin.android")
+ id("artifact-deploy")
+}
+
+android {
+ namespace = "com.arcgismaps.toolkit.featureeditor"
+ compileSdk = libs.versions.compileSdk.get().toInt()
+
+ defaultConfig {
+ minSdk = libs.versions.minSdk.get().toInt()
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ consumerProguardFiles("consumer-rules.pro")
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_1_8
+ targetCompatibility = JavaVersion.VERSION_1_8
+ }
+ kotlinOptions {
+ jvmTarget = "1.8"
+ }
+ @Suppress("UnstableApiUsage")
+ buildFeatures {
+ compose = true
+ }
+ composeOptions {
+ kotlinCompilerExtensionVersion = libs.versions.androidxComposeCompiler.get()
+ }
+ // If this were not an android project, we would just write `explicitApi()` in the Kotlin scope.
+ // but as an android project could write `freeCompilerArgs = listOf("-Xexplicit-api=strict")`
+ // in the kotlinOptions above, but that would enforce api rules on the test code, which we don't want.
+ tasks.withType {
+ if ("Test" !in name) {
+ kotlinOptions.freeCompilerArgs += "-Xexplicit-api=strict"
+ }
+ }
+}
+
+dependencies {
+ implementation(project(":composable-map"))
+ implementation(project(":featureforms"))
+ implementation(libs.bundles.composeCore)
+ implementation(libs.bundles.core)
+ implementation(arcgis.mapsSdk)
+ implementation(libs.androidx.lifecycle.runtime.ktx)
+ implementation(libs.androidx.activity.compose)
+ testImplementation(libs.bundles.unitTest)
+ androidTestImplementation(libs.bundles.composeTest)
+ debugImplementation(libs.bundles.debug)
+}
diff --git a/toolkit/featureeditor/consumer-rules.pro b/toolkit/featureeditor/consumer-rules.pro
new file mode 100644
index 000000000..e69de29bb
diff --git a/toolkit/featureeditor/src/main/AndroidManifest.xml b/toolkit/featureeditor/src/main/AndroidManifest.xml
new file mode 100644
index 000000000..9b94d167c
--- /dev/null
+++ b/toolkit/featureeditor/src/main/AndroidManifest.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
diff --git a/toolkit/featureeditor/src/main/java/com/arcgismaps/toolkit/featureeditor/FeatureEditor.kt b/toolkit/featureeditor/src/main/java/com/arcgismaps/toolkit/featureeditor/FeatureEditor.kt
new file mode 100644
index 000000000..8c561547e
--- /dev/null
+++ b/toolkit/featureeditor/src/main/java/com/arcgismaps/toolkit/featureeditor/FeatureEditor.kt
@@ -0,0 +1,403 @@
+/*
+ *
+ * Copyright 2023 Esri
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package com.arcgismaps.toolkit.featureeditor
+
+import com.arcgismaps.Color
+import com.arcgismaps.data.ArcGISFeature
+import com.arcgismaps.geometry.Geometry
+import com.arcgismaps.geometry.GeometryType
+import com.arcgismaps.geometry.Multipoint
+import com.arcgismaps.geometry.Point
+import com.arcgismaps.geometry.Polygon
+import com.arcgismaps.geometry.Polyline
+import com.arcgismaps.mapping.featureforms.FeatureForm
+import com.arcgismaps.mapping.layers.FeatureLayer
+import com.arcgismaps.mapping.symbology.MultilayerPolygonSymbol
+import com.arcgismaps.mapping.symbology.MultilayerPolylineSymbol
+import com.arcgismaps.mapping.symbology.PictureMarkerSymbol
+import com.arcgismaps.mapping.symbology.SimpleFillSymbol
+import com.arcgismaps.mapping.symbology.SimpleLineSymbol
+import com.arcgismaps.mapping.symbology.SimpleLineSymbolStyle
+import com.arcgismaps.mapping.symbology.SimpleMarkerSymbol
+import com.arcgismaps.mapping.symbology.SimpleMarkerSymbolStyle
+import com.arcgismaps.mapping.symbology.StrokeSymbolLayer
+import com.arcgismaps.mapping.view.geometryeditor.GeometryEditor
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.channels.BufferOverflow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import kotlin.math.max
+
+public sealed class FinishState(public open val feature: ArcGISFeature) {
+ public data class Discarded(override val feature: ArcGISFeature) : FinishState(feature)
+ public data class Stopped(override val feature: ArcGISFeature) : FinishState(feature)
+}
+
+public class FeatureEditor(
+ public val geometryEditor: GeometryEditor,
+ scope: CoroutineScope // TODO: Ideally this would not be needed here...
+) {
+
+ public var featureForm: FeatureForm? = null
+ private set
+
+ // IDEA: would be nice to collect these into a session object
+
+ private var currentFeature: ArcGISFeature? = null
+
+ private val _isStarted = MutableStateFlow(false)
+
+ // TODO: can this be defined in terms of whether currentFeature is null?
+ public val isStarted: StateFlow = _isStarted.asStateFlow()
+
+ private val _onFinish = MutableSharedFlow(
+ replay = 0, extraBufferCapacity = Int.MAX_VALUE, onBufferOverflow = BufferOverflow.DROP_OLDEST
+ )
+ public val onFinish: SharedFlow = _onFinish.asSharedFlow()
+
+ init {
+ // IDEA: Could we somehow tie this to the start method and use the scope from that, and suspend it until
+ // editing is complete? We would still need the stop method, and that would then have to be called asynchronously
+ // from another coroutine (potentially another thread, so may need to use a custom dispatcher for thread safety).
+ // It makes this class look a bit nicer internally I suppose, but it's just pushing the problem around because then
+ // we need to launch a coroutine to call start. But it's being launched in the viewmodel layer which makes more
+ // sense?...
+ scope.launch {
+ geometryEditor.geometry.collect {
+ // Only act when editor is started to avoid spurious updates based on the state of the editor
+ // when it's not being used.
+ if (geometryEditor.isStarted.value) {
+ currentFeature?.geometry = it
+ featureForm?.evaluateExpressions() // TODO: error handling?
+ }
+ }
+ }
+ }
+
+ public fun start(feature: ArcGISFeature) {
+ if (isStarted.value) return
+
+ assert(!geometryEditor.isStarted.value) {
+ "Geometry editor should not be started when feature editor is not started."
+ }
+
+ val table = feature.featureTable
+ // TODO: handle this case by not showing a form
+ require(table != null) { "The feature to be edited must have an associated table." }
+ val formDefinition = (table.layer as FeatureLayer).featureFormDefinition
+ // TODO: again, just don't show the form
+ require(formDefinition != null) { "The feature to be edited must have a form definition." }
+
+ currentFeature = feature
+ featureForm = FeatureForm(feature, formDefinition)
+
+ val geometry = feature.geometry
+ if (geometry?.let { !it.isEmpty } == true) geometryEditor.start(geometry)
+ else geometryEditor.start(table.geometryType) // TODO: all types supported?
+
+ updateGeometryEditorStyle(geometryEditor, feature, geometry)
+
+ (feature.featureTable?.layer as? FeatureLayer)?.setFeatureVisible(feature, false)
+
+ _isStarted.value = true
+ }
+
+ public fun stop() {
+ if (!isStarted.value) return
+
+ assert(geometryEditor.isStarted.value) {
+ "Geometry editor should be started whenever feature editor is started."
+ }
+
+ val feature = currentFeature
+ assert(currentFeature != null) {
+ "Current feature should not be null whenever feature editor is started."
+ }
+
+ _onFinish.tryEmit(FinishState.Stopped(feature!!))
+
+ resetState(feature)
+ }
+
+ public fun discard() {
+ if (!isStarted.value) return
+
+ assert(geometryEditor.isStarted.value) {
+ "Geometry editor should be started whenever feature editor is started."
+ }
+
+ val feature = currentFeature
+ assert(currentFeature != null) {
+ "Current feature should not be null whenever feature editor is started."
+ }
+
+ /*
+ We need separate stop and discard methods so that the feature editor knows whether it was the user's
+ intention to save the edits they made or not. It then needs to propagate this information to the app
+ developer so they can act accordingly. We have said that the feature editor won't do anything special to
+ save edits made to the feature and that it should be the app developer's responsibility instead. But the
+ app developer doesn't know which buttons are being pressed on the feature editor so they can't act
+ accordingly unless we tell them whether they are supposed to save the feature or not.
+
+ But it seemed weird to have stop and discard methods that do exactly the same thing. Refreshing the feature
+ seems like a light way to discard the edits without dabbling with the feature's table.
+ */
+ feature!!.refresh()
+
+ _onFinish.tryEmit(FinishState.Discarded(feature))
+ resetState(feature)
+ }
+
+ private fun resetState(feature: ArcGISFeature) {
+ featureForm = null
+ // Don't need to push the final geometry into the feature because it's done by
+ // the collection of the geometry event anyway.
+ geometryEditor.stop()
+ // TODO: technically the user could have started editing an invisible feature so we should preserve that when
+ // the editor stops.
+ (feature.featureTable?.layer as? FeatureLayer)?.setFeatureVisible(feature, true)
+ _isStarted.value = false
+ currentFeature = null
+ }
+}
+
+private fun updateGeometryEditorStyle(geometryEditor: GeometryEditor, feature: ArcGISFeature, geometry: Geometry?) {
+ // Get the feature symbol from the renderer or return if null.
+ val renderer = (feature.featureTable?.layer as? FeatureLayer)?.renderer
+ val featureSymbol = renderer?.getSymbol(feature, true) ?: return
+
+ // Determine the geometry of the feature.
+ val isGeometryPoint = if (geometry?.let { !it.isEmpty } == true) geometry is Point || geometry is Multipoint
+ else feature.featureTable?.geometryType == GeometryType.Point || feature.featureTable?.geometryType == GeometryType.Multipoint
+
+ val isGeometryLine = if (geometry?.let { !it.isEmpty } == true) geometry is Polyline
+ else feature.featureTable?.geometryType == GeometryType.Polyline
+
+ val isGeometryPolygon = if (geometry?.let { !it.isEmpty } == true) geometry is Polygon
+ else feature.featureTable?.geometryType == GeometryType.Polygon
+
+ // Apply symbology to the editor for the geometry types.
+ if (isGeometryPoint) {
+ geometryEditor.tool.style.apply {
+ vertexSymbol = featureSymbol
+ selectedVertexSymbol = featureSymbol
+ feedbackVertexSymbol = featureSymbol
+ vertexTextSymbol = null
+ }
+ } else if (isGeometryLine) {
+ geometryEditor.tool.style.apply {
+ val vertexOutlineSymbol = SimpleLineSymbol(SimpleLineSymbolStyle.Solid, color = Color.black)
+ when (featureSymbol) {
+ is SimpleLineSymbol -> {
+ val vertexSize = max(featureSymbol.width, 1f) * 5
+ val midVertexSize = max(featureSymbol.width, 1f) * 4
+
+ val vertex = SimpleMarkerSymbol(
+ color = featureSymbol.color.opaque,
+ style = SimpleMarkerSymbolStyle.Circle,
+ size = vertexSize
+ )
+ vertex.outline = vertexOutlineSymbol
+ val midVertex = SimpleMarkerSymbol(
+ color = Color.white,
+ style = SimpleMarkerSymbolStyle.Circle,
+ size = midVertexSize
+ )
+ midVertex.outline = vertexOutlineSymbol
+
+ vertexSymbol = vertex
+ selectedVertexSymbol = vertex
+ feedbackVertexSymbol = vertex
+ midVertexSymbol = midVertex
+ selectedMidVertexSymbol = midVertex
+ feedbackLineSymbol = SimpleLineSymbol(
+ SimpleLineSymbolStyle.Dash,
+ color = featureSymbol.color.opaque,
+ width = featureSymbol.width
+ )
+ lineSymbol = featureSymbol
+ vertexTextSymbol = null
+ }
+ is MultilayerPolylineSymbol -> {
+ var vertexSize = (vertexSymbol as? SimpleMarkerSymbol)?.size ?: 5f
+ var midVertexSize = (midVertexSymbol as? SimpleMarkerSymbol)?.size ?: 4f
+ var strokeWidth = (lineSymbol as? SimpleLineSymbol)?.width ?: 3f
+
+ // Find the first stroke symbol layer in the list of layers, if any.
+ val strokeSymbolLayer = featureSymbol.symbolLayers.filterIsInstance().firstOrNull()
+ if (strokeSymbolLayer != null) {
+ strokeWidth = strokeSymbolLayer.width.toFloat()
+ vertexSize = max(strokeWidth, 1f) * 5
+ midVertexSize = max(strokeWidth, 1f) * 4
+ }
+
+ val vertex = SimpleMarkerSymbol(
+ color = featureSymbol.color.opaque,
+ style = SimpleMarkerSymbolStyle.Circle,
+ size = vertexSize
+ )
+ vertex.outline = vertexOutlineSymbol
+ val midVertex = SimpleMarkerSymbol(
+ color = Color.white,
+ style = SimpleMarkerSymbolStyle.Circle,
+ size = midVertexSize
+ )
+ midVertex.outline = vertexOutlineSymbol
+
+ vertexSymbol = vertex
+ selectedVertexSymbol = vertex
+ feedbackVertexSymbol = vertex
+ midVertexSymbol = midVertex
+ selectedMidVertexSymbol = midVertex
+ feedbackLineSymbol = SimpleLineSymbol(
+ SimpleLineSymbolStyle.Dash,
+ color = featureSymbol.color.opaque,
+ width = strokeWidth
+ )
+ lineSymbol = featureSymbol
+ vertexTextSymbol = null
+ }
+ else -> {} // Use default symbology
+ }
+ }
+ } else if (isGeometryPolygon) {
+ geometryEditor.tool.style.apply {
+ val vertexOutlineSymbol = SimpleLineSymbol(SimpleLineSymbolStyle.Solid, color = Color.black)
+ when (featureSymbol) {
+ is SimpleFillSymbol -> {
+ // Defaults when outline is null
+ var vertexSize = (vertexSymbol as? SimpleMarkerSymbol)?.size ?: 5f
+ var midVertexSize = (midVertexSymbol as? SimpleMarkerSymbol)?.size ?: 4f
+ var outlineWidth = (lineSymbol as? SimpleLineSymbol)?.width ?: 3f
+ var outlineColor = Color.white
+
+ featureSymbol.outline?.let {
+ outlineColor = it.color.opaque
+ outlineWidth = it.width
+ vertexSize = max(outlineWidth, 1f) * 5
+ midVertexSize = max(outlineWidth, 1f) * 4
+ }
+
+ // Color can be null (no fill)
+ val vertex = SimpleMarkerSymbol(
+ color = featureSymbol.color.opaque,
+ style = SimpleMarkerSymbolStyle.Circle,
+ size = vertexSize
+ )
+ vertex.outline = vertexOutlineSymbol
+ val midVertex = SimpleMarkerSymbol(
+ color = Color.white,
+ style = SimpleMarkerSymbolStyle.Circle,
+ size = midVertexSize
+ )
+ midVertex.outline = vertexOutlineSymbol
+
+ vertexSymbol = vertex
+ selectedVertexSymbol = vertex
+ feedbackVertexSymbol = vertex
+ midVertexSymbol = midVertex
+ selectedMidVertexSymbol = midVertex
+ feedbackLineSymbol = SimpleLineSymbol(
+ SimpleLineSymbolStyle.Dash,
+ color = outlineColor,
+ width = outlineWidth
+ )
+ lineSymbol = featureSymbol // Could be null (no outline)
+ fillSymbol = featureSymbol
+ vertexTextSymbol = null
+ }
+ is MultilayerPolygonSymbol -> {
+ var vertexSize = (vertexSymbol as? SimpleMarkerSymbol)?.size ?: 5f
+ var midVertexSize = (midVertexSymbol as? SimpleMarkerSymbol)?.size ?: 4f
+ var symbolStrokeWidth = (lineSymbol as? SimpleLineSymbol)?.width ?: 3f
+
+ // Find the first stroke symbol layer in the list of layers, if any.
+ val strokeSymbolLayer = featureSymbol.symbolLayers.filterIsInstance().firstOrNull()
+ if (strokeSymbolLayer != null) {
+ symbolStrokeWidth = strokeSymbolLayer.width.toFloat()
+ vertexSize = max(symbolStrokeWidth, 1f) * 5
+ midVertexSize = max(symbolStrokeWidth, 1f) * 4
+ }
+
+ val vertex = SimpleMarkerSymbol(
+ color = featureSymbol.color.opaque,
+ style = SimpleMarkerSymbolStyle.Circle,
+ size = vertexSize
+ )
+ vertex.outline = vertexOutlineSymbol
+ val midVertex = SimpleMarkerSymbol(
+ color = Color.white,
+ style = SimpleMarkerSymbolStyle.Circle,
+ size = midVertexSize
+ )
+ midVertex.outline = vertexOutlineSymbol
+
+ vertexSymbol = vertex
+ selectedVertexSymbol = vertex
+ feedbackVertexSymbol = vertex
+ midVertexSymbol = midVertex
+ selectedMidVertexSymbol = midVertex
+ feedbackLineSymbol = SimpleLineSymbol(
+ SimpleLineSymbolStyle.Dash,
+ color = featureSymbol.color.opaque,
+ width = symbolStrokeWidth
+ )
+ lineSymbol = featureSymbol // Could be null (no outline)
+ fillSymbol = featureSymbol
+ vertexTextSymbol = null
+ }
+ is PictureMarkerSymbol -> {
+ val midVertex = SimpleMarkerSymbol(
+ color = Color.white,
+ style = SimpleMarkerSymbolStyle.Circle,
+ size = featureSymbol.toMultilayerSymbol().size / 3
+ )
+ midVertex.outline = vertexOutlineSymbol
+
+ vertexSymbol = featureSymbol
+ selectedVertexSymbol = featureSymbol
+ feedbackVertexSymbol = featureSymbol
+ midVertexSymbol = midVertex
+ selectedMidVertexSymbol = midVertex
+ feedbackLineSymbol = SimpleLineSymbol(
+ SimpleLineSymbolStyle.Dash,
+ featureSymbol.toMultilayerSymbol().color.opaque,
+ width = featureSymbol.toMultilayerSymbol().size / 5
+ )
+ lineSymbol = SimpleLineSymbol(
+ SimpleLineSymbolStyle.Dash,
+ featureSymbol.toMultilayerSymbol().color.opaque,
+ width = featureSymbol.toMultilayerSymbol().size / 5
+ )
+ fillSymbol = featureSymbol
+ vertexTextSymbol = null
+ }
+ else -> {} // Use default symbology
+ }
+ }
+ }
+}
+
+private val Color.opaque: Color
+ get() = Color.fromRgba(red, green, blue)
diff --git a/toolkit/featureeditor/src/main/java/com/arcgismaps/toolkit/featureeditor/FeatureEditorState.kt b/toolkit/featureeditor/src/main/java/com/arcgismaps/toolkit/featureeditor/FeatureEditorState.kt
new file mode 100644
index 000000000..785df2778
--- /dev/null
+++ b/toolkit/featureeditor/src/main/java/com/arcgismaps/toolkit/featureeditor/FeatureEditorState.kt
@@ -0,0 +1,50 @@
+/*
+ * COPYRIGHT 1995-2023 ESRI
+ *
+ * TRADE SECRETS: ESRI PROPRIETARY AND CONFIDENTIAL
+ * Unpublished material - all rights reserved under the
+ * Copyright Laws of the United States.
+ *
+ * For additional information, contact:
+ * Environmental Systems Research Institute, Inc.
+ * Attn: Contracts Dept
+ * 380 New York Street
+ * Redlands, California, USA 92373
+ *
+ * email: contracts@esri.com
+ */
+
+package com.arcgismaps.toolkit.featureeditor
+
+import com.arcgismaps.toolkit.featureforms.FeatureFormState
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+
+public class FeatureEditorState(
+ public val featureEditor: FeatureEditor,
+ public val featureFormState: FeatureFormState,
+ scope: CoroutineScope, // TODO: needed? it's also causing weird initialization problems further up in the app
+) {
+
+ private val _isStarted = MutableStateFlow(featureEditor.isStarted.value)
+ public val isStarted: StateFlow = _isStarted.asStateFlow()
+
+ init {
+ scope.launch(start = CoroutineStart.UNDISPATCHED) {
+ featureEditor.isStarted.collect { // TEST: make sure this works when the editor is already started
+ // Note that we don't clear the form from the feature form state when the feature editor clear its form
+ // because the feature form state doesn't support null forms.
+ // Instead we rely on the UI using isStarted to decide to hide the form once the editor has been
+ // stopped. As far as I can tell this is the same way that it works in the feature form micro app.
+ featureEditor.featureForm?.let { featureFormState.setFeatureForm(it) }
+ // Propagate this to our own isStarted flow so that we know the feature form state is being updated =
+ // before clients of this class see the new value of isStarted.
+ _isStarted.value = it
+ }
+ }
+ }
+}
diff --git a/toolkit/featureeditor/src/main/java/com/arcgismaps/toolkit/featureeditor/FeatureEditorView.kt b/toolkit/featureeditor/src/main/java/com/arcgismaps/toolkit/featureeditor/FeatureEditorView.kt
new file mode 100644
index 000000000..ec1225ed7
--- /dev/null
+++ b/toolkit/featureeditor/src/main/java/com/arcgismaps/toolkit/featureeditor/FeatureEditorView.kt
@@ -0,0 +1,288 @@
+/*
+ * COPYRIGHT 1995-2023 ESRI
+ *
+ * TRADE SECRETS: ESRI PROPRIETARY AND CONFIDENTIAL
+ * Unpublished material - all rights reserved under the
+ * Copyright Laws of the United States.
+ *
+ * For additional information, contact:
+ * Environmental Systems Research Institute, Inc.
+ * Attn: Contracts Dept
+ * 380 New York Street
+ * Redlands, California, USA 92373
+ *
+ * email: contracts@esri.com
+ */
+
+package com.arcgismaps.toolkit.featureeditor
+
+import androidx.compose.foundation.gestures.Orientation
+import androidx.compose.foundation.gestures.scrollable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.IntrinsicSize
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Button
+import androidx.compose.material3.Divider
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.RectangleShape
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import com.arcgismaps.toolkit.featureforms.FeatureForm
+
+// TODO: should name contain "view"?
+@Composable
+public fun FeatureEditorView(
+ featureEditorState: FeatureEditorState,
+ modifier: Modifier = Modifier,
+ useSideBySideView: Boolean = false,
+ map: @Composable () -> Unit,
+) {
+ if (useSideBySideView) SideBySideFeatureEditorView(
+ featureEditorState = featureEditorState,
+ modifier = modifier,
+ map = map,
+ ) else CompactFeatureEditorView(
+ featureEditorState = featureEditorState,
+ modifier = modifier,
+ map = map,
+ )
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun CompactFeatureEditorView(
+ featureEditorState: FeatureEditorState,
+ modifier: Modifier = Modifier,
+ map: @Composable () -> Unit,
+) {
+ var isBottomSheetVisible by remember { mutableStateOf(false) }
+
+ Box(modifier = modifier.fillMaxSize()) {
+ map()
+
+ FeatureEditorToolbar(
+ onAttributeButtonPress = { isBottomSheetVisible = !isBottomSheetVisible },
+ featureEditorState = featureEditorState,
+ attributeButtonState =
+ if (isBottomSheetVisible) AttributeButtonState.SHOW_GEOMETRY else AttributeButtonState.SHOW_ATTRIBUTES,
+ )
+
+ if (isBottomSheetVisible) {
+ ModalBottomSheet(onDismissRequest = { isBottomSheetVisible = false }) {
+ FeatureForm(featureFormState = featureEditorState.featureFormState)
+ }
+ }
+ }
+}
+
+@Composable
+private fun SideBySideFeatureEditorView(
+ featureEditorState: FeatureEditorState,
+ modifier: Modifier = Modifier,
+ map: @Composable () -> Unit,
+) {
+ val isStarted by featureEditorState.isStarted.collectAsState()
+ Row(modifier = modifier) { // TODO: is it correct to use the incoming modifier here and nowhere else?
+ Box(modifier = Modifier
+ .fillMaxHeight()
+ .fillMaxWidth(0.5f)) {
+ map()
+
+ FeatureEditorToolbar(
+ onAttributeButtonPress = {},
+ featureEditorState = featureEditorState,
+ attributeButtonState = AttributeButtonState.HIDE,
+ )
+ }
+
+ if (isStarted) FeatureForm(
+ featureFormState = featureEditorState.featureFormState,
+ modifier = Modifier.fillMaxSize()
+ ) else Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize()) {
+ Text(
+ text = "Select a feature to begin editing!",
+ fontWeight = FontWeight.Bold,
+ textAlign = TextAlign.Center
+ )
+ }
+ }
+}
+
+@Composable
+private fun FeatureEditorToolbar(
+ onAttributeButtonPress: () -> Unit,
+ attributeButtonState: AttributeButtonState,
+ featureEditorState: FeatureEditorState
+) {
+
+ var showGeometryButtonGroup by remember { mutableStateOf(false) }
+ val isStarted by featureEditorState.isStarted.collectAsState()
+ Row(
+ horizontalArrangement = Arrangement.Center,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(IntrinsicSize.Min),
+ ) {
+ Surface(shape = RoundedCornerShape(bottomStart = 3.dp, bottomEnd = 3.dp)) {
+ Row(
+ horizontalArrangement = Arrangement.Center,
+ modifier = Modifier.padding(2.dp)
+ ) {
+
+ ToolbarButton(
+ onClick = { showGeometryButtonGroup = !showGeometryButtonGroup },
+ enabled = isStarted,
+ contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp)
+ ) {
+ Icon(
+ painter = painterResource(id = R.drawable.baseline_swap_vert_24),
+ contentDescription = "Swap toolbar"
+ )
+ }
+
+ Divider(
+ color = Color.Transparent,
+ modifier = Modifier
+ .fillMaxHeight()
+ .width(10.dp)
+ .padding(1.dp)
+ )
+
+ if (showGeometryButtonGroup && isStarted) GeometryButtonGroup(
+ featureEditorState = featureEditorState,
+ attributeButtonState = attributeButtonState,
+ ) else ControlButtonGroup(
+ isStarted = isStarted,
+ onAttributeButtonPress = onAttributeButtonPress,
+ attributeButtonState = attributeButtonState,
+ featureEditorState = featureEditorState,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun ControlButtonGroup(
+ isStarted: Boolean,
+ onAttributeButtonPress: () -> Unit,
+ attributeButtonState: AttributeButtonState,
+ featureEditorState: FeatureEditorState,
+) {
+ if (attributeButtonState != AttributeButtonState.HIDE) {
+ val useGeometryIcon = attributeButtonState == AttributeButtonState.SHOW_GEOMETRY
+ ToolbarButton(
+ onClick = onAttributeButtonPress,
+ enabled = isStarted,
+ ) {
+ Icon(
+ painter = painterResource(
+ id = if (useGeometryIcon) R.drawable.baseline_edit_24 else R.drawable.baseline_edit_note_24
+ ),
+ contentDescription = if (useGeometryIcon) "Edit geometry" else "Edit attributes"
+ )
+ }
+ }
+ ToolbarButton(
+ onClick = { featureEditorState.featureEditor.discard() },
+ enabled = isStarted,
+ ) {
+ Icon(
+ painter = painterResource(id = R.drawable.baseline_delete_forever_24),
+ contentDescription = "Discard"
+ )
+ }
+ ToolbarButton(
+ onClick = { featureEditorState.featureEditor.stop() },
+ enabled = isStarted,
+ ) {
+ Icon(
+ painter = painterResource(id = R.drawable.baseline_check_circle_24),
+ contentDescription = "Stop"
+ )
+ }
+}
+
+@Composable
+private fun GeometryButtonGroup(
+ featureEditorState: FeatureEditorState,
+ attributeButtonState: AttributeButtonState,
+) {
+ ToolbarButton(
+ onClick = { featureEditorState.featureEditor.geometryEditor.undo() },
+ enabled = featureEditorState.featureEditor.geometryEditor.canUndo.collectAsState().value
+ ) {
+ Icon(
+ painter = painterResource(id = R.drawable.baseline_undo_24),
+ contentDescription = "Undo"
+ )
+ }
+
+ ToolbarButton(
+ onClick = { featureEditorState.featureEditor.geometryEditor.redo() },
+ enabled = featureEditorState.featureEditor.geometryEditor.canRedo.collectAsState().value
+ ) {
+ Icon(
+ painter = painterResource(id = R.drawable.baseline_redo_24),
+ contentDescription = "Redo"
+ )
+ }
+
+ if (attributeButtonState != AttributeButtonState.HIDE) {
+ ToolbarButton(
+ onClick = {},
+ enabled = false
+ ) {
+ Icon(
+ painter = painterResource(id = R.drawable.baseline_local_hotel_24),
+ contentDescription =
+ "Local hotel we need to have the same number of buttons in both toolbars or else they don't align"
+ )
+ }
+ }
+}
+
+@Composable
+private fun ToolbarButton(
+ onClick: () -> Unit,
+ enabled: Boolean,
+ contentPadding: PaddingValues = PaddingValues(8.dp),
+ content: @Composable RowScope.() -> Unit
+) {
+ Button(
+ onClick = onClick,
+ enabled = enabled,
+ shape = RectangleShape,
+ modifier = Modifier.padding(1.dp),
+ contentPadding = contentPadding,
+ content = content,
+ )
+}
+
+private enum class AttributeButtonState { SHOW_ATTRIBUTES, SHOW_GEOMETRY, HIDE }
diff --git a/toolkit/featureeditor/src/main/res/drawable/baseline_check_circle_24.xml b/toolkit/featureeditor/src/main/res/drawable/baseline_check_circle_24.xml
new file mode 100644
index 000000000..345be308b
--- /dev/null
+++ b/toolkit/featureeditor/src/main/res/drawable/baseline_check_circle_24.xml
@@ -0,0 +1,5 @@
+
+
+
diff --git a/toolkit/featureeditor/src/main/res/drawable/baseline_delete_forever_24.xml b/toolkit/featureeditor/src/main/res/drawable/baseline_delete_forever_24.xml
new file mode 100644
index 000000000..39260e0ca
--- /dev/null
+++ b/toolkit/featureeditor/src/main/res/drawable/baseline_delete_forever_24.xml
@@ -0,0 +1,5 @@
+
+
+
diff --git a/toolkit/featureeditor/src/main/res/drawable/baseline_edit_24.xml b/toolkit/featureeditor/src/main/res/drawable/baseline_edit_24.xml
new file mode 100644
index 000000000..1fa259070
--- /dev/null
+++ b/toolkit/featureeditor/src/main/res/drawable/baseline_edit_24.xml
@@ -0,0 +1,5 @@
+
+
+
diff --git a/toolkit/featureeditor/src/main/res/drawable/baseline_edit_note_24.xml b/toolkit/featureeditor/src/main/res/drawable/baseline_edit_note_24.xml
new file mode 100644
index 000000000..124f36709
--- /dev/null
+++ b/toolkit/featureeditor/src/main/res/drawable/baseline_edit_note_24.xml
@@ -0,0 +1,5 @@
+
+
+
diff --git a/toolkit/featureeditor/src/main/res/drawable/baseline_local_hotel_24.xml b/toolkit/featureeditor/src/main/res/drawable/baseline_local_hotel_24.xml
new file mode 100644
index 000000000..d7a8868c3
--- /dev/null
+++ b/toolkit/featureeditor/src/main/res/drawable/baseline_local_hotel_24.xml
@@ -0,0 +1,5 @@
+
+
+
diff --git a/toolkit/featureeditor/src/main/res/drawable/baseline_notes_24.xml b/toolkit/featureeditor/src/main/res/drawable/baseline_notes_24.xml
new file mode 100644
index 000000000..f6d89fb0b
--- /dev/null
+++ b/toolkit/featureeditor/src/main/res/drawable/baseline_notes_24.xml
@@ -0,0 +1,5 @@
+
+
+
diff --git a/toolkit/featureeditor/src/main/res/drawable/baseline_redo_24.xml b/toolkit/featureeditor/src/main/res/drawable/baseline_redo_24.xml
new file mode 100644
index 000000000..699aeb22c
--- /dev/null
+++ b/toolkit/featureeditor/src/main/res/drawable/baseline_redo_24.xml
@@ -0,0 +1,5 @@
+
+
+
diff --git a/toolkit/featureeditor/src/main/res/drawable/baseline_swap_vert_24.xml b/toolkit/featureeditor/src/main/res/drawable/baseline_swap_vert_24.xml
new file mode 100644
index 000000000..b9f63bc00
--- /dev/null
+++ b/toolkit/featureeditor/src/main/res/drawable/baseline_swap_vert_24.xml
@@ -0,0 +1,5 @@
+
+
+
diff --git a/toolkit/featureeditor/src/main/res/drawable/baseline_undo_24.xml b/toolkit/featureeditor/src/main/res/drawable/baseline_undo_24.xml
new file mode 100644
index 000000000..b86c43051
--- /dev/null
+++ b/toolkit/featureeditor/src/main/res/drawable/baseline_undo_24.xml
@@ -0,0 +1,5 @@
+
+
+
diff --git a/toolkit/featureeditor/src/main/res/drawable/ic_compass.xml b/toolkit/featureeditor/src/main/res/drawable/ic_compass.xml
new file mode 100644
index 000000000..39bdd3db4
--- /dev/null
+++ b/toolkit/featureeditor/src/main/res/drawable/ic_compass.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
diff --git a/toolkit/featureforms/.gitignore b/toolkit/featureforms/.gitignore
new file mode 100644
index 000000000..796b96d1c
--- /dev/null
+++ b/toolkit/featureforms/.gitignore
@@ -0,0 +1 @@
+/build
diff --git a/toolkit/featureforms/README.md b/toolkit/featureforms/README.md
new file mode 100644
index 000000000..9f1bb0291
--- /dev/null
+++ b/toolkit/featureforms/README.md
@@ -0,0 +1,70 @@
+# FeatureForm
+
+## Description
+
+The Forms toolkit component enables users to edit field values of features in a layer using forms that have been configured externally (using either in the the Web Map Viewer or the Fields Maps web app).
+
+## Behavior
+
+To see it in action, check out the [microapp](../../microapps/FeatureFormsApp).
+
+## Usage
+
+The `FeatureForm` composable is provided with its `FeatureFormState` and its default implementation `FeatureFormStateImpl` which is also available through the Factory function `FeatureFormState()`.
+They can be used either as a simple state class or within a ViewModel.
+
+
+#### Creating the state using the Factory function
+
+```kotlin
+val formState = FeatureFormState()
+```
+
+#### Using ViewModels
+
+```kotlin
+class MyViewModel : FeatureFormState by FeatureFormState() {
+ ...
+}
+```
+
+#### Creating the FeatureForm
+
+The FeatureForm should be displayed in a Container and passed the `FeatureFormState`.
+It's visibility and the container are external and should be controlled by the calling Composable.
+
+```kotlin
+@Composable
+fun MyComposable() {
+ // use the inEditingMode to control the visibility of the FeatureForm
+ val inEditingMode by formState.inEditingMode.collectAsState()
+ // a container
+ MyContainer(modifier = Modifier) {
+ if (inEditingMode) {
+ // show the FeatureForm only when inEditingMode is true
+ FeatureForm(
+ // pass in our FeatureFormState
+ featureFormState = formState,
+ // control the layout using the modifier property
+ modifier = Modifier.fillMaxSize()
+ )
+ }
+ }
+}
+```
+
+#### Updating the `FeatureFormState`
+
+Changes to the `FeatureFormState` will cause a recomposition.
+
+```kotlin
+@Composable
+fun MyComposable() {
+ ....
+ // set the feature, this causes recomposition
+ formState.setFeature(feature)
+ // set formViewModel to editing state
+ formState.setEditingActive(true)
+ ....
+}
+```
diff --git a/toolkit/featureforms/build.gradle.kts b/toolkit/featureforms/build.gradle.kts
new file mode 100644
index 000000000..e34279c9c
--- /dev/null
+++ b/toolkit/featureforms/build.gradle.kts
@@ -0,0 +1,63 @@
+plugins {
+ id("com.android.library")
+ id("org.jetbrains.kotlin.android")
+ id("artifact-deploy")
+ id("com.google.android.libraries.mapsplatform.secrets-gradle-plugin")
+}
+
+secrets {
+ defaultPropertiesFileName = "secrets.defaults.properties"
+}
+
+android {
+ namespace = "com.arcgismaps.toolkit.featureforms"
+ compileSdk = libs.versions.compileSdk.get().toInt()
+
+ defaultConfig {
+ minSdk = libs.versions.minSdk.get().toInt()
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ consumerProguardFiles("consumer-rules.pro")
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_1_8
+ targetCompatibility = JavaVersion.VERSION_1_8
+ }
+ kotlinOptions {
+ jvmTarget = "1.8"
+ }
+ @Suppress("UnstableApiUsage")
+ buildFeatures {
+ compose = true
+ buildConfig = true
+ }
+ composeOptions {
+ kotlinCompilerExtensionVersion = libs.versions.androidxComposeCompiler.get()
+ }
+ // If this were not an android project, we would just write `explicitApi()` in the Kotlin scope.
+ // but as an android project could write `freeCompilerArgs = listOf("-Xexplicit-api=strict")`
+ // in the kotlinOptions above, but that would enforce api rules on the test code, which we don't want.
+ tasks.withType {
+ if ("Test" !in name) {
+ kotlinOptions.freeCompilerArgs += "-Xexplicit-api=strict"
+ }
+ }
+}
+
+dependencies {
+ api(arcgis.mapsSdk)
+ implementation(libs.bundles.composeCore)
+ implementation(libs.bundles.core)
+ implementation(libs.androidx.lifecycle.runtime.ktx)
+ implementation(libs.androidx.activity.compose)
+ implementation(libs.androidx.material.icons)
+ testImplementation(libs.bundles.unitTest)
+ androidTestImplementation(libs.bundles.composeTest)
+ debugImplementation(libs.bundles.debug)
+}
diff --git a/toolkit/featureforms/consumer-rules.pro b/toolkit/featureforms/consumer-rules.pro
new file mode 100644
index 000000000..e69de29bb
diff --git a/toolkit/featureforms/src/androidTest/java/com/arcgismaps/toolkit/featureforms/ComboBoxFieldTests.kt b/toolkit/featureforms/src/androidTest/java/com/arcgismaps/toolkit/featureforms/ComboBoxFieldTests.kt
new file mode 100644
index 000000000..1d99cee82
--- /dev/null
+++ b/toolkit/featureforms/src/androidTest/java/com/arcgismaps/toolkit/featureforms/ComboBoxFieldTests.kt
@@ -0,0 +1,443 @@
+/*
+ * Copyright 2023 Esri
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.arcgismaps.toolkit.featureforms
+
+import android.content.Context
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.semantics.SemanticsProperties
+import androidx.compose.ui.test.SemanticsNodeInteraction
+import androidx.compose.ui.test.assertContentDescriptionContains
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.assertIsNotFocused
+import androidx.compose.ui.test.assertTextEquals
+import androidx.compose.ui.test.hasText
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onAllNodesWithContentDescription
+import androidx.compose.ui.test.onChildren
+import androidx.compose.ui.test.onNodeWithContentDescription
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.onRoot
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.printToLog
+import androidx.test.platform.app.InstrumentationRegistry
+import com.arcgismaps.ArcGISEnvironment
+import com.arcgismaps.data.ArcGISFeature
+import com.arcgismaps.data.QueryParameters
+import com.arcgismaps.mapping.ArcGISMap
+import com.arcgismaps.mapping.featureforms.ComboBoxFormInput
+import com.arcgismaps.mapping.featureforms.FeatureForm
+import com.arcgismaps.mapping.featureforms.FeatureFormDefinition
+import com.arcgismaps.mapping.featureforms.FieldFormElement
+import com.arcgismaps.mapping.layers.FeatureLayer
+import junit.framework.TestCase.fail
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.BeforeClass
+import org.junit.Rule
+import org.junit.Test
+
+class ComboBoxFieldTests {
+ private val descriptionSemanticLabel = "description"
+ private val clearTextSemanticLabel = "Clear text button"
+ private val optionsIconSemanticLabel = "field icon"
+ private val comboBoxDialogListSemanticLabel = "ComboBoxDialogLazyColumn"
+ private val comboBoxDialogDoneButtonSemanticLabel = "combo box done selection"
+ private val noValueRowSemanticLabel = "no value row"
+ private lateinit var context: Context
+
+ private val featureForm by lazy {
+ sharedFeatureForm!!
+ }
+
+ private var errorTextColor : Color? = null
+
+ private fun getFormElementWithLabel(label: String): FieldFormElement {
+ return featureForm.elements
+ .filterIsInstance()
+ .first {
+ it.label == label
+ }
+ }
+
+ @Before
+ fun setUp() {
+ context = InstrumentationRegistry.getInstrumentation().context
+ }
+
+ @get:Rule
+ val composeTestRule = createComposeRule()
+
+ @Before
+ fun setContent() {
+ composeTestRule.setContent {
+ errorTextColor = MaterialTheme.colorScheme.error
+ val state = FeatureFormState()
+ state.setFeatureForm(featureForm)
+ FeatureForm(featureFormState = state)
+ }
+ }
+
+ /**
+ * Test case 3.1:
+ * Given a ComboBoxField with a pre-existing value, description and a no value label
+ * When the pre-existing value is cleared
+ * Then the ComboBoxField shows the noValueLabel
+ * https://devtopia.esri.com/runtime/common-toolkit/blob/main/designs/Forms/FormsTestDesign.md#test-case-31-pre-existing-value-description-clear-button-no-value-label
+ */
+ @Test
+ fun testClearValueNoValueLabel() {
+ val formElement = getFormElementWithLabel("Combo String")
+ val input = formElement.input as ComboBoxFormInput
+ // find the field with the the label
+ val comboBoxField = composeTestRule.onNodeWithText(formElement.label)
+ // assert it is displayed and not focused
+ comboBoxField.assertIsDisplayed()
+ comboBoxField.assertIsNotFocused()
+ // find the child node with the description semantic label
+ val descriptionNode = comboBoxField.onChildWithContentDescription(descriptionSemanticLabel)
+ val hasDescriptionMatcher = hasText(formElement.description)
+ // validate the correct description is visible
+ assert(hasDescriptionMatcher.matches(descriptionNode.fetchSemanticsNode())) {
+ "Failed to assert the following: ${hasDescriptionMatcher.description}"
+ }
+ // validate that the pre-populated value shown shown in accurate and as expected
+ // assertTextEquals matches the Text(the label) and Editable Text (the actual editable input text)
+ comboBoxField.assertTextEquals(formElement.label, formElement.formattedValue)
+ // find the clear text node within its children
+ val clearButton = comboBoxField.onChildWithContentDescription(clearTextSemanticLabel)
+ // validate the clear icon is visible
+ clearButton.assertIsDisplayed()
+ // clear the value
+ clearButton.performClick()
+ // assert "no value" placeholder is visible
+ // assertTextEquals matches the Text(the label) and Editable Text (the placeholder here
+ // due to the use of the PlaceHolderTransformation)
+ comboBoxField.assertTextEquals(
+ formElement.label,
+ input.noValueLabel.ifEmpty { context.getString(R.string.no_value) }
+ )
+ }
+
+ /**
+ * Test case 3.2:
+ * Given a ComboBoxField with no pre-existing value, description and a no value label
+ * When the field is observed
+ * Then the ComboBoxField shows the noValueLabel and the options menu icon is visible
+ * https://devtopia.esri.com/runtime/common-toolkit/blob/main/designs/Forms/FormsTestDesign.md#test-case-32-no-pre-existing-value-no-value-label-options-button
+ */
+ @Test
+ fun testNoValueAndNoValueLabel() {
+ val formElement = getFormElementWithLabel("Combo Integer")
+ val input = formElement.input as ComboBoxFormInput
+ // find the field with the the label
+ val comboBoxField = composeTestRule.onNodeWithText(formElement.label)
+ // assert it is displayed and not focused
+ comboBoxField.assertIsDisplayed()
+ comboBoxField.assertIsNotFocused()
+ // find the child node with the description semantic label
+ val descriptionNode = comboBoxField.onChildWithContentDescription(descriptionSemanticLabel)
+ val hasDescriptionMatcher = hasText(formElement.description)
+ // validate the correct description is visible
+ assert(hasDescriptionMatcher.matches(descriptionNode.fetchSemanticsNode())) {
+ "Failed to assert the following: ${hasDescriptionMatcher.description}"
+ }
+ // assert "no value" placeholder is visible
+ // assertTextEquals matches the Text(the label) and Editable Text (the placeholder here
+ // due to the use of the PlaceHolderTransformation)
+ comboBoxField.assertTextEquals(
+ formElement.label,
+ input.noValueLabel.ifEmpty { context.getString(R.string.no_value) }
+ )
+ // validate that the options icon is visible
+ val optionsIconNode =
+ comboBoxField.assertContentDescriptionContains(optionsIconSemanticLabel)
+ optionsIconNode.assertIsDisplayed()
+ }
+
+ /**
+ * Test case 3.3:
+ * Given a ComboBoxField with a pre-existing value, description and a no value label
+ * When the ComboBoxField is tapped
+ * Then a ComboBoxDialog is shown AND
+ * When a coded value is selected from the dialog and dismissed
+ * Then the ComboBoxField also shows this selected value
+ * https://devtopia.esri.com/runtime/common-toolkit/blob/main/designs/Forms/FormsTestDesign.md#test-case-33-pick-a-value
+ */
+ @Test
+ fun testEnteredValueWithComboBoxPicker() {
+ val formElement = getFormElementWithLabel("Combo String")
+ val input = formElement.input as ComboBoxFormInput
+ // find the field with the the label
+ val comboBoxField = composeTestRule.onNodeWithText(formElement.label)
+ // assert it is displayed and not focused
+ comboBoxField.assertIsDisplayed()
+ comboBoxField.assertIsNotFocused()
+ // find the child node with the description semantic label
+ val descriptionNode = comboBoxField.onChildWithContentDescription(descriptionSemanticLabel)
+ val hasDescriptionMatcher = hasText(formElement.description)
+ // validate the correct description is visible
+ assert(hasDescriptionMatcher.matches(descriptionNode.fetchSemanticsNode())) {
+ "Failed to assert the following: ${hasDescriptionMatcher.description}"
+ }
+ // validate that the pre-populated value shown shown in accurate and as expected
+ // assertTextEquals matches the Text(the label) and Editable Text (the actual editable input text)
+ comboBoxField.assertTextEquals(formElement.label, formElement.formattedValue)
+ // tap the value to bring up the picker
+ comboBoxField.performClick()
+ // find the dialog
+ val comboBoxDialogList =
+ composeTestRule.onNodeWithContentDescription(comboBoxDialogListSemanticLabel)
+ comboBoxDialogList.assertIsDisplayed()
+ val codedValueToSelect = input.codedValues.first().name
+ // find the first list item and tap on it
+ val listItem =
+ comboBoxDialogList.onChildWithContentDescription("$codedValueToSelect list item")
+ listItem.assertIsDisplayed()
+ listItem.performClick()
+ // find and tap the done button
+ val doneButton =
+ composeTestRule.onNodeWithContentDescription(comboBoxDialogDoneButtonSemanticLabel)
+ doneButton.performClick()
+ // validate the selection has changed
+ comboBoxField.assertTextEquals(formElement.label, codedValueToSelect)
+ }
+
+ /**
+ * Test case 3.4:
+ * Given a ComboBoxField with a pre-existing value, description and a no value label
+ * When the ComboBoxField is tapped
+ * Then the ComboBoxDialog is shown and a noValueLabel row is visible and selectable
+ * https://devtopia.esri.com/runtime/common-toolkit/blob/main/designs/Forms/FormsTestDesign.md#test-case-34-picker-with-a-novaluelabel-row
+ */
+ @Test
+ fun testNoValueRow() {
+ val formElement = getFormElementWithLabel("Combo String")
+ val input = formElement.input as ComboBoxFormInput
+ // find the field with the the label
+ val comboBoxField = composeTestRule.onNodeWithText(formElement.label)
+ // assert it is displayed and not focused
+ comboBoxField.assertIsDisplayed()
+ comboBoxField.assertIsNotFocused()
+ // validate that the pre-populated value shown shown in accurate and as expected
+ // assertTextEquals matches the Text(the label) and Editable Text (the actual editable input text)
+ comboBoxField.assertTextEquals(formElement.label, formElement.formattedValue)
+ // open the picker
+ comboBoxField.performClick()
+ // find the dialog
+ val comboBoxDialogList =
+ composeTestRule.onNodeWithContentDescription(comboBoxDialogListSemanticLabel)
+ comboBoxDialogList.assertIsDisplayed()
+ val noValueLabel = input.noValueLabel.ifEmpty { context.getString(R.string.no_value) }
+ // this field has a no value label and not required, hence check for the row
+ val noValueRow = comboBoxDialogList.onChildWithContentDescription(
+ noValueRowSemanticLabel
+ ).assertIsDisplayed()
+ // select the no value row
+ noValueRow.performClick()
+ // find and tap the done button
+ val doneButton =
+ composeTestRule.onNodeWithContentDescription(comboBoxDialogDoneButtonSemanticLabel)
+ doneButton.performClick()
+ // validate the selection has changed
+ comboBoxField.assertTextEquals(formElement.label, noValueLabel)
+ }
+
+ /**
+ * Test case 3.5:
+ * Given a ComboBoxField with a pre-existing value, description and is required
+ * When the ComboBoxField value is cleared
+ * Then the helper text is visible, has the platform default error color and says "Required" AND
+ * When the ComboBoxField is tapped
+ * Then a noValueLabel row is not present
+ * https://devtopia.esri.com/runtime/common-toolkit/blob/main/designs/Forms/FormsTestDesign.md#test-case-35-required-value
+ */
+ @Test
+ fun testRequiredValueWithComboBoxPicker() {
+ val formElement = getFormElementWithLabel("Required Combo Box")
+ val input = formElement.input as ComboBoxFormInput
+ val requiredLabel = "${formElement.label} *"
+ // find the field with the the label
+ val comboBoxField = composeTestRule.onNodeWithText(requiredLabel)
+ // assert it is displayed and not focused
+ comboBoxField.assertIsDisplayed()
+ comboBoxField.assertIsNotFocused()
+ // validate that the pre-populated value shown shown in accurate and as expected
+ // assertTextEquals matches the Text(the label) and Editable Text (the actual editable input text)
+ comboBoxField.assertTextEquals(requiredLabel, formElement.formattedValue)
+ // find the clear text node within its children
+ val clearButton = comboBoxField.onChildWithContentDescription(clearTextSemanticLabel)
+ // validate the clear icon is visible
+ clearButton.assertIsDisplayed()
+ // clear the value
+ clearButton.performClick()
+ composeTestRule.onRoot().printToLog("TAG")
+ // assert "Enter Value" placeholder is visible
+ comboBoxField.assertTextEquals(requiredLabel, context.getString(R.string.enter_value))
+ // validate required text is visible and is in error color
+ comboBoxField.onChildWithText(context.getString(R.string.required)).assertTextColor(errorTextColor!!)
+
+ // open the picker
+ comboBoxField.performClick()
+ // find the dialog
+ val comboBoxDialogList =
+ composeTestRule.onNodeWithContentDescription(comboBoxDialogListSemanticLabel)
+ comboBoxDialogList.assertIsDisplayed()
+ // validate a noValueLabel row is not present
+ assert(
+ composeTestRule.onAllNodesWithContentDescription(noValueRowSemanticLabel)
+ .fetchSemanticsNodes().isEmpty()
+ )
+ val codedValueToSelect = input.codedValues.first().name
+ // find the first list item and tap on it
+ val listItem =
+ comboBoxDialogList.onChildWithContentDescription("$codedValueToSelect list item")
+ listItem.assertIsDisplayed()
+ listItem.performClick()
+ // find and tap the done button
+ val doneButton =
+ composeTestRule.onNodeWithContentDescription(comboBoxDialogDoneButtonSemanticLabel)
+ doneButton.performClick()
+ // validate the selection has changed
+ comboBoxField.assertTextEquals(requiredLabel, codedValueToSelect)
+ }
+
+ /**
+ * Test case 3.6:
+ * Given a ComboBoxField with a pre-existing value, description and showNoValueOption is Hide
+ * When the ComboBoxField is tapped
+ * Then a noValueLabel row is not present
+ * https://devtopia.esri.com/runtime/common-toolkit/blob/main/designs/Forms/FormsTestDesign.md#test-case-36-novalueoption-is-hide
+ */
+ @Test
+ fun testNoValueOptionHidden() {
+ val formElement = getFormElementWithLabel("Combo No Value False")
+ val input = formElement.input as ComboBoxFormInput
+ // find the field with the the label
+ val comboBoxField = composeTestRule.onNodeWithText(formElement.label)
+ // assert it is displayed and not focused
+ comboBoxField.assertIsDisplayed()
+ comboBoxField.assertIsNotFocused()
+ // validate that the pre-populated value shown shown in accurate and as expected and that
+ // no placeholder is visible. blank space is used here due to PlaceHolderTransformation
+ comboBoxField.assertTextEquals(formElement.label, " ")
+
+ // open the picker
+ comboBoxField.performClick()
+ // find the dialog
+ val comboBoxDialogList =
+ composeTestRule.onNodeWithContentDescription(comboBoxDialogListSemanticLabel)
+ comboBoxDialogList.assertIsDisplayed()
+ // validate a noValueLabel row is not displayed
+ assert(
+ composeTestRule.onAllNodesWithContentDescription(noValueRowSemanticLabel)
+ .fetchSemanticsNodes().isEmpty()
+ )
+ val codedValueToSelect = input.codedValues.first().name
+ // find the first list item and tap on it
+ val listItem =
+ comboBoxDialogList.onChildWithContentDescription("$codedValueToSelect list item")
+ listItem.assertIsDisplayed()
+ listItem.performClick()
+ // find and tap the done button
+ val doneButton =
+ composeTestRule.onNodeWithContentDescription(comboBoxDialogDoneButtonSemanticLabel)
+ doneButton.performClick()
+ // validate the selection has changed
+ comboBoxField.assertTextEquals(formElement.label, codedValueToSelect)
+ }
+
+ companion object {
+ private var sharedFeatureFormDefinition: FeatureFormDefinition? = null
+ private var sharedFeatureForm: FeatureForm? = null
+ private var sharedFeature: ArcGISFeature? = null
+ private var sharedMap: ArcGISMap? = null
+
+ @BeforeClass
+ @JvmStatic
+ fun setupClass() = runTest {
+ ArcGISEnvironment.authenticationManager.arcGISAuthenticationChallengeHandler =
+ FeatureFormsTestChallengeHandler(
+ BuildConfig.webMapUser,
+ BuildConfig.webMapPassword
+ )
+
+ sharedMap =
+ ArcGISMap("https://runtimecoretest.maps.arcgis.com/home/item.html?id=ed930cf0eb724ea49c6bccd8fd3dd9af")
+ sharedMap?.load()?.onFailure { fail("failed to load webmap with ${it.message}") }
+ val featureLayer = sharedMap?.operationalLayers?.first() as? FeatureLayer
+ featureLayer?.let { layer ->
+ layer.load().onFailure { fail("failed to load layer with ${it.message}") }
+ sharedFeatureFormDefinition = layer.featureFormDefinition!!
+ val parameters = QueryParameters().also {
+ it.objectIds.add(2L)
+ it.maxFeatures = 1
+ }
+ layer.featureTable?.queryFeatures(parameters)?.onSuccess {
+ sharedFeature = it.filterIsInstance().firstOrNull()
+ if (sharedFeature == null) fail("failed to fetch feature")
+ sharedFeature?.load()
+ ?.onFailure { fail("failed to load feature with ${it.message}") }
+ sharedFeatureForm = FeatureForm(sharedFeature!!, sharedFeatureFormDefinition!!)
+ sharedFeatureForm!!.evaluateExpressions()
+ }?.onFailure {
+ fail("failed to query features on layer's featuretable with ${it.message}")
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Returns the child node with the given text [value]. This only checks for the
+ * children with a depth of 1. An exception is thrown if the child with the content description
+ * does not exist.
+ */
+internal fun SemanticsNodeInteraction.onChildWithText(value: String): SemanticsNodeInteraction {
+ val nodes = onChildren()
+ val count = nodes.fetchSemanticsNodes().count()
+
+ for (i in 0 until count) {
+ val semanticsNode = nodes[i].fetchSemanticsNode()
+ if (semanticsNode.config[SemanticsProperties.Text].toList().map
+ {
+ it.text
+ }.contains(value)
+ ) {
+ return nodes[i]
+ }
+ }
+ throw AssertionError("No node exists with the given content description : $value")
+}
+
+/**
+ * Returns the child node with the given content description [value]. This only checks for the
+ * children with a depth of 1. An exception is thrown if the child with the content description
+ * does not exist.
+ */
+internal fun SemanticsNodeInteraction.onChildWithContentDescription(value: String): SemanticsNodeInteraction {
+ val nodes = onChildren()
+ val count = nodes.fetchSemanticsNodes().count()
+
+ for (i in 0 until count) {
+ val semanticsNode = nodes[i].fetchSemanticsNode()
+ if (semanticsNode.config[SemanticsProperties.ContentDescription].contains(value)) {
+ return nodes[i]
+ }
+ }
+ throw AssertionError("No node exists with the given content description : $value")
+}
diff --git a/toolkit/featureforms/src/androidTest/java/com/arcgismaps/toolkit/featureforms/FormTextFieldNumericTests.kt b/toolkit/featureforms/src/androidTest/java/com/arcgismaps/toolkit/featureforms/FormTextFieldNumericTests.kt
new file mode 100644
index 000000000..1edd7b4ef
--- /dev/null
+++ b/toolkit/featureforms/src/androidTest/java/com/arcgismaps/toolkit/featureforms/FormTextFieldNumericTests.kt
@@ -0,0 +1,206 @@
+/*
+ * COPYRIGHT 1995-2023 ESRI
+ *
+ * TRADE SECRETS: ESRI PROPRIETARY AND CONFIDENTIAL
+ * Unpublished material - all rights reserved under the
+ * Copyright Laws of the United States.
+ *
+ * For additional information, contact:
+ * Environmental Systems Research Institute, Inc.
+ * Attn: Contracts Dept
+ * 380 New York Street
+ * Redlands, California, USA 92373
+ *
+ * email: contracts@esri.com
+ */
+
+package com.arcgismaps.toolkit.featureforms
+
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.hasContentDescription
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithContentDescription
+import androidx.compose.ui.test.performTextInput
+import com.arcgismaps.ArcGISEnvironment
+import com.arcgismaps.data.ArcGISFeature
+import com.arcgismaps.data.QueryParameters
+import com.arcgismaps.mapping.ArcGISMap
+import com.arcgismaps.mapping.featureforms.FeatureForm
+import com.arcgismaps.mapping.featureforms.FeatureFormDefinition
+import com.arcgismaps.mapping.featureforms.FieldFormElement
+import com.arcgismaps.mapping.featureforms.TextBoxFormInput
+import com.arcgismaps.mapping.layers.FeatureLayer
+import com.arcgismaps.toolkit.featureforms.components.text.FormTextField
+import com.arcgismaps.toolkit.featureforms.components.text.FormTextFieldState
+import com.arcgismaps.toolkit.featureforms.components.text.TextFieldProperties
+import com.arcgismaps.toolkit.featureforms.utils.editValue
+import com.arcgismaps.toolkit.featureforms.utils.fieldType
+import com.arcgismaps.toolkit.featureforms.utils.valueFlow
+import junit.framework.TestCase
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.runTest
+import org.junit.BeforeClass
+import org.junit.Rule
+import org.junit.Test
+
+/**
+ * Tests for FormTextFields whose backing FormFeatureElement is associated with a numeric field and attribute type.
+ */
+class FormTextFieldNumericTests {
+ private val helperSemanticLabel = "helper"
+ private val outlinedTextFieldSemanticLabel = "outlined text field"
+
+ private val featureForm by lazy {
+ sharedFeatureForm!!
+ }
+
+ private val integerField by lazy {
+ featureForm.elements
+ .filterIsInstance()
+ .first {
+ it.label == "Number Integer"
+ }
+ }
+
+ private val floatingPointField by lazy {
+ featureForm.elements
+ .filterIsInstance()
+ .first {
+ it.label == "Number Double"
+ }
+ }
+
+ @get:Rule
+ val composeTestRule = createComposeRule()
+
+ /**
+ * Given a FormTextField with a FormFeatureElement whose backing fieldType is an integer type.
+ * When a non numeric value is entered
+ * Then the label is displayed with the expected error text
+ * https://devtopia.esri.com/runtime/common-toolkit/blob/main/designs/Forms/InputValidationDesign.md#text
+ */
+ @Test
+ fun testEnterNonNumericValueIntegerField() = runTest {
+ composeTestRule.setContent {
+ val scope = rememberCoroutineScope()
+ val textFieldProperties = TextFieldProperties(
+ label = integerField.label,
+ placeholder = integerField.hint,
+ description = integerField.description,
+ value = integerField.valueFlow(scope),
+ editable = integerField.isEditable,
+ required = integerField.isRequired,
+ singleLine = integerField.input is TextBoxFormInput,
+ domain = integerField.domain,
+ fieldType = featureForm.fieldType(integerField),
+ minLength = (integerField.input as TextBoxFormInput).minLength.toInt(),
+ maxLength = (integerField.input as TextBoxFormInput).maxLength.toInt(),
+ visible = integerField.isVisible
+ )
+ FormTextField(
+ state = FormTextFieldState(
+ textFieldProperties,
+ scope = scope,
+ context = LocalContext.current,
+ onEditValue = {
+ featureForm.editValue(integerField, it)
+ scope.launch { featureForm.evaluateExpressions() }
+ }
+ )
+ )
+ }
+ val outlinedTextField = composeTestRule.onNodeWithContentDescription(outlinedTextFieldSemanticLabel)
+ val text = "lorem ipsum"
+ outlinedTextField.performTextInput(text)
+ val helper = composeTestRule.onNode(hasContentDescription(helperSemanticLabel), useUnmergedTree = true)
+ val helperText = helper.getTextString()
+ helper.assertIsDisplayed()
+ TestCase.assertEquals("Value must be a whole number", helperText)
+ }
+
+ /**
+ * Given a FormTextField with a FormFeatureElement whose backing fieldType is a floating point type.
+ * When a non numeric value is entered
+ * Then the label is displayed with the expected error text
+ * https://devtopia.esri.com/runtime/common-toolkit/blob/main/designs/Forms/InputValidationDesign.md#text
+ */
+ @Test
+ fun testEnterNonNumericValueFloatingPointField() = runTest {
+ composeTestRule.setContent {
+ val scope = rememberCoroutineScope()
+ val textFieldProperties = TextFieldProperties(
+ label = floatingPointField.label,
+ placeholder = floatingPointField.hint,
+ description = floatingPointField.description,
+ value = floatingPointField.valueFlow(scope),
+ editable = floatingPointField.isEditable,
+ required = floatingPointField.isRequired,
+ singleLine = floatingPointField.input is TextBoxFormInput,
+ domain = floatingPointField.domain,
+ fieldType = featureForm.fieldType(floatingPointField),
+ minLength = (floatingPointField.input as TextBoxFormInput).minLength.toInt(),
+ maxLength = (floatingPointField.input as TextBoxFormInput).maxLength.toInt(),
+ visible = floatingPointField.isVisible
+ )
+ FormTextField(
+ state = FormTextFieldState(
+ textFieldProperties,
+ scope = scope,
+ context = LocalContext.current,
+ onEditValue = {
+ featureForm.editValue(floatingPointField, it)
+ scope.launch { featureForm.evaluateExpressions() }
+ }
+ )
+ )
+ }
+ val outlinedTextField = composeTestRule.onNodeWithContentDescription(outlinedTextFieldSemanticLabel)
+ val text = "lorem ipsum"
+ outlinedTextField.performTextInput(text)
+ val helper = composeTestRule.onNode(hasContentDescription(helperSemanticLabel), useUnmergedTree = true)
+ val helperText = helper.getTextString()
+ helper.assertIsDisplayed()
+ TestCase.assertEquals("Value must be a number", helperText)
+ }
+
+ companion object {
+ var sharedFeatureFormDefinition: FeatureFormDefinition? = null
+ var sharedFeatureForm: FeatureForm? = null
+ var sharedFeature: ArcGISFeature? = null
+ var sharedMap: ArcGISMap? = null
+
+ @BeforeClass
+ @JvmStatic
+ fun setupClass() = runTest {
+ ArcGISEnvironment.authenticationManager.arcGISAuthenticationChallengeHandler =
+ FeatureFormsTestChallengeHandler(
+ BuildConfig.webMapUser,
+ BuildConfig.webMapPassword
+ )
+
+ sharedMap =
+ ArcGISMap("https://runtimecoretest.maps.arcgis.com/home/item.html?id=355f37b49dca42c38ed1e156c1a23d26")
+ sharedMap?.load()?.onFailure { TestCase.fail("failed to load webmap with ${it.message}") }
+ val featureLayer = sharedMap?.operationalLayers?.first() as? FeatureLayer
+ featureLayer?.let { layer ->
+ layer.load().onFailure { TestCase.fail("failed to load layer with ${it.message}") }
+ sharedFeatureFormDefinition = layer.featureFormDefinition!!
+ val parameters = QueryParameters().also {
+ it.whereClause = "1=1"
+ it.maxFeatures = 1
+ }
+ layer.featureTable?.queryFeatures(parameters)?.onSuccess {
+ sharedFeature = it.filterIsInstance().first()
+ sharedFeature?.load()?.onFailure { TestCase.fail("failed to load feature with ${it.message}") }
+ sharedFeatureForm = FeatureForm(sharedFeature!!, sharedFeatureFormDefinition!!)
+ sharedFeatureForm?.evaluateExpressions()
+ }?.onFailure {
+ TestCase.fail("failed to query features on layer's featuretable with ${it.message}")
+ }
+ }
+ }
+ }
+
+}
diff --git a/toolkit/featureforms/src/androidTest/java/com/arcgismaps/toolkit/featureforms/FormTextFieldRangeNumericTests.kt b/toolkit/featureforms/src/androidTest/java/com/arcgismaps/toolkit/featureforms/FormTextFieldRangeNumericTests.kt
new file mode 100644
index 000000000..01fded5aa
--- /dev/null
+++ b/toolkit/featureforms/src/androidTest/java/com/arcgismaps/toolkit/featureforms/FormTextFieldRangeNumericTests.kt
@@ -0,0 +1,154 @@
+/*
+ * COPYRIGHT 1995-2023 ESRI
+ *
+ * TRADE SECRETS: ESRI PROPRIETARY AND CONFIDENTIAL
+ * Unpublished material - all rights reserved under the
+ * Copyright Laws of the United States.
+ *
+ * For additional information, contact:
+ * Environmental Systems Research Institute, Inc.
+ * Attn: Contracts Dept
+ * 380 New York Street
+ * Redlands, California, USA 92373
+ *
+ * email: contracts@esri.com
+ */
+
+package com.arcgismaps.toolkit.featureforms
+
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.hasContentDescription
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithContentDescription
+import androidx.compose.ui.test.performTextInput
+import com.arcgismaps.ArcGISEnvironment
+import com.arcgismaps.data.ArcGISFeature
+import com.arcgismaps.data.QueryParameters
+import com.arcgismaps.mapping.ArcGISMap
+import com.arcgismaps.mapping.featureforms.FeatureForm
+import com.arcgismaps.mapping.featureforms.FeatureFormDefinition
+import com.arcgismaps.mapping.featureforms.FieldFormElement
+import com.arcgismaps.mapping.featureforms.TextBoxFormInput
+import com.arcgismaps.mapping.layers.FeatureLayer
+import com.arcgismaps.toolkit.featureforms.components.text.FormTextField
+import com.arcgismaps.toolkit.featureforms.components.text.FormTextFieldState
+import com.arcgismaps.toolkit.featureforms.components.text.TextFieldProperties
+import com.arcgismaps.toolkit.featureforms.utils.editValue
+import com.arcgismaps.toolkit.featureforms.utils.fieldType
+import com.arcgismaps.toolkit.featureforms.utils.valueFlow
+import junit.framework.TestCase
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.runTest
+import org.junit.BeforeClass
+import org.junit.Rule
+import org.junit.Test
+
+/**
+ * Tests for FormTextFields whose backing FormFeatureElement is associated with a numeric field and attribute type.
+ */
+class FormTextFieldRangeNumericTests {
+ private val helperSemanticLabel = "helper"
+ private val outlinedTextFieldSemanticLabel = "outlined text field"
+
+ private val featureForm by lazy {
+ sharedFeatureForm!!
+ }
+
+ private val integerField by lazy {
+ featureForm.elements
+ .filterIsInstance()
+ .first {
+ it.label == "ForRange"
+ }
+ }
+
+
+ @get:Rule
+ val composeTestRule = createComposeRule()
+
+ /**
+ * Given a FormTextField with a FormFeatureElement whose backing fieldType is a floating point type.
+ * When a non numeric value is entered
+ * Then the label is displayed with the expected error text
+ * https://devtopia.esri.com/runtime/common-toolkit/blob/main/designs/Forms/InputValidationDesign.md#text
+ */
+ @Test
+ fun testEnterNumericValueOutOfRange() = runTest {
+ composeTestRule.setContent {
+ val scope = rememberCoroutineScope()
+ val textFieldProperties = TextFieldProperties(
+ label = integerField.label,
+ placeholder = integerField.hint,
+ description = integerField.description,
+ value = integerField.valueFlow(scope),
+ editable = integerField.isEditable,
+ required = integerField.isRequired,
+ singleLine = integerField.input is TextBoxFormInput,
+ domain = integerField.domain,
+ fieldType = featureForm.fieldType(integerField),
+ minLength = (integerField.input as TextBoxFormInput).minLength.toInt(),
+ maxLength = (integerField.input as TextBoxFormInput).maxLength.toInt(),
+ visible = integerField.isVisible
+ )
+ FormTextField(
+ state = FormTextFieldState(
+ textFieldProperties,
+ scope = scope,
+ context = LocalContext.current,
+ onEditValue = {
+ featureForm.editValue(integerField, it)
+ scope.launch { featureForm.evaluateExpressions() }
+ }
+ )
+ )
+ }
+ val outlinedTextField = composeTestRule.onNodeWithContentDescription(outlinedTextFieldSemanticLabel)
+ val text = "9"
+ outlinedTextField.performTextInput(text)
+ val helper = composeTestRule.onNode(hasContentDescription(helperSemanticLabel), useUnmergedTree = true)
+ val helperText = helper.getTextString()
+ helper.assertIsDisplayed()
+ TestCase.assertEquals("Enter value from 1 to 7", helperText)
+ }
+
+ companion object {
+ var sharedFeatureFormDefinition: FeatureFormDefinition? = null
+ var sharedFeatureForm: FeatureForm? = null
+ var sharedFeature: ArcGISFeature? = null
+ var sharedMap: ArcGISMap? = null
+
+ @BeforeClass
+ @JvmStatic
+ fun setupClass() = runTest {
+ ArcGISEnvironment.authenticationManager.arcGISAuthenticationChallengeHandler =
+ FeatureFormsTestChallengeHandler(
+ BuildConfig.webMapUser,
+ BuildConfig.webMapPassword
+ )
+
+ sharedMap =
+ ArcGISMap("https://runtimecoretest.maps.arcgis.com/home/item.html?id=225ffaf1091d48fcbe31be0146808b2b")
+ sharedMap?.load()?.onFailure { TestCase.fail("failed to load webmap with ${it.message}") }
+ val featureLayer = sharedMap?.operationalLayers?.first() as? FeatureLayer
+ featureLayer?.let { layer ->
+ layer.load().onFailure { TestCase.fail("failed to load layer with ${it.message}") }
+ sharedFeatureFormDefinition = layer.featureFormDefinition!!
+ val parameters = QueryParameters().also {
+ it.whereClause = "1=1"
+ it.maxFeatures = 1
+ }
+ layer.featureTable?.queryFeatures(parameters)?.onSuccess {
+ sharedFeature = it.filterIsInstance().first()
+ sharedFeature?.load()?.onFailure { TestCase.fail("failed to load feature with ${it.message}") }
+ sharedFeatureForm = FeatureForm(sharedFeature!!, sharedFeatureFormDefinition!!)
+ sharedFeatureForm?.evaluateExpressions()
+ }?.onFailure {
+ TestCase.fail("failed to query features on layer's featuretable with ${it.message}")
+ }
+ }
+ }
+ }
+
+}
diff --git a/toolkit/featureforms/src/androidTest/java/com/arcgismaps/toolkit/featureforms/FormTextFieldTests.kt b/toolkit/featureforms/src/androidTest/java/com/arcgismaps/toolkit/featureforms/FormTextFieldTests.kt
new file mode 100644
index 000000000..bd348e541
--- /dev/null
+++ b/toolkit/featureforms/src/androidTest/java/com/arcgismaps/toolkit/featureforms/FormTextFieldTests.kt
@@ -0,0 +1,341 @@
+/*
+ *
+ * Copyright 2023 Esri
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package com.arcgismaps.toolkit.featureforms
+
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.assertIsFocused
+import androidx.compose.ui.test.assertIsNotFocused
+import androidx.compose.ui.test.hasContentDescription
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithContentDescription
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.performImeAction
+import androidx.compose.ui.test.performTextClearance
+import androidx.compose.ui.test.performTextInput
+import com.arcgismaps.ArcGISEnvironment
+import com.arcgismaps.data.ArcGISFeature
+import com.arcgismaps.data.QueryParameters
+import com.arcgismaps.mapping.ArcGISMap
+import com.arcgismaps.mapping.featureforms.FeatureForm
+import com.arcgismaps.mapping.featureforms.FeatureFormDefinition
+import com.arcgismaps.mapping.featureforms.FieldFormElement
+import com.arcgismaps.mapping.featureforms.TextBoxFormInput
+import com.arcgismaps.mapping.layers.FeatureLayer
+import com.arcgismaps.toolkit.featureforms.components.text.FormTextField
+import com.arcgismaps.toolkit.featureforms.components.text.FormTextFieldState
+import com.arcgismaps.toolkit.featureforms.components.text.TextFieldProperties
+import com.arcgismaps.toolkit.featureforms.utils.editValue
+import com.arcgismaps.toolkit.featureforms.utils.fieldType
+import com.arcgismaps.toolkit.featureforms.utils.valueFlow
+import junit.framework.TestCase.assertEquals
+import junit.framework.TestCase.fail
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.runTest
+import org.junit.After
+import org.junit.Before
+import org.junit.BeforeClass
+import org.junit.Rule
+import org.junit.Test
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class FormTextFieldTests {
+ private val labelSemanticLabel = "label"
+ private val helperSemanticLabel = "helper"
+ private val outlinedTextFieldSemanticLabel = "outlined text field"
+ private val charCountSemanticLabel = "char count"
+ private val clearTextSemanticLabel = "Clear text button"
+
+ private val featureForm by lazy {
+ sharedFeatureForm!!
+ }
+
+ private val field by lazy {
+ featureForm.elements
+ .filterIsInstance()
+ .first {
+ it.label == "Single Line No Value, Placeholder or Description"
+ }
+ }
+
+ private val errorTextColor = Color(
+ red = 0.7019608f,
+ green = 0.14901961f,
+ blue = 0.11764706f
+ )
+
+ @get:Rule
+ val composeTestRule = createComposeRule()
+
+ @Before
+ fun setContent() = runTest {
+ composeTestRule.setContent {
+ val scope = rememberCoroutineScope()
+ val textFieldProperties = TextFieldProperties(
+ label = field.label,
+ placeholder = field.hint,
+ description = field.description,
+ value = field.valueFlow(scope),
+ editable = field.isEditable,
+ required = field.isRequired,
+ singleLine = field.input is TextBoxFormInput,
+ domain = field.domain,
+ fieldType = featureForm.fieldType(field),
+ minLength = (field.input as TextBoxFormInput).minLength.toInt(),
+ maxLength = (field.input as TextBoxFormInput).maxLength.toInt(),
+ visible = field.isVisible
+ )
+ FormTextField(
+ state = FormTextFieldState(
+ textFieldProperties,
+ scope = scope,
+ context = LocalContext.current,
+ onEditValue = {
+ featureForm.editValue(field, it)
+ scope.launch { featureForm.evaluateExpressions() }
+ }
+ )
+ )
+ }
+ featureForm.evaluateExpressions()
+ }
+
+ @After
+ fun clearText() {
+ val outlinedTextField = composeTestRule.onNodeWithContentDescription(outlinedTextFieldSemanticLabel)
+ // clear out any text added to this empty field during tests
+ outlinedTextField.performTextClearance()
+ }
+
+ /**
+ * Given a FormTextField with no value, placeholder, or description
+ * When it is unfocused
+ * Then the label is displayed and the helper text composable does not exist.
+ * https://devtopia.esri.com/runtime/common-toolkit/blob/main/designs/Forms/FormsTestDesign.md#test-case-11-unfocused-and-focused-state-no-value
+ */
+ @Test
+ fun testNoValueUnfocusedState() = runTest {
+ val label = composeTestRule.onNodeWithContentDescription(labelSemanticLabel)
+ label.assertIsDisplayed()
+
+ val helper = composeTestRule.onNode(hasContentDescription(helperSemanticLabel), useUnmergedTree = true)
+ helper.assertDoesNotExist()
+ }
+
+ /**
+ * Given a FormTextField with no value, placeholder, description, but with a max length.
+ * When it is focused
+ * Then the label is displayed and the helper text is displayed, indicating the max length of the form field text.
+ * https://devtopia.esri.com/runtime/common-toolkit/blob/main/designs/Forms/FormsTestDesign.md#test-case-11-unfocused-and-focused-state-no-value
+ */
+ @Test
+ fun testNoValueFocusedState() = runTest {
+ val outlinedTextField = composeTestRule.onNodeWithContentDescription(outlinedTextFieldSemanticLabel)
+ outlinedTextField.performClick()
+ outlinedTextField.assertIsFocused()
+ val label = composeTestRule.onNodeWithContentDescription(labelSemanticLabel)
+ label.assertIsDisplayed()
+
+ val helper = composeTestRule.onNode(hasContentDescription(helperSemanticLabel), useUnmergedTree = true)
+ val helperText = helper.getTextString()
+ helper.assertIsDisplayed()
+ val maxLength = (field.input as TextBoxFormInput).maxLength
+ assertEquals("Maximum $maxLength characters", helperText)
+ }
+
+ /**
+ * Given a FormTextField with no value, placeholder, or description
+ * When a value is entered
+ * Then the label, the helper text, and the char count are displayed.
+ * https://devtopia.esri.com/runtime/common-toolkit/blob/main/designs/Forms/FormsTestDesign.md#test-case-12-focused-and-unfocused-state-with-value-populated
+ */
+ @Test
+ fun testEnteredValueFocusedState() = runTest {
+ val outlinedTextField = composeTestRule.onNodeWithContentDescription(outlinedTextFieldSemanticLabel)
+ val text = "lorem ipsum"
+ outlinedTextField.performTextInput(text)
+ outlinedTextField.assertIsFocused()
+ val label = composeTestRule.onNodeWithContentDescription(labelSemanticLabel)
+ label.assertIsDisplayed()
+
+ val helper = composeTestRule.onNode(hasContentDescription(helperSemanticLabel), useUnmergedTree = true)
+ val helperText = helper.getTextString()
+ helper.assertIsDisplayed()
+ val maxLength = (field.input as TextBoxFormInput).maxLength.toInt()
+ assertEquals("Maximum $maxLength characters", helperText)
+
+ val charCountNode =
+ composeTestRule.onNode(hasContentDescription(charCountSemanticLabel), useUnmergedTree = true)
+ val charCountText = charCountNode.getTextString()
+ charCountNode.assertIsDisplayed()
+ assertEquals(text.length.toString(), charCountText)
+
+ val clearButton = composeTestRule.onNode(hasContentDescription(clearTextSemanticLabel), useUnmergedTree = true)
+ clearButton.assertExists()
+ }
+
+ /**
+ * Given a FormTextField with no value, placeholder, or description
+ * When a value is entered, but the focus is elsewhere
+ * Then the label is displayed. bue the helper text, and the char count are not displayed.
+ * https://devtopia.esri.com/runtime/common-toolkit/blob/main/designs/Forms/FormsTestDesign.md#test-case-12-focused-and-unfocused-state-with-value-populated
+ */
+ @Test
+ fun testEnteredValueUnfocusedState() = runTest {
+ val outlinedTextField = composeTestRule.onNodeWithContentDescription(outlinedTextFieldSemanticLabel)
+ val text = "lorem ipsum"
+ outlinedTextField.performTextInput(text)
+ outlinedTextField.assertIsFocused()
+ val label = composeTestRule.onNodeWithContentDescription(labelSemanticLabel)
+ label.assertIsDisplayed()
+
+ val helper = composeTestRule.onNode(hasContentDescription(helperSemanticLabel), useUnmergedTree = true)
+ val helperText = helper.getTextString()
+ helper.assertIsDisplayed()
+ val maxLength = (field.input as TextBoxFormInput).maxLength.toInt()
+ assertEquals("Maximum $maxLength characters", helperText)
+
+ outlinedTextField.performImeAction()
+ outlinedTextField.assertIsNotFocused()
+ helper.assertDoesNotExist()
+
+ val charCountNode =
+ composeTestRule.onNode(hasContentDescription(charCountSemanticLabel), useUnmergedTree = true)
+ charCountNode.assertDoesNotExist()
+
+ val clearButton = composeTestRule.onNode(hasContentDescription(clearTextSemanticLabel), useUnmergedTree = true)
+ clearButton.assertExists()
+ }
+
+ /**
+ * Given a FormTextField with no value, placeholder, or description
+ * When a value is entered that exceeds the max length of the form input
+ * Then the label is displayed, and the helper text and the char count are displayed in red.
+ * https://devtopia.esri.com/runtime/common-toolkit/blob/main/designs/Forms/FormsTestDesign.md#test-case-13-unfocused-and-focused-state-with-error-value--254-chars
+ */
+ @Test
+ fun testErrorValueFocusedState() = runTest {
+ val maxLength = (field.input as TextBoxFormInput).maxLength.toInt()
+ val outlinedTextField = composeTestRule.onNodeWithContentDescription(outlinedTextFieldSemanticLabel)
+ val text = buildString {
+ repeat(maxLength + 1) {
+ append("x")
+ }
+ }
+ outlinedTextField.performTextInput(text)
+ outlinedTextField.assertIsFocused()
+ val label = composeTestRule.onNodeWithContentDescription(labelSemanticLabel)
+ label.assertIsDisplayed()
+
+ val helper = composeTestRule.onNode(hasContentDescription(helperSemanticLabel), useUnmergedTree = true)
+ val helperText = helper.getTextString()
+ helper.assertIsDisplayed()
+
+ assertEquals("Maximum $maxLength characters", helperText)
+ helper.assertTextColor(errorTextColor)
+
+ val charCountNode =
+ composeTestRule.onNode(hasContentDescription(charCountSemanticLabel), useUnmergedTree = true)
+ val charCountText = charCountNode.getTextString()
+ charCountNode.assertIsDisplayed()
+ assertEquals(text.length.toString(), charCountText)
+ charCountNode.assertTextColor(errorTextColor)
+
+ val clearButton = composeTestRule.onNode(hasContentDescription(clearTextSemanticLabel), useUnmergedTree = true)
+ clearButton.assertExists()
+ }
+
+ /**
+ * Given a FormTextField with no value, placeholder, or description
+ * When a value is entered, but the focus is elsewhere
+ * Then the label is displayed, the helper text is displayed in red, and the char count is not displayed.
+ * https://devtopia.esri.com/runtime/common-toolkit/blob/main/designs/Forms/FormsTestDesign.md#test-case-13-unfocused-and-focused-state-with-error-value--254-char
+ */
+ @Test
+ fun testErrorValueUnfocusedState() = runTest {
+ val outlinedTextField = composeTestRule.onNodeWithContentDescription(outlinedTextFieldSemanticLabel)
+ val maxLength = (field.input as TextBoxFormInput).maxLength.toInt()
+ val text = buildString {
+ repeat(maxLength + 1) {
+ append("x")
+ }
+ }
+ outlinedTextField.performTextInput(text)
+ outlinedTextField.assertIsFocused()
+ val label = composeTestRule.onNodeWithContentDescription(labelSemanticLabel)
+ label.assertIsDisplayed()
+
+ val helper = composeTestRule.onNode(hasContentDescription(helperSemanticLabel), useUnmergedTree = true)
+ val helperText = helper.getTextString()
+ helper.assertIsDisplayed()
+ assertEquals("Maximum $maxLength characters", helperText)
+
+ outlinedTextField.performImeAction()
+ outlinedTextField.assertIsNotFocused()
+ helper.assertTextColor(errorTextColor)
+
+ val charCountNode =
+ composeTestRule.onNode(hasContentDescription(charCountSemanticLabel), useUnmergedTree = true)
+ charCountNode.assertDoesNotExist()
+
+ val clearButton = composeTestRule.onNode(hasContentDescription(clearTextSemanticLabel), useUnmergedTree = true)
+ clearButton.assertExists()
+ }
+
+ companion object {
+ var sharedFeatureFormDefinition: FeatureFormDefinition? = null
+ var sharedFeatureForm: FeatureForm? = null
+ var sharedFeature: ArcGISFeature? = null
+ var sharedMap: ArcGISMap? = null
+
+ @BeforeClass
+ @JvmStatic
+ fun setupClass() = runTest {
+ ArcGISEnvironment.authenticationManager.arcGISAuthenticationChallengeHandler =
+ FeatureFormsTestChallengeHandler(
+ BuildConfig.webMapUser,
+ BuildConfig.webMapPassword
+ )
+
+ sharedMap =
+ ArcGISMap("https://runtimecoretest.maps.arcgis.com/home/item.html?id=5d69e2301ad14ec8a73b568dfc29450a")
+ sharedMap?.load()?.onFailure { fail("failed to load webmap with ${it.message}") }
+ val featureLayer = sharedMap?.operationalLayers?.first() as? FeatureLayer
+ featureLayer?.let { layer ->
+ layer.load().onFailure { fail("failed to load layer with ${it.message}") }
+ sharedFeatureFormDefinition = layer.featureFormDefinition!!
+ val parameters = QueryParameters().also {
+ it.whereClause = "1=1"
+ it.maxFeatures = 1
+ }
+ layer.featureTable?.queryFeatures(parameters)?.onSuccess {
+ sharedFeature = it.filterIsInstance().first()
+ sharedFeature?.load()?.onFailure { fail("failed to load feature with ${it.message}") }
+ sharedFeatureForm = FeatureForm(sharedFeature!!, sharedFeatureFormDefinition!!)
+ }?.onFailure {
+ fail("failed to query features on layer's featuretable with ${it.message}")
+ }
+ }
+ }
+ }
+
+}
diff --git a/toolkit/featureforms/src/androidTest/java/com/arcgismaps/toolkit/featureforms/SemanticsNodeUtil.kt b/toolkit/featureforms/src/androidTest/java/com/arcgismaps/toolkit/featureforms/SemanticsNodeUtil.kt
new file mode 100644
index 000000000..a5f16247b
--- /dev/null
+++ b/toolkit/featureforms/src/androidTest/java/com/arcgismaps/toolkit/featureforms/SemanticsNodeUtil.kt
@@ -0,0 +1,40 @@
+package com.arcgismaps.toolkit.featureforms
+
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.semantics.SemanticsActions
+import androidx.compose.ui.semantics.SemanticsProperties
+import androidx.compose.ui.semantics.getOrNull
+import androidx.compose.ui.test.SemanticsMatcher
+import androidx.compose.ui.test.SemanticsNodeInteraction
+import androidx.compose.ui.test.assert
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.TextLayoutResult
+
+fun SemanticsNodeInteraction.getAnnotatedTextString(): AnnotatedString {
+ val textList = fetchSemanticsNode().config.first {
+ it.key.name == "Text"
+ }.value as List<*>
+ return textList.first() as AnnotatedString
+}
+
+fun SemanticsNodeInteraction.getTextString(): String {
+ return getAnnotatedTextString().text
+}
+
+fun SemanticsNodeInteraction.assertTextColor(
+ color: Color
+): SemanticsNodeInteraction = assert(isOfColor(color))
+
+private fun isOfColor(color: Color): SemanticsMatcher = SemanticsMatcher(
+ "${SemanticsProperties.Text.name} is of color '$color'"
+) {
+ val textLayoutResults = mutableListOf()
+ it.config.getOrNull(SemanticsActions.GetTextLayoutResult)
+ ?.action
+ ?.invoke(textLayoutResults)
+ return@SemanticsMatcher if (textLayoutResults.isEmpty()) {
+ false
+ } else {
+ textLayoutResults.first().layoutInput.style.color == color
+ }
+}
diff --git a/toolkit/featureforms/src/androidTest/java/com/arcgismaps/toolkit/featureforms/TestChallengeHandler.kt b/toolkit/featureforms/src/androidTest/java/com/arcgismaps/toolkit/featureforms/TestChallengeHandler.kt
new file mode 100644
index 000000000..099e833d4
--- /dev/null
+++ b/toolkit/featureforms/src/androidTest/java/com/arcgismaps/toolkit/featureforms/TestChallengeHandler.kt
@@ -0,0 +1,47 @@
+/*
+ * COPYRIGHT 1995-2023 ESRI
+ *
+ * TRADE SECRETS: ESRI PROPRIETARY AND CONFIDENTIAL
+ * Unpublished material - all rights reserved under the
+ * Copyright Laws of the United States.
+ *
+ * For additional information, contact:
+ * Environmental Systems Research Institute, Inc.
+ * Attn: Contracts Dept
+ * 380 New York Street
+ * Redlands, California, USA 92373
+ *
+ * email: contracts@esri.com
+ */
+
+package com.arcgismaps.toolkit.featureforms
+
+import com.arcgismaps.httpcore.authentication.ArcGISAuthenticationChallenge
+import com.arcgismaps.httpcore.authentication.ArcGISAuthenticationChallengeHandler
+import com.arcgismaps.httpcore.authentication.ArcGISAuthenticationChallengeResponse
+import com.arcgismaps.httpcore.authentication.TokenCredential
+
+
+class FeatureFormsTestChallengeHandler(
+ private val username: String,
+ private val password: String
+) : ArcGISAuthenticationChallengeHandler {
+ override suspend fun handleArcGISAuthenticationChallenge(
+ challenge: ArcGISAuthenticationChallenge
+ ): ArcGISAuthenticationChallengeResponse {
+ val result: Result =
+ TokenCredential.create(
+ challenge.requestUrl,
+ username,
+ password,
+ tokenExpirationInterval = 0
+ )
+ return result.let {
+ if (it.isSuccess) {
+ ArcGISAuthenticationChallengeResponse.ContinueWithCredential(it.getOrThrow())
+ } else {
+ ArcGISAuthenticationChallengeResponse.ContinueAndFailWithError(it.exceptionOrNull()!!)
+ }
+ }
+ }
+}
diff --git a/toolkit/featureforms/src/main/AndroidManifest.xml b/toolkit/featureforms/src/main/AndroidManifest.xml
new file mode 100644
index 000000000..17a1ce200
--- /dev/null
+++ b/toolkit/featureforms/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/FeatureForm.kt b/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/FeatureForm.kt
new file mode 100644
index 000000000..b3070d56b
--- /dev/null
+++ b/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/FeatureForm.kt
@@ -0,0 +1,404 @@
+package com.arcgismaps.toolkit.featureforms
+
+import android.content.Context
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.ExperimentalAnimationApi
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.slideInVertically
+import androidx.compose.animation.slideOutVertically
+import androidx.compose.animation.with
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Divider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.arcgismaps.mapping.featureforms.ComboBoxFormInput
+import com.arcgismaps.mapping.featureforms.DateTimePickerFormInput
+import com.arcgismaps.mapping.featureforms.FeatureForm
+import com.arcgismaps.mapping.featureforms.FieldFormElement
+import com.arcgismaps.mapping.featureforms.FormElement
+import com.arcgismaps.mapping.featureforms.GroupFormElement
+import com.arcgismaps.mapping.featureforms.RadioButtonsFormInput
+import com.arcgismaps.mapping.featureforms.SwitchFormInput
+import com.arcgismaps.mapping.featureforms.TextAreaFormInput
+import com.arcgismaps.mapping.featureforms.TextBoxFormInput
+import com.arcgismaps.toolkit.featureforms.components.base.BaseFieldState
+import com.arcgismaps.toolkit.featureforms.components.base.BaseGroupState
+import com.arcgismaps.toolkit.featureforms.components.base.rememberBaseGroupState
+import com.arcgismaps.toolkit.featureforms.components.codedvalue.CodedValueFieldState
+import com.arcgismaps.toolkit.featureforms.components.codedvalue.rememberCodedValueFieldState
+import com.arcgismaps.toolkit.featureforms.components.codedvalue.rememberRadioButtonFieldState
+import com.arcgismaps.toolkit.featureforms.components.codedvalue.rememberSwitchFieldState
+import com.arcgismaps.toolkit.featureforms.components.datetime.DateTimeFieldState
+import com.arcgismaps.toolkit.featureforms.components.datetime.rememberDateTimeFieldState
+import com.arcgismaps.toolkit.featureforms.components.formelement.FieldElement
+import com.arcgismaps.toolkit.featureforms.components.formelement.GroupElement
+import com.arcgismaps.toolkit.featureforms.components.text.rememberFormTextFieldState
+import com.arcgismaps.toolkit.featureforms.utils.DialogType
+import com.arcgismaps.toolkit.featureforms.utils.FeatureFormDialog
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.delay
+import java.util.Objects
+
+/**
+ * A composable Form toolkit component that enables users to edit field values of features in a
+ * layer using forms that have been configured externally (using either in the the Web Map Viewer
+ * or the Fields Maps web app). The composable uses the [featureFormState] as the UI state.
+ *
+ * @since 200.2.0
+ */
+@Composable
+public fun FeatureForm(
+ featureFormState: FeatureFormState,
+ modifier: Modifier = Modifier
+) {
+ val featureForm by featureFormState.featureForm.collectAsState()
+ var initialEvaluation by rememberSaveable(featureForm) { mutableStateOf(false) }
+
+ featureForm?.let {
+ InitializingExpressions(modifier) {
+ initialEvaluation
+ }
+ FeatureFormContent(form = it, modifier = modifier)
+ } ?: run {
+ NoDataToDisplay(modifier)
+ }
+
+ LaunchedEffect(featureForm) {
+ // ensure expressions are evaluated before state objects are created.
+ featureForm?.evaluateExpressions()
+ // add an artificial delay of 300ms to avoid the slight flicker if the
+ // expressions are evaluated quickly
+ delay(300)
+ initialEvaluation = true
+ }
+}
+
+@OptIn(ExperimentalAnimationApi::class)
+@Composable
+internal fun InitializingExpressions(
+ modifier: Modifier = Modifier,
+ evaluationProvider: () -> Boolean
+) {
+ AnimatedContent(
+ targetState = evaluationProvider(),
+ transitionSpec = {
+ slideInVertically() with
+ slideOutVertically(
+ animationSpec = tween()
+ ) { 0 } + fadeOut()
+ },
+ label = "evaluation loading animation"
+ ) { evaluated ->
+ if (!evaluated) {
+ Surface(modifier = modifier.fillMaxSize()) {
+ Column(
+ verticalArrangement = Arrangement.Top,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ modifier = Modifier.fillMaxSize()
+ ) {
+ CircularProgressIndicator(
+ modifier = Modifier
+ .width(60.dp)
+ .height(60.dp)
+ )
+ Spacer(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(20.dp)
+ )
+ Text(text = "Initializing", style = MaterialTheme.typography.titleMedium)
+ }
+ }
+ }
+ }
+}
+
+@Composable
+internal fun NoDataToDisplay(modifier: Modifier = Modifier) {
+ Column(
+ modifier = modifier.fillMaxSize()
+ ) {
+ Text(text = "No information to display.")
+ }
+}
+
+@Composable
+internal fun FeatureFormContent(
+ form: FeatureForm,
+ modifier: Modifier = Modifier
+) {
+ val context = LocalContext.current
+ val scope = rememberCoroutineScope()
+ val fieldStateMap = rememberFieldStates(
+ form = form,
+ elements = form.elements,
+ context = context,
+ scope = scope
+ )
+ val groupStateMap = rememberGroupStates(
+ form = form,
+ context = context,
+ scope = scope
+ )
+ var dialogType: DialogType by rememberSaveable {
+ mutableStateOf(DialogType.NoDialog)
+ }
+ FeatureFormBody(
+ form = form,
+ fieldStateMap = fieldStateMap,
+ groupStateMap = groupStateMap,
+ modifier = modifier
+ ) { state, id ->
+ if (state is DateTimeFieldState) {
+ dialogType = DialogType.DatePickerDialog(id)
+ } else if (state is CodedValueFieldState) {
+ dialogType = DialogType.ComboBoxDialog(id)
+ }
+ }
+ FeatureFormDialog(
+ dialogType = dialogType,
+ state = dialogType.getStateKey()?.let { stateKey ->
+ fieldStateMap[stateKey] ?: groupStateMap.firstNotNullOfOrNull {
+ it.value.fieldStates[stateKey]
+ }
+ },
+ onDismissRequest = {
+ dialogType = DialogType.NoDialog
+ }
+ )
+}
+
+@Composable
+private fun FeatureFormBody(
+ form: FeatureForm,
+ fieldStateMap: Map>,
+ groupStateMap: Map,
+ modifier: Modifier = Modifier,
+ onFieldDialogRequest: ((BaseFieldState<*>, Int) -> Unit)? = null
+) {
+ val lazyListState = rememberLazyListState()
+ Column(
+ modifier = modifier.fillMaxSize(),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ // title
+ Text(text = form.title, style = TextStyle(fontWeight = FontWeight.Bold))
+ Spacer(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(15.dp)
+ )
+ Divider(modifier = Modifier.fillMaxWidth(), thickness = 2.dp)
+ // form content
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ state = lazyListState
+ ) {
+ items(form.elements) { formElement ->
+ when (formElement) {
+ is FieldFormElement -> {
+ val state = fieldStateMap[formElement.id]
+ if (state != null) {
+ FieldElement(
+ state = state,
+ onDialogRequest = {
+ onFieldDialogRequest?.invoke(state, formElement.id)
+ }
+ )
+ }
+ }
+
+ is GroupFormElement -> {
+ val state = groupStateMap[formElement.id]
+ if (state != null) {
+ GroupElement(
+ formElement,
+ state,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(
+ start = 15.dp,
+ end = 15.dp,
+ top = 10.dp,
+ bottom = 10.dp
+ ),
+ onDialogRequest = { baseFieldState, key ->
+ onFieldDialogRequest?.invoke(baseFieldState, key)
+ }
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+internal fun rememberGroupStates(
+ form: FeatureForm,
+ context: Context,
+ scope: CoroutineScope,
+): Map {
+ return form.elements.filterIsInstance().associateBy(
+ { groupElement ->
+ groupElement.id
+ },
+ { groupElement ->
+ val fieldStates = rememberFieldStates(
+ form = form,
+ elements = groupElement.formElements,
+ context = context,
+ scope = scope
+ )
+ rememberBaseGroupState(groupElement = groupElement, fieldStates = fieldStates)
+ }
+ )
+}
+
+@Composable
+internal fun rememberFieldStates(
+ form: FeatureForm,
+ elements: List,
+ context: Context,
+ scope: CoroutineScope
+): Map> {
+ val stateMap = mutableMapOf>()
+ elements.forEach { element ->
+ if (element is FieldFormElement) {
+ val state = when (element.input) {
+ is TextBoxFormInput, is TextAreaFormInput -> {
+ val minLength = if (element.input is TextBoxFormInput) {
+ (element.input as TextBoxFormInput).minLength.toInt()
+ } else {
+ (element.input as TextAreaFormInput).minLength.toInt()
+ }
+ val maxLength = if (element.input is TextBoxFormInput) {
+ (element.input as TextBoxFormInput).maxLength.toInt()
+ } else {
+ (element.input as TextAreaFormInput).maxLength.toInt()
+ }
+ rememberFormTextFieldState(
+ field = element,
+ minLength = minLength,
+ maxLength = maxLength,
+ form = form,
+ context = context,
+ scope = scope
+ )
+ }
+
+ is DateTimePickerFormInput -> {
+ val input = element.input as DateTimePickerFormInput
+ rememberDateTimeFieldState(
+ field = element,
+ minEpochMillis = input.min,
+ maxEpochMillis = input.max,
+ shouldShowTime = input.includeTime,
+ form = form,
+ scope = scope
+ )
+ }
+
+ is ComboBoxFormInput -> {
+ rememberCodedValueFieldState(
+ field = element,
+ form = form,
+ scope = scope
+ )
+ }
+
+ is SwitchFormInput -> {
+ val input = element.input as SwitchFormInput
+ val initialValue = element.formattedValue
+ val fallback = initialValue.isEmpty()
+ || (element.value.value != input.onValue.code && element.value.value != input.offValue.code)
+ rememberSwitchFieldState(
+ field = element,
+ form = form,
+ fallback = fallback,
+ scope = scope,
+ noValueString = context.getString(R.string.no_value)
+ )
+ }
+
+ is RadioButtonsFormInput -> {
+ rememberRadioButtonFieldState(
+ field = element,
+ form = form,
+ scope = scope
+ )
+ }
+
+ else -> {
+ null
+ }
+ }
+ if (state != null) {
+ stateMap[element.id] = state
+ }
+ }
+ }
+ return stateMap
+}
+
+@Preview(showBackground = true, backgroundColor = 0xFFFFFF)
+@Composable
+private fun InitializingExpressionsPreview() {
+ InitializingExpressions { false }
+}
+
+
+@Preview
+@Composable
+private fun NoDataPreview() {
+ NoDataToDisplay()
+}
+
+/**
+ * Unique id for each form element.
+ */
+internal val FieldFormElement.id: Int
+ get() {
+ return Objects.hash(fieldName, label, description, hint)
+ }
+
+/**
+ * Unique id for each form element.
+ */
+internal val GroupFormElement.id: Int
+ get() {
+ return Objects.hash(
+ formElements.forEach { if (it is FieldFormElement) it.id },
+ label,
+ description
+ )
+ }
diff --git a/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/FeatureFormState.kt b/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/FeatureFormState.kt
new file mode 100644
index 000000000..d6dea62b4
--- /dev/null
+++ b/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/FeatureFormState.kt
@@ -0,0 +1,146 @@
+package com.arcgismaps.toolkit.featureforms
+
+import com.arcgismaps.data.ServiceFeatureTable
+import com.arcgismaps.mapping.featureforms.FeatureForm
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+
+/**
+ * The editing state of the FeatureForm.
+ *
+ * @since 200.2.0
+ */
+public sealed class EditingTransactionState {
+ /**
+ * No ongoing editing session.
+ *
+ * @since 200.2.0
+ */
+ public object NotEditing: EditingTransactionState()
+
+ /**
+ * An editing session is ongoing.
+ *
+ * @since 200.2.0
+ */
+ public object Editing: EditingTransactionState()
+
+ /**
+ * The Feature is being updated and the edits are being applied to the Feature's Geodatabase
+ *
+ * @since 200.2.0
+ */
+ public object Committing: EditingTransactionState()
+
+ /**
+ * Local edits to the Feature's attributes are being discarded.
+ *
+ * @since 200.2.0
+ */
+ public object RollingBack: EditingTransactionState()
+}
+
+/**
+ * A state holder to provide the feature form state and control.
+ *
+ * @since 200.2.0
+ */
+public interface FeatureFormState {
+ /**
+ * The FeatureForm that defines the Form and provides Feature access.
+ *
+ * @since 200.2.0
+ */
+ public val featureForm: StateFlow
+
+ /**
+ * Indicates that the form UI is available to the user for editing
+ *
+ * @since 200.2.0
+ */
+ public val transactionState: StateFlow
+
+ /**
+ * Sets the feature form to which edits will be applied.
+ *
+ * @since 200.2.0
+ */
+ public fun setFeatureForm(featureForm: FeatureForm)
+
+ /**
+ * Sets the editing mode of the form
+ *
+ * @since 200.2.0
+ */
+ public fun setTransactionState(state: EditingTransactionState)
+
+ /**
+ * Save form edits to the Feature
+ * @param stateAfterCommit the state to put the form into after the commit is completed.
+ *
+ * @since 200.2.0
+ */
+ public suspend fun commitEdits(stateAfterCommit: EditingTransactionState): Result
+
+ /**
+ * Discard form edits to the Feature
+ * @param stateAfterRollback the state to put the form into after the rollback is completed.
+ *
+ * @since 200.2.0
+ */
+ public suspend fun rollbackEdits(stateAfterRollback: EditingTransactionState): Result
+}
+
+/**
+ * Default implementation for the [FeatureFormState]
+ */
+public class FeatureFormStateImpl : FeatureFormState {
+ private val _featureForm: MutableStateFlow = MutableStateFlow(null)
+ override val featureForm: StateFlow = _featureForm.asStateFlow()
+ private val _transactionState: MutableStateFlow = MutableStateFlow(EditingTransactionState.NotEditing)
+ override val transactionState: StateFlow = _transactionState.asStateFlow()
+ override fun setTransactionState(state: EditingTransactionState) {
+ _transactionState.value = state
+ }
+
+ public override suspend fun commitEdits(stateAfterCommit: EditingTransactionState): Result {
+ setTransactionState(EditingTransactionState.Committing)
+ val feature = featureForm.value?.feature
+ ?: return Result.failure(IllegalStateException("cannot save feature edit without a Feature"))
+ val serviceFeatureTable =
+ featureForm.value?.feature?.featureTable as? ServiceFeatureTable ?: return Result.failure(
+ IllegalStateException("cannot save feature edit without a ServiceFeatureTable")
+ )
+
+ val result = serviceFeatureTable.updateFeature(feature)
+ .map {
+ serviceFeatureTable.serviceGeodatabase?.applyEdits()
+ ?: throw IllegalStateException("cannot apply feature edit without a ServiceGeodatabase")
+ feature.refresh()
+ Unit
+ }
+
+ // note: this will silently fail and close the form.
+ setTransactionState(stateAfterCommit)
+ return result
+ }
+
+ override suspend fun rollbackEdits(stateAfterRollback: EditingTransactionState): Result {
+ setTransactionState(EditingTransactionState.RollingBack)
+ val feature = featureForm.value?.feature
+ (feature?.featureTable as? ServiceFeatureTable)?.undoLocalEdits()
+ feature?.refresh()
+ setTransactionState(stateAfterRollback)
+ return Result.success(Unit)
+ }
+
+ override fun setFeatureForm(featureForm: FeatureForm) {
+ _featureForm.value = featureForm
+ }
+}
+
+/**
+ * Factory function for the default implementation of [FeatureFormState]
+ */
+public fun FeatureFormState(): FeatureFormState = FeatureFormStateImpl()
diff --git a/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/base/BaseFieldState.kt b/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/base/BaseFieldState.kt
new file mode 100644
index 000000000..3e8fb0d23
--- /dev/null
+++ b/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/base/BaseFieldState.kt
@@ -0,0 +1,105 @@
+/*
+ * Copyright 2023 Esri
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.arcgismaps.toolkit.featureforms.components.base
+
+import com.arcgismaps.mapping.featureforms.FieldFormElement
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.drop
+import kotlinx.coroutines.flow.flattenMerge
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.stateIn
+
+internal open class FieldProperties(
+ val label: String,
+ val placeholder: String,
+ val description: String,
+ val value: StateFlow,
+ val required: StateFlow,
+ val editable: StateFlow,
+ val visible: StateFlow
+)
+
+/**
+ * Base state class for any Field within a feature form. It provides the default set of properties
+ * that are common to all [FieldFormElement]'s.
+ *
+ * @param properties the [FieldProperties] associated with this state.
+ * @param initialValue optional initial value to set for this field. It is set to the value of
+ * [FieldProperties.value] by default.
+ * @param scope a [CoroutineScope] to start [StateFlow] collectors on.
+ * @param onEditValue a callback to invoke when the user edits result in a change of value. This
+ * is called on [BaseFieldState.onValueChanged].
+ */
+internal open class BaseFieldState(
+ properties: FieldProperties,
+ initialValue: T = properties.value.value,
+ scope: CoroutineScope,
+ protected val onEditValue: (Any?) -> Unit,
+) {
+ /**
+ * Title for the field.
+ */
+ open val label: String = properties.label
+
+ /**
+ * Placeholder hint for the field.
+ */
+ open val placeholder: String = properties.placeholder
+
+ /**
+ * Description text for the field.
+ */
+ val description: String = properties.description
+
+ // a state flow to handle user input changes
+ protected val _value = MutableStateFlow(initialValue)
+
+ /**
+ * Current value state for the field.
+ */
+ @OptIn(ExperimentalCoroutinesApi::class)
+ val value: StateFlow = flowOf(_value, properties.value.drop(1))
+ .flattenMerge()
+ .stateIn(scope, SharingStarted.Eagerly, initialValue)
+
+ /**
+ * Property that indicates if the field is editable.
+ */
+ val isEditable: StateFlow = properties.editable
+
+ /**
+ * Property that indicates if the field is required.
+ */
+ val isRequired: StateFlow = properties.required
+
+ /**
+ * Property that indicates if the field is visible.
+ */
+ val isVisible: StateFlow = properties.visible
+
+ /**
+ * Callback to update the current value of the FormTextFieldState to the given [input].
+ */
+ open fun onValueChanged(input: T) {
+ onEditValue(input)
+ _value.value = input
+ }
+}
diff --git a/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/base/BaseGroupState.kt b/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/base/BaseGroupState.kt
new file mode 100644
index 000000000..ac6fa7855
--- /dev/null
+++ b/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/base/BaseGroupState.kt
@@ -0,0 +1,84 @@
+/*
+ * Copyright 2023 Esri
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.arcgismaps.toolkit.featureforms.components.base
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.State
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.Saver
+import androidx.compose.runtime.saveable.listSaver
+import androidx.compose.runtime.saveable.rememberSaveable
+import com.arcgismaps.mapping.featureforms.FormGroupState
+import com.arcgismaps.mapping.featureforms.GroupFormElement
+
+internal class GroupProperties(
+ val label: String,
+ val description: String,
+ val expanded: Boolean
+)
+
+internal class BaseGroupState(
+ properties: GroupProperties,
+ val fieldStates: Map?>
+) {
+ val label = properties.label
+
+ val description = properties.description
+
+ private val _expanded = mutableStateOf(properties.expanded)
+ val expanded : State = _expanded
+
+ fun setExpanded(value : Boolean) {
+ _expanded.value = value
+ }
+
+ companion object {
+ fun Saver(fieldStates: Map?>): Saver = listSaver(
+ save = {
+ listOf(it.label, it.description, it.expanded.value)
+ },
+ restore = {
+ val properties = GroupProperties(
+ label = it[0] as String,
+ description = it[1] as String,
+ expanded = it[2] as Boolean
+ )
+ BaseGroupState(
+ properties = properties,
+ fieldStates = fieldStates
+ )
+ }
+ )
+ }
+}
+
+@Composable
+internal fun rememberBaseGroupState(
+ groupElement: GroupFormElement,
+ fieldStates: Map?>
+): BaseGroupState = rememberSaveable(
+ saver = BaseGroupState.Saver(fieldStates)
+) {
+ BaseGroupState(
+ properties = GroupProperties(
+ label = groupElement.label,
+ description = groupElement.description,
+ expanded = groupElement.initialState == FormGroupState.Expanded
+ ),
+ fieldStates = fieldStates
+ )
+}
diff --git a/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/base/BaseTextField.kt b/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/base/BaseTextField.kt
new file mode 100644
index 000000000..40cd3532e
--- /dev/null
+++ b/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/base/BaseTextField.kt
@@ -0,0 +1,254 @@
+/*
+ * Copyright 2023 Esri
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.arcgismaps.toolkit.featureforms.components.base
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.gestures.detectTapGestures
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.CheckCircle
+import androidx.compose.material.icons.rounded.Clear
+import androidx.compose.material.icons.rounded.TextFields
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.VisualTransformation
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.arcgismaps.toolkit.featureforms.utils.ClearFocus
+import com.arcgismaps.toolkit.featureforms.utils.PlaceholderTransformation
+
+@Composable
+private fun trailingIcon(
+ text: String,
+ isEditable: Boolean,
+ singleLine: Boolean,
+ isFocused: Boolean,
+ trailingIcon: ImageVector?,
+ onValueChange: (String) -> Unit,
+ onDone: () -> Unit
+): (@Composable () -> Unit)? {
+ // single line field and is editable
+ return if (singleLine && isEditable && text.isEmpty() && trailingIcon != null) {
+ {
+ // show a trailing icon if provided when the field is empty
+ Icon(imageVector = trailingIcon, contentDescription = "field icon")
+ }
+ } else if (singleLine && isEditable && text.isNotEmpty()) {
+ {
+ // show a clear icon instead if the field is not empty
+ IconButton(onClick = { onValueChange("") }, modifier = Modifier.semantics {
+ contentDescription = "Clear text button"
+ }) {
+ Icon(
+ imageVector = Icons.Rounded.Clear, contentDescription = "Clear Text"
+ )
+ }
+ }
+ } else if (singleLine && trailingIcon != null) {
+ // single line field but not editable
+ {
+ // show a trailing icon to indicate field type
+ Icon(imageVector = trailingIcon, contentDescription = "field icon")
+ }
+ } else if (!singleLine && isEditable && isFocused) {
+ // multiline editable field
+ {
+ // show a done button only when focused
+ IconButton(onClick = { onDone() }, modifier = Modifier.semantics {
+ contentDescription = "Save local edit button"
+ }) {
+ Icon(
+ imageVector = Icons.Rounded.CheckCircle, contentDescription = "Done"
+ )
+ }
+ }
+
+ } else if (!singleLine && isEditable && text.isNotEmpty()) {
+ {
+ // show a clear icon instead if the multiline field is not empty
+ IconButton(onClick = { onValueChange("") }, modifier = Modifier.semantics {
+ contentDescription = "Clear text button"
+ }) {
+ Icon(
+ imageVector = Icons.Rounded.Clear, contentDescription = "Clear Text"
+ )
+ }
+ }
+ } else {
+ null
+ }
+}
+
+/**
+ * A base text field component built on top of an [OutlinedTextField] that provides a standard for
+ * visual and behavioral properties. This can be used to build more customized composite components.
+ *
+ * The BaseTextField also takes care of clearing focus when the keyboard is dismissed or tapped
+ * outside the input area.
+ *
+ * @param text the input text to be shown in the text field.
+ * @param onValueChange the callback that is triggered when the input service updates the text. An
+ * updated text comes as a parameter of the callback.
+ * @param modifier a [Modifier] for this text field.
+ * @param readOnly controls the editable state of the text field. When true, the text field cannot
+ * be modified. However, a user can focus it and copy text from it. Read-only text fields are
+ * usually used to display pre-filled forms that a user cannot edit.
+ * @param isEditable controls if the text field can be edited. When false, this component will
+ * not respond to user input, and it will appear visually disabled.
+ * @param label the title to be displayed for the text field.
+ * @param placeholder the text to be displayed when the text field input text is empty.
+ * @param singleLine when set to true, this text field becomes a single horizontally scrolling
+ * text field instead of wrapping onto multiple lines.
+ * @param keyboardType the keyboard type to use depending on the FormFieldElement input type.
+ * @param trailingIcon the icon to be displayed at the end of the text field container.
+ * @param supportingText supporting text to be displayed below the text field.
+ * @param onFocusChange callback that is triggered when the focus state for this text field changes.
+ * @param interactionSource the MutableInteractionSource representing the stream of Interactions
+ * for this text field.
+ * @param trailingContent a widget to be displayed at the end of the text field container.
+ */
+@Composable
+internal fun BaseTextField(
+ text: String,
+ onValueChange: (String) -> Unit,
+ modifier: Modifier = Modifier,
+ isEditable: Boolean,
+ label: String,
+ placeholder: String,
+ singleLine: Boolean,
+ readOnly: Boolean = !isEditable,
+ keyboardType: KeyboardType = KeyboardType.Ascii,
+ trailingIcon: ImageVector? = null,
+ supportingText: @Composable (ColumnScope.() -> Unit)? = null,
+ onFocusChange: ((Boolean) -> Unit)? = null,
+ interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
+ trailingContent: (@Composable () -> Unit)? = null
+ ) {
+ var clearFocus by remember { mutableStateOf(false) }
+ var isFocused by remember { mutableStateOf(false) }
+
+ // if the keyboard is gone clear focus from the field as a side-effect
+ ClearFocus(clearFocus) { clearFocus = false }
+
+ Column(modifier = modifier
+ .onFocusChanged {
+ isFocused = it.hasFocus
+ onFocusChange?.invoke(it.hasFocus)
+ }
+ .pointerInput(Unit) {
+ // any tap on a blank space will also dismiss the keyboard and clear focus
+ detectTapGestures { clearFocus = true }
+ }
+ .padding(start = 15.dp, end = 15.dp, top = 10.dp, bottom = 10.dp)
+ ) {
+ OutlinedTextField(
+ value = text,
+ onValueChange = onValueChange,
+ modifier = Modifier
+ .fillMaxWidth()
+ .semantics { contentDescription = "outlined text field" },
+ readOnly = readOnly,
+ label = {
+ Text(
+ text = label,
+ modifier = Modifier.semantics { contentDescription = "label" },
+ overflow = TextOverflow.Ellipsis,
+ maxLines = 1
+ )
+ },
+ trailingIcon = trailingContent
+ ?: trailingIcon(
+ text,
+ isEditable,
+ singleLine,
+ isFocused,
+ trailingIcon,
+ onValueChange = onValueChange,
+ onDone = { clearFocus = true }
+ ),
+ supportingText = {
+ Column(
+ modifier = Modifier.clickable {
+ clearFocus = true
+ }
+ ) {
+ supportingText?.invoke(this)
+ }
+ },
+ visualTransformation = if (text.isEmpty())
+ PlaceholderTransformation(placeholder.ifEmpty { " " })
+ else VisualTransformation.None,
+ keyboardActions = KeyboardActions(
+ onDone = { clearFocus = true }
+ ),
+ keyboardOptions = KeyboardOptions.Default.copy(
+ imeAction = if (singleLine) ImeAction.Done else ImeAction.None,
+ keyboardType = keyboardType
+ ),
+ singleLine = singleLine,
+ interactionSource = interactionSource,
+ colors = baseTextFieldColors(
+ isEditable = isEditable,
+ isEmpty = text.isEmpty(),
+ isPlaceholderEmpty = placeholder.isEmpty()
+ )
+ )
+ }
+}
+
+
+@Preview(showBackground = true, backgroundColor = 0xFFFFFFFF)
+@Composable
+private fun BaseTextFieldPreview() {
+ MaterialTheme {
+ BaseTextField(
+ text = "",
+ onValueChange = {},
+ isEditable = true,
+ label = "Title",
+ placeholder = "Enter Value",
+ singleLine = true,
+ trailingIcon = Icons.Rounded.TextFields,
+ supportingText = {
+ Text(text = "A Description")
+ }
+ )
+ }
+}
diff --git a/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/base/BaseTextFieldDefaults.kt b/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/base/BaseTextFieldDefaults.kt
new file mode 100644
index 000000000..520ba38c7
--- /dev/null
+++ b/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/base/BaseTextFieldDefaults.kt
@@ -0,0 +1,73 @@
+/*
+ * Copyright 2023 Esri
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.arcgismaps.toolkit.featureforms.components.base
+
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextFieldDefaults
+import androidx.compose.material3.TextFieldColors
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+
+@Composable
+internal fun baseTextFieldColors(isEditable: Boolean, isEmpty: Boolean, isPlaceholderEmpty: Boolean): TextFieldColors {
+ val textColor = BaseTextFieldColors.textColor(
+ isEditable = isEditable,
+ isEmpty = isEmpty,
+ isPlaceHolderEmpty = isPlaceholderEmpty
+ )
+ val borderColor = BaseTextFieldColors.borderColor(
+ isEditable = isEditable
+ )
+ val labelColor = BaseTextFieldColors.labelColor(
+ isEditable = isEditable
+ )
+ return OutlinedTextFieldDefaults.colors(
+ focusedTextColor = textColor,
+ unfocusedTextColor = textColor,
+ focusedBorderColor = borderColor,
+ focusedLabelColor = labelColor
+ )
+}
+/**
+ * Color properties of a base text field.
+ */
+internal object BaseTextFieldColors {
+ @Composable
+ fun borderColor(isEditable: Boolean) =
+ if (isEditable)
+ MaterialTheme.colorScheme.primary
+ else
+ MaterialTheme.colorScheme.secondary.copy(alpha = 0.8f)
+
+ @Composable
+ fun labelColor(isEditable: Boolean) =
+ if (isEditable)
+ MaterialTheme.colorScheme.primary
+ else
+ MaterialTheme.colorScheme.secondary.copy(alpha = 0.8f)
+
+ @Composable
+ fun textColor(isEditable: Boolean, isEmpty: Boolean, isPlaceHolderEmpty: Boolean): Color {
+ val color = if (isEmpty && !isPlaceHolderEmpty) {
+ Color.Gray
+ } else {
+ MaterialTheme.colorScheme.secondary
+ }
+
+ return if (isEditable) color else color.copy(alpha = 0.6f)
+ }
+}
diff --git a/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/codedvalue/CodedValueFieldState.kt b/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/codedvalue/CodedValueFieldState.kt
new file mode 100644
index 000000000..9e129af13
--- /dev/null
+++ b/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/codedvalue/CodedValueFieldState.kt
@@ -0,0 +1,187 @@
+/*
+ * Copyright 2023 Esri
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.arcgismaps.toolkit.featureforms.components.codedvalue
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.saveable.Saver
+import androidx.compose.runtime.saveable.listSaver
+import androidx.compose.runtime.saveable.rememberSaveable
+import com.arcgismaps.data.CodedValue
+import com.arcgismaps.data.FieldType
+import com.arcgismaps.mapping.featureforms.ComboBoxFormInput
+import com.arcgismaps.mapping.featureforms.FeatureForm
+import com.arcgismaps.mapping.featureforms.FieldFormElement
+import com.arcgismaps.mapping.featureforms.FormInputNoValueOption
+import com.arcgismaps.toolkit.featureforms.components.base.BaseFieldState
+import com.arcgismaps.toolkit.featureforms.components.base.FieldProperties
+import com.arcgismaps.toolkit.featureforms.components.text.TextFieldProperties
+import com.arcgismaps.toolkit.featureforms.utils.editValue
+import com.arcgismaps.toolkit.featureforms.utils.fieldType
+import com.arcgismaps.toolkit.featureforms.utils.valueFlow
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.launch
+
+internal open class CodedValueFieldProperties(
+ label: String,
+ placeholder: String,
+ description: String,
+ value: StateFlow,
+ required: StateFlow,
+ editable: StateFlow,
+ visible: StateFlow,
+ val fieldType: FieldType,
+ val codedValues: List,
+ val showNoValueOption: FormInputNoValueOption,
+ val noValueLabel: String
+) : FieldProperties(label, placeholder, description, value, required, editable, visible)
+
+/**
+ * A class to handle the state of a [ComboBoxField]. Essential properties are inherited
+ * from the [BaseFieldState].
+ *
+ * @param properties the [CodedValueFieldProperties] associated with this state.
+ * @param initialValue optional initial value to set for this field. It is set to the value of
+ * [TextFieldProperties.value] by default.
+ * @param scope a [CoroutineScope] to start [StateFlow] collectors on.
+ * @param onEditValue a callback to invoke when the user edits result in a change of value. This
+ * is called on [CodedValueFieldState.onValueChanged].
+ */
+@Stable
+internal open class CodedValueFieldState(
+ properties: CodedValueFieldProperties,
+ initialValue: String = properties.value.value,
+ scope: CoroutineScope,
+ onEditValue: ((Any?) -> Unit)
+) : BaseFieldState(
+ properties = properties,
+ scope = scope,
+ initialValue = initialValue,
+ onEditValue = onEditValue
+) {
+ /**
+ * The list of coded values associated with this field.
+ */
+ val codedValues: List = properties.codedValues
+
+ /**
+ * This property defines whether to display a special "no value" option if this field is
+ * optional.
+ */
+ val showNoValueOption: FormInputNoValueOption = properties.showNoValueOption
+
+ /**
+ * The custom label to use if [showNoValueOption] is enabled.
+ */
+ val noValueLabel: String = properties.noValueLabel
+
+ /**
+ * The FieldType of the element's Field.
+ */
+ val fieldType: FieldType = properties.fieldType
+
+ /**
+ * Returns the name of the [code] if it is present in [codedValues] else returns null.
+ */
+ fun getCodedValueNameOrNull(code: Any?): String? {
+ return codedValues.find {
+ it.code.toString() == code.toString()
+ }?.name
+ }
+
+ override fun onValueChanged(input: String) {
+ val code = codedValues.firstOrNull {
+ it.name == input
+ }?.code
+ onEditValue(code)
+ _value.value = input
+ }
+
+ companion object {
+ /**
+ * The default saver for a [CodedValueFieldState] implemented for a [ComboBoxFormInput] type.
+ * Hence for [formElement] the [FieldFormElement.input] type must be a [ComboBoxFormInput].
+ */
+ fun Saver(
+ formElement: FieldFormElement,
+ form: FeatureForm,
+ scope: CoroutineScope
+ ): Saver = listSaver(
+ save = {
+ listOf(
+ it.value.value
+ )
+ },
+ restore = { list ->
+ val input = formElement.input as ComboBoxFormInput
+ CodedValueFieldState(
+ properties = CodedValueFieldProperties(
+ label = formElement.label,
+ placeholder = formElement.hint,
+ description = formElement.description,
+ value = formElement.valueFlow(scope),
+ editable = formElement.isEditable,
+ required = formElement.isRequired,
+ visible = formElement.isVisible,
+ codedValues = input.codedValues,
+ showNoValueOption = input.noValueOption,
+ noValueLabel = input.noValueLabel,
+ fieldType = form.fieldType(formElement)
+ ),
+ initialValue = list[0],
+ scope = scope,
+ onEditValue = { newValue ->
+ form.editValue(formElement, newValue)
+ scope.launch { form.evaluateExpressions() }
+ }
+ )
+ }
+ )
+ }
+}
+
+@Composable
+internal fun rememberCodedValueFieldState(
+ field: FieldFormElement,
+ form: FeatureForm,
+ scope: CoroutineScope
+): CodedValueFieldState = rememberSaveable(
+ saver = CodedValueFieldState.Saver(field, form, scope)
+) {
+ val input = field.input as ComboBoxFormInput
+ CodedValueFieldState(
+ properties = CodedValueFieldProperties(
+ label = field.label,
+ placeholder = field.hint,
+ description = field.description,
+ value = field.valueFlow(scope),
+ editable = field.isEditable,
+ required = field.isRequired,
+ visible = field.isVisible,
+ codedValues = input.codedValues,
+ showNoValueOption = input.noValueOption,
+ noValueLabel = input.noValueLabel,
+ fieldType = form.fieldType(field)
+ ),
+ scope = scope,
+ onEditValue = {
+ form.editValue(field, it)
+ scope.launch { form.evaluateExpressions() }
+ }
+ )
+}
diff --git a/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/codedvalue/ComboBoxField.kt b/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/codedvalue/ComboBoxField.kt
new file mode 100644
index 000000000..bc3e0816a
--- /dev/null
+++ b/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/codedvalue/ComboBoxField.kt
@@ -0,0 +1,345 @@
+/*
+ * Copyright 2023 Esri
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.arcgismaps.toolkit.featureforms.components.codedvalue
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.interaction.PressInteraction
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Check
+import androidx.compose.material.icons.outlined.Close
+import androidx.compose.material.icons.outlined.List
+import androidx.compose.material3.Divider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.LocalTextStyle
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TextField
+import androidx.compose.material3.TextFieldDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.font.FontStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
+import com.arcgismaps.data.FieldType
+import com.arcgismaps.mapping.featureforms.FormInputNoValueOption
+import com.arcgismaps.toolkit.featureforms.R
+import com.arcgismaps.toolkit.featureforms.components.base.BaseTextField
+import kotlinx.coroutines.flow.MutableStateFlow
+
+@Composable
+internal fun ComboBoxField(
+ state: CodedValueFieldState,
+ modifier: Modifier = Modifier,
+ onDialogRequest: () -> Unit = {}
+) {
+ val value by state.value.collectAsState()
+ val isEditable by state.isEditable.collectAsState()
+ val isRequired by state.isRequired.collectAsState()
+ val interactionSource = remember { MutableInteractionSource() }
+ // to check if the field was ever focused by the user
+ var wasFocused by rememberSaveable { mutableStateOf(false) }
+ val label = remember(isRequired) {
+ if (isRequired) {
+ "${state.label} *"
+ } else {
+ state.label
+ }
+ }
+ val placeholder = if (isRequired) {
+ stringResource(R.string.enter_value)
+ } else if (state.showNoValueOption == FormInputNoValueOption.Show) {
+ state.noValueLabel.ifEmpty { stringResource(R.string.no_value) }
+ } else ""
+
+ BaseTextField(
+ text = state.getCodedValueNameOrNull(value) ?: value,
+ onValueChange = {
+ state.onValueChanged(it)
+ // consider a "clear" operation to be a focused state even though the clear icon
+ // is not part of the field's focus target
+ if (it.isEmpty()) wasFocused = true
+ },
+ modifier = modifier,
+ readOnly = true,
+ isEditable = isEditable,
+ label = label,
+ placeholder = placeholder,
+ singleLine = true,
+ trailingIcon = Icons.Outlined.List,
+ supportingText = {
+ // if the field was focused and is required, validate the current value
+ if (wasFocused && isRequired && value.isEmpty()) {
+ Text(
+ text = stringResource(id = R.string.required),
+ color = MaterialTheme.colorScheme.error
+ )
+ } else {
+ Text(
+ text = state.description,
+ modifier = Modifier.semantics { contentDescription = "description" },
+ )
+ }
+ },
+ interactionSource = interactionSource
+ )
+
+ LaunchedEffect(interactionSource) {
+ interactionSource.interactions.collect {
+ if (it is PressInteraction.Release) {
+ wasFocused = true
+ if (isEditable) {
+ onDialogRequest()
+ }
+ }
+ }
+ }
+}
+
+@Composable
+internal fun ComboBoxDialog(
+ initialValue: String,
+ values: Map,
+ label: String,
+ description: String,
+ isRequired: Boolean,
+ noValueOption: FormInputNoValueOption,
+ noValueLabel: String,
+ keyboardType: KeyboardType,
+ onValueChange: (String) -> Unit,
+ onDismissRequest: () -> Unit
+) {
+ var searchText by rememberSaveable { mutableStateOf("") }
+ val codedValues = if (!isRequired) {
+ if (noValueOption == FormInputNoValueOption.Show) {
+ mapOf("" to noValueLabel) + values
+ } else values
+ } else values
+
+ val filteredList by remember {
+ derivedStateOf {
+ codedValues.filter {
+ it.value.contains(searchText, ignoreCase = true)
+ }
+ }
+ }
+
+ Dialog(
+ onDismissRequest = onDismissRequest,
+ properties = DialogProperties(usePlatformDefaultWidth = false)
+ ) {
+ Scaffold(
+ topBar = {
+ Column(
+ modifier = Modifier
+ .padding(start = 15.dp, top = 15.dp, bottom = 10.dp, end = 10.dp)
+ .fillMaxWidth(),
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ TextField(
+ value = searchText,
+ onValueChange = {
+ searchText = it
+ },
+ modifier = Modifier.weight(1f),
+ placeholder = {
+ Text(text = stringResource(R.string.filter, label))
+ },
+ trailingIcon = {
+ if (searchText.isNotEmpty()) {
+ IconButton(onClick = { searchText = "" }) {
+ Icon(
+ imageVector = Icons.Outlined.Close,
+ contentDescription = null
+ )
+ }
+ }
+ },
+ singleLine = true,
+ keyboardOptions = KeyboardOptions.Default.copy(
+ imeAction = ImeAction.Done,
+ keyboardType = keyboardType
+ ),
+ shape = RoundedCornerShape(15.dp),
+ colors = TextFieldDefaults.colors(
+ focusedIndicatorColor = Color.Transparent,
+ unfocusedIndicatorColor = Color.Transparent
+ ),
+ )
+ TextButton(
+ onClick = onDismissRequest,
+ modifier = Modifier.semantics {
+ contentDescription = "combo box done selection"
+ }
+ ) {
+ Text(text = stringResource(R.string.done))
+ }
+ }
+ Text(
+ text = description,
+ modifier = Modifier.padding(top = 10.dp),
+ style = MaterialTheme.typography.labelSmall
+ )
+ }
+ }
+ ) { padding ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding)
+ ) {
+ Divider(
+ modifier = Modifier
+ .fillMaxWidth()
+ )
+ LazyColumn(modifier = Modifier
+ .fillMaxSize()
+ .semantics {
+ contentDescription = "ComboBoxDialogLazyColumn"
+ }) {
+ items(filteredList.count()) {
+ val code = filteredList.keys.elementAt(it)
+ val name = filteredList.getValue(code)
+ ListItem(
+ headlineContent = {
+ Text(
+ text = name,
+ style = if (name == noValueLabel) LocalTextStyle.current.copy(
+ fontStyle = FontStyle.Italic,
+ fontWeight = FontWeight.Light
+ )
+ else LocalTextStyle.current
+ )
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable {
+ // if the no value label was selected, set the value to be empty
+ if (name == noValueLabel) {
+ onValueChange("")
+ } else {
+ onValueChange(name)
+ }
+ }
+ .semantics {
+ contentDescription = if (name == noValueLabel) {
+ "no value row"
+ } else {
+ "$name list item"
+ }
+ },
+ trailingContent = {
+ if (name == initialValue || (name == noValueLabel && initialValue.isEmpty())
+ ) {
+ Icon(
+ imageVector = Icons.Outlined.Check,
+ contentDescription = "list item check"
+ )
+ }
+ }
+ )
+ }
+ }
+ Spacer(modifier = Modifier.weight(1f))
+ }
+ }
+ }
+}
+
+@Preview(showBackground = true, backgroundColor = 0xFFFFFFFF)
+@Composable
+private fun ComboBoxPreview() {
+ val scope = rememberCoroutineScope()
+ val state = CodedValueFieldState(
+ properties = CodedValueFieldProperties(
+ label = "Types",
+ placeholder = "",
+ description = "Select the tree species",
+ value = MutableStateFlow(""),
+ editable = MutableStateFlow(true),
+ required = MutableStateFlow(false),
+ visible = MutableStateFlow(true),
+ fieldType = FieldType.Text,
+ codedValues = listOf(),
+ showNoValueOption = FormInputNoValueOption.Show,
+ noValueLabel = "No value"
+ ),
+ scope = scope,
+ onEditValue = {}
+ )
+ ComboBoxField(state = state)
+}
+
+@Preview
+@Composable
+private fun ComboBoxDialogPreview() {
+ ComboBoxDialog(
+ initialValue = "x",
+ values = mapOf(
+ "Birch" to "Birch",
+ "Maple" to "Maple",
+ "Oak" to "Oak",
+ "Spruce" to "Spruce",
+ "Hickory" to "Hickory",
+ "Hemlock" to "Hemlock"
+ ),
+ label = "Types",
+ description = "Select the tree species",
+ isRequired = false,
+ noValueOption = FormInputNoValueOption.Show,
+ noValueLabel = "No Value",
+ onValueChange = {},
+ onDismissRequest = {},
+ keyboardType = KeyboardType.Ascii
+ )
+}
diff --git a/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/codedvalue/RadioButtonField.kt b/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/codedvalue/RadioButtonField.kt
new file mode 100644
index 000000000..abbf1f969
--- /dev/null
+++ b/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/codedvalue/RadioButtonField.kt
@@ -0,0 +1,192 @@
+/*
+ * Copyright 2023 Esri
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.arcgismaps.toolkit.featureforms.components.codedvalue
+
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.selection.selectable
+import androidx.compose.foundation.selection.selectableGroup
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.LocalContentColor
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.RadioButton
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.arcgismaps.mapping.featureforms.FormInputNoValueOption
+import com.arcgismaps.toolkit.featureforms.R
+
+@Composable
+internal fun RadioButtonField(
+ state: RadioButtonFieldState,
+ modifier: Modifier = Modifier,
+ colors: RadioButtonFieldColors = RadioButtonFieldDefaults.colors()
+) {
+ val value by state.value.collectAsState()
+ val editable by state.isEditable.collectAsState()
+ val required by state.isRequired.collectAsState()
+ val noValueLabel = state.noValueLabel.ifEmpty { stringResource(R.string.no_value) }
+ RadioButtonField(
+ label = state.label,
+ description = state.description,
+ value = value,
+ editable = editable,
+ required = required,
+ codedValues = state.codedValues.associateBy({ it.code }, { it.name }),
+ showNoValueOption = state.showNoValueOption,
+ noValueLabel = noValueLabel,
+ modifier = modifier,
+ colors = colors
+ ) {
+ state.onValueChanged(it)
+ }
+}
+
+@Composable
+private fun RadioButtonField(
+ label: String,
+ description: String,
+ value: String,
+ editable: Boolean,
+ required: Boolean,
+ codedValues: Map,
+ showNoValueOption: FormInputNoValueOption,
+ noValueLabel: String,
+ modifier: Modifier = Modifier,
+ colors: RadioButtonFieldColors = RadioButtonFieldDefaults.colors(),
+ onValueChanged: (String) -> Unit = {}
+) {
+ val options = if (!required) {
+ if (showNoValueOption == FormInputNoValueOption.Show) {
+ mapOf("" to noValueLabel) + codedValues
+ } else codedValues
+ } else codedValues
+
+ Column(
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(start = 15.dp, end = 15.dp, top = 10.dp, bottom = 10.dp),
+ verticalArrangement = Arrangement.spacedBy(10.dp),
+ horizontalAlignment = Alignment.Start
+ ) {
+ Text(
+ text = if (required) {
+ "$label *"
+ } else {
+ label
+ },
+ style = MaterialTheme.typography.bodyMedium,
+ color = colors.labelColor
+ )
+ Column(
+ modifier = Modifier
+ .selectableGroup()
+ .border(
+ width = 1.dp,
+ color = colors.containerBorderColor,
+ shape = RoundedCornerShape(5.dp)
+ )
+ ) {
+ CompositionLocalProvider(
+ LocalContentColor provides colors.textColor
+ ) {
+ options.forEach { (_, name) ->
+ RadioButtonRow(
+ value = name,
+ selected = name == value || (name == noValueLabel && value.isEmpty()),
+ enabled = editable,
+ onClick = { onValueChanged(name) }
+ )
+ }
+ }
+ }
+ if (description.isNotEmpty()) {
+ Text(
+ text = description,
+ style = MaterialTheme.typography.bodySmall,
+ color = colors.supportingTextColor
+ )
+ }
+ }
+
+}
+
+@Composable
+private fun RadioButtonRow(
+ value: String,
+ selected: Boolean,
+ enabled: Boolean,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .selectable(
+ selected = selected,
+ enabled = enabled,
+ role = Role.RadioButton,
+ onClick = onClick,
+ )
+ .padding(10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(10.dp)
+ ) {
+ RadioButton(
+ selected = selected,
+ onClick = null,
+ enabled = enabled
+ )
+ Text(
+ text = value,
+ style = MaterialTheme.typography.bodyMedium
+ )
+ }
+}
+
+@Preview(showBackground = true, backgroundColor = 0xFFFFFFFF, showSystemUi = true)
+@Composable
+private fun RadioButtonFieldPreview() {
+ MaterialTheme {
+ RadioButtonField(
+ label = "A list of values",
+ description = "Description",
+ value = "",
+ editable = true,
+ required = true,
+ codedValues = mapOf(
+ "One" to "One",
+ "Two" to "Two",
+ "Three" to "Three"
+ ),
+ showNoValueOption = FormInputNoValueOption.Show,
+ noValueLabel = "No Value",
+ ) { }
+ }
+}
diff --git a/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/codedvalue/RadioButtonFieldDefaults.kt b/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/codedvalue/RadioButtonFieldDefaults.kt
new file mode 100644
index 000000000..85f62940d
--- /dev/null
+++ b/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/codedvalue/RadioButtonFieldDefaults.kt
@@ -0,0 +1,51 @@
+/*
+ * Copyright 2023 Esri
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.arcgismaps.toolkit.featureforms.components.codedvalue
+
+import androidx.compose.material3.LocalContentColor
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+
+internal object RadioButtonFieldDefaults {
+ @Composable
+ fun colors(): RadioButtonFieldColors = RadioButtonFieldColors(
+ labelColor = MaterialTheme.colorScheme.onSurfaceVariant,
+ supportingTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
+ errorColor = MaterialTheme.colorScheme.error,
+ containerBorderColor = MaterialTheme.colorScheme.outline,
+ textColor = LocalContentColor.current
+ )
+}
+
+/**
+ * Color properties of a radio button field.
+ *
+ * @property labelColor The color used for the label of this radio button field.
+ * @property supportingTextColor The color used for the supporting text of this radio button field.
+ * @property errorColor The color used for the supporting text of this radio button field when the value is considered
+ * invalid.
+ * @property containerBorderColor The color used for the container border of this radio button field.
+ * @property textColor The color used for the text of this radio button field options.
+ */
+internal data class RadioButtonFieldColors(
+ val labelColor: Color,
+ val supportingTextColor: Color,
+ val errorColor: Color,
+ val containerBorderColor: Color,
+ val textColor: Color
+)
diff --git a/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/codedvalue/RadioButtonFieldState.kt b/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/codedvalue/RadioButtonFieldState.kt
new file mode 100644
index 000000000..7b1c2fbde
--- /dev/null
+++ b/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/codedvalue/RadioButtonFieldState.kt
@@ -0,0 +1,132 @@
+/*
+ * Copyright 2023 Esri
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.arcgismaps.toolkit.featureforms.components.codedvalue
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.saveable.Saver
+import androidx.compose.runtime.saveable.listSaver
+import androidx.compose.runtime.saveable.rememberSaveable
+import com.arcgismaps.mapping.featureforms.FeatureForm
+import com.arcgismaps.mapping.featureforms.FieldFormElement
+import com.arcgismaps.mapping.featureforms.RadioButtonsFormInput
+import com.arcgismaps.toolkit.featureforms.utils.editValue
+import com.arcgismaps.toolkit.featureforms.utils.fieldType
+import com.arcgismaps.toolkit.featureforms.utils.valueFlow
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+
+internal typealias RadioButtonFieldProperties = CodedValueFieldProperties
+
+internal class RadioButtonFieldState(
+ properties: RadioButtonFieldProperties,
+ initialValue: String = properties.value.value,
+ scope: CoroutineScope,
+ onEditValue: ((Any?) -> Unit)
+) : CodedValueFieldState(
+ properties = properties,
+ initialValue = initialValue,
+ scope = scope,
+ onEditValue = onEditValue
+) {
+
+ /**
+ * Returns true if the current value of [value] is not in the [codedValues]. This should
+ * trigger a fallback to a ComboBox. If the [value] is empty then this returns false.
+ */
+ fun shouldFallback(): Boolean {
+ return if (value.value.isEmpty()) {
+ false
+ } else {
+ !codedValues.any {
+ it.name == value.value
+ }
+ }
+ }
+
+ companion object {
+
+ /**
+ * Default saver for the [RadioButtonFieldState].
+ */
+ fun Saver(
+ formElement: FieldFormElement,
+ form: FeatureForm,
+ scope: CoroutineScope
+ ): Saver = listSaver(
+ save = {
+ listOf(
+ it.value.value
+ )
+ },
+ restore = { list ->
+ val input = formElement.input as RadioButtonsFormInput
+ RadioButtonFieldState(
+ properties = RadioButtonFieldProperties(
+ label = formElement.label,
+ placeholder = formElement.hint,
+ description = formElement.description,
+ value = formElement.valueFlow(scope),
+ editable = formElement.isEditable,
+ required = formElement.isRequired,
+ visible = formElement.isVisible,
+ fieldType = form.fieldType(formElement),
+ codedValues = input.codedValues,
+ showNoValueOption = input.noValueOption,
+ noValueLabel = input.noValueLabel
+ ),
+ initialValue = list[0],
+ scope = scope,
+ onEditValue = { newValue ->
+ form.editValue(formElement, newValue)
+ scope.launch { form.evaluateExpressions() }
+ }
+ )
+ }
+ )
+ }
+}
+
+@Composable
+internal fun rememberRadioButtonFieldState(
+ field: FieldFormElement,
+ form: FeatureForm,
+ scope: CoroutineScope
+): RadioButtonFieldState = rememberSaveable(
+ saver = RadioButtonFieldState.Saver(field, form, scope)
+) {
+ val input = field.input as RadioButtonsFormInput
+ RadioButtonFieldState(
+ properties = RadioButtonFieldProperties(
+ label = field.label,
+ placeholder = field.hint,
+ description = field.description,
+ value = field.valueFlow(scope),
+ editable = field.isEditable,
+ required = field.isRequired,
+ visible = field.isVisible,
+ fieldType = form.fieldType(field),
+ codedValues = input.codedValues,
+ showNoValueOption = input.noValueOption,
+ noValueLabel = input.noValueLabel
+ ),
+ scope = scope,
+ onEditValue = {
+ form.editValue(field, it)
+ scope.launch { form.evaluateExpressions() }
+ }
+ )
+}
diff --git a/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/codedvalue/SwitchField.kt b/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/codedvalue/SwitchField.kt
new file mode 100644
index 000000000..2449d7dfb
--- /dev/null
+++ b/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/codedvalue/SwitchField.kt
@@ -0,0 +1,94 @@
+/*
+ * Copyright 2023 Esri
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.arcgismaps.toolkit.featureforms.components.codedvalue
+
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.interaction.PressInteraction
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.unit.dp
+import com.arcgismaps.toolkit.featureforms.components.base.BaseTextField
+
+@Composable
+internal fun SwitchField(state: SwitchFieldState, modifier: Modifier = Modifier) {
+ val codeName by state.value.collectAsState()
+ val checkedState = codeName == state.onValue.name
+ val value = if (checkedState) state.onValue.name else state.offValue.name
+ val isEditable by state.isEditable.collectAsState()
+ val interactionSource = remember { MutableInteractionSource() }
+ BaseTextField(
+ text = value,
+ onValueChange = {
+ state.onValueChanged(it)
+ },
+ modifier = modifier,
+ readOnly = true,
+ isEditable = isEditable,
+ label = state.label,
+ placeholder = state.placeholder,
+ singleLine = true,
+ supportingText = {
+ Text(
+ text = state.description,
+ modifier = Modifier.semantics { contentDescription = "description" },
+ )
+ },
+ interactionSource = interactionSource
+ ) {
+ Switch(
+ checked = checkedState,
+ onCheckedChange = { newState ->
+ val newValue = (
+ if (newState)
+ state.onValue.name
+ else
+ state.offValue.name
+ )
+ state.onValueChanged(newValue)
+ },
+ modifier = Modifier
+ .semantics { contentDescription = "switch" }
+ .padding(horizontal = 10.dp),
+ enabled = isEditable
+ )
+ }
+
+ LaunchedEffect(codeName) {
+ interactionSource.interactions.collect {
+ if (isEditable) {
+ if (it is PressInteraction.Release) {
+ val newValue = (
+ if (checkedState)
+ state.offValue.name
+ else
+ state.onValue.name
+ )
+ state.onValueChanged(newValue)
+ }
+ }
+ }
+ }
+}
diff --git a/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/codedvalue/SwitchFieldState.kt b/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/codedvalue/SwitchFieldState.kt
new file mode 100644
index 000000000..9e91508f6
--- /dev/null
+++ b/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/codedvalue/SwitchFieldState.kt
@@ -0,0 +1,189 @@
+/*
+ * Copyright 2023 Esri
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.arcgismaps.toolkit.featureforms.components.codedvalue
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.saveable.Saver
+import androidx.compose.runtime.saveable.listSaver
+import androidx.compose.runtime.saveable.rememberSaveable
+import com.arcgismaps.data.CodedValue
+import com.arcgismaps.data.FieldType
+import com.arcgismaps.mapping.featureforms.FeatureForm
+import com.arcgismaps.mapping.featureforms.FieldFormElement
+import com.arcgismaps.mapping.featureforms.FormInputNoValueOption
+import com.arcgismaps.mapping.featureforms.SwitchFormInput
+import com.arcgismaps.toolkit.featureforms.components.base.BaseFieldState
+import com.arcgismaps.toolkit.featureforms.utils.editValue
+import com.arcgismaps.toolkit.featureforms.utils.fieldIsNullable
+import com.arcgismaps.toolkit.featureforms.utils.fieldType
+import com.arcgismaps.toolkit.featureforms.utils.valueFlow
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.launch
+
+internal class SwitchFieldProperties(
+ label: String,
+ placeholder: String,
+ description: String,
+ value: StateFlow,
+ editable: StateFlow,
+ required: StateFlow,
+ visible : StateFlow,
+ fieldType: FieldType,
+ val onValue: CodedValue,
+ val offValue: CodedValue,
+ val fallback: Boolean,
+ showNoValueOption: FormInputNoValueOption,
+ noValueLabel: String
+) : CodedValueFieldProperties(
+ label,
+ placeholder,
+ description,
+ value,
+ required,
+ editable,
+ visible,
+ fieldType,
+ listOf(onValue, offValue),
+ showNoValueOption,
+ noValueLabel
+)
+
+/**
+ * A class to handle the state of a [SwitchField]. Essential properties are inherited from the
+ * [BaseFieldState].
+ *
+ * @param properties the [SwitchFieldProperties] associated with this state.
+ * @property initialValue the initial value to set for this field. This value should be a CodedValue code or subtype.
+ * @param scope a [CoroutineScope] to start [StateFlow] collectors on.
+ * @param onEditValue a callback to invoke when the user edits result in a change of value. This
+ * is called on [SwitchFieldState.onValueChanged].
+ */
+@Stable
+internal class SwitchFieldState(
+ properties: SwitchFieldProperties,
+ val initialValue: String = properties.value.value,
+ scope: CoroutineScope,
+ onEditValue: ((Any?) -> Unit)
+) : CodedValueFieldState(
+ properties = properties,
+ scope = scope,
+ initialValue = initialValue,
+ onEditValue = onEditValue
+) {
+ /**
+ * The CodedValue that represents the "on" state of the Switch.
+ */
+ val onValue: CodedValue = properties.onValue
+
+ /**
+ * The CodedValue that represents the "off" state of the Switch.
+ */
+ val offValue: CodedValue = properties.offValue
+
+ /**
+ * Whether this Switch should fall back to being displayed as a ComboBox.
+ */
+ val fallback: Boolean = properties.fallback
+
+ companion object {
+ fun Saver(
+ formElement: FieldFormElement,
+ form: FeatureForm,
+ scope: CoroutineScope,
+ noValueString: String
+ ): Saver = listSaver(
+ save = {
+ listOf(
+ it.value.value,
+ it.fallback
+ )
+ },
+ restore = { list ->
+ val input = formElement.input as SwitchFormInput
+ SwitchFieldState(
+ properties = SwitchFieldProperties(
+ label = formElement.label,
+ placeholder = formElement.hint,
+ description = formElement.description,
+ value = formElement.valueFlow(scope),
+ editable = formElement.isEditable,
+ required = formElement.isRequired,
+ visible = formElement.isVisible,
+ fieldType = form.fieldType(formElement),
+ onValue = input.onValue,
+ offValue = input.offValue,
+ fallback = list[1] as Boolean,
+ showNoValueOption = if (form.fieldIsNullable(formElement))
+ FormInputNoValueOption.Show
+ else
+ FormInputNoValueOption.Hide,
+ noValueLabel = noValueString
+ ),
+ initialValue = list[0] as String,
+ scope = scope,
+ onEditValue = { codedValueName ->
+ form.editValue(
+ formElement,
+ if (codedValueName == input.onValue.name) input.onValue.code else input.offValue.code
+ )
+ scope.launch { form.evaluateExpressions() }
+ }
+ )
+ }
+ )
+ }
+}
+
+@Composable
+internal fun rememberSwitchFieldState(
+ field: FieldFormElement,
+ form: FeatureForm,
+ fallback: Boolean,
+ scope: CoroutineScope,
+ noValueString: String
+): SwitchFieldState = rememberSaveable(
+ saver = SwitchFieldState.Saver(field, form, scope, noValueString)
+) {
+ val input = field.input as SwitchFormInput
+ SwitchFieldState(
+ properties = SwitchFieldProperties(
+ label = field.label,
+ placeholder = field.hint,
+ description = field.description,
+ value = field.valueFlow(scope),
+ editable = field.isEditable,
+ required = field.isRequired,
+ visible = field.isVisible,
+ fieldType = form.fieldType(field),
+ onValue = input.onValue,
+ offValue = input.offValue,
+ fallback = fallback,
+ showNoValueOption = if (form.fieldIsNullable(field))
+ FormInputNoValueOption.Show
+ else
+ FormInputNoValueOption.Hide,
+ noValueLabel = noValueString
+ ),
+ scope = scope,
+ onEditValue = {
+ form.editValue(field, it)
+ scope.launch { form.evaluateExpressions() }
+ }
+ )
+}
diff --git a/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/datetime/DateTimeField.kt b/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/datetime/DateTimeField.kt
new file mode 100644
index 000000000..1037ccbb8
--- /dev/null
+++ b/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/datetime/DateTimeField.kt
@@ -0,0 +1,132 @@
+/*
+ *
+ * Copyright 2023 Esri
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package com.arcgismaps.toolkit.featureforms.components.datetime
+
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.interaction.PressInteraction
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.CalendarMonth
+import androidx.compose.material.icons.rounded.EditCalendar
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.tooling.preview.Preview
+import com.arcgismaps.toolkit.featureforms.R
+import com.arcgismaps.toolkit.featureforms.components.base.BaseTextField
+import kotlinx.coroutines.flow.MutableStateFlow
+
+@Composable
+internal fun DateTimeField(
+ state: DateTimeFieldState,
+ modifier: Modifier = Modifier,
+ onDialogRequest: () -> Unit
+) {
+ val isEditable by state.isEditable.collectAsState()
+ val isRequired by state.isRequired.collectAsState()
+ val instant by state.value.collectAsState()
+ val interactionSource = remember { MutableInteractionSource() }
+ // to check if the field was ever focused by the user
+ var wasFocused by rememberSaveable { mutableStateOf(false) }
+ val label = if (isRequired) {
+ "${state.label} *"
+ } else {
+ state.label
+ }
+
+ BaseTextField(
+ text = instant?.formattedDateTime(state.shouldShowTime) ?: "",
+ onValueChange = {
+ // the only allowable change is to clear the text
+ if (it.isEmpty()) {
+ state.onValueChanged(null)
+ }
+ },
+ modifier = modifier,
+ readOnly = true,
+ isEditable = isEditable,
+ label = label,
+ placeholder = state.placeholder.ifEmpty { stringResource(id = R.string.no_value) },
+ singleLine = true,
+ interactionSource = interactionSource,
+ trailingIcon = if (isEditable) Icons.Rounded.EditCalendar else Icons.Rounded.CalendarMonth,
+ supportingText = {
+ // if the field was focused and is required, validate the current value
+ if (wasFocused && isRequired && instant == null) {
+ Text(
+ text = stringResource(id = R.string.required),
+ color = MaterialTheme.colorScheme.error
+ )
+ } else {
+ Text(
+ text = state.description,
+ modifier = Modifier.semantics { contentDescription = "description" },
+ )
+ }
+ }
+ )
+
+ LaunchedEffect(interactionSource) {
+ interactionSource.interactions.collect {
+ if (it is PressInteraction.Release) {
+ wasFocused = true
+ // request to show the date picker dialog only when the touch is released
+ // the dialog is responsible for updating the value on the state
+ if (isEditable)
+ onDialogRequest()
+ }
+ }
+ }
+}
+
+@Preview(showBackground = true, backgroundColor = 0xFFFFFFFF)
+@Composable
+private fun DateTimeFieldPreview() {
+ MaterialTheme {
+ val scope = rememberCoroutineScope()
+ val state = DateTimeFieldState(
+ properties = DateTimeFieldProperties(
+ label = "Launch Date and Time",
+ placeholder = "",
+ description = "Enter the date for apollo 11 launch",
+ value = MutableStateFlow(null),
+ editable = MutableStateFlow(true),
+ required = MutableStateFlow(false),
+ visible = MutableStateFlow(true),
+ minEpochMillis = null,
+ maxEpochMillis = null,
+ shouldShowTime = true
+ ),
+ scope = scope,
+ onEditValue = {}
+ )
+ DateTimeField(state = state) {}
+ }
+}
diff --git a/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/datetime/DateTimeFieldState.kt b/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/datetime/DateTimeFieldState.kt
new file mode 100644
index 000000000..0300fa942
--- /dev/null
+++ b/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/datetime/DateTimeFieldState.kt
@@ -0,0 +1,151 @@
+/*
+ *
+ * Copyright 2023 Esri
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package com.arcgismaps.toolkit.featureforms.components.datetime
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.saveable.Saver
+import androidx.compose.runtime.saveable.listSaver
+import androidx.compose.runtime.saveable.rememberSaveable
+import com.arcgismaps.mapping.featureforms.DateTimePickerFormInput
+import com.arcgismaps.mapping.featureforms.FeatureForm
+import com.arcgismaps.mapping.featureforms.FieldFormElement
+import com.arcgismaps.toolkit.featureforms.components.base.BaseFieldState
+import com.arcgismaps.toolkit.featureforms.components.base.FieldProperties
+import com.arcgismaps.toolkit.featureforms.components.text.FormTextFieldState
+import com.arcgismaps.toolkit.featureforms.components.text.TextFieldProperties
+import com.arcgismaps.toolkit.featureforms.utils.editValue
+import com.arcgismaps.toolkit.featureforms.utils.valueFlow
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.launch
+import java.time.Instant
+
+internal class DateTimeFieldProperties(
+ label: String,
+ placeholder: String,
+ description: String,
+ value: StateFlow,
+ required: StateFlow,
+ editable: StateFlow,
+ visible: StateFlow,
+ val minEpochMillis: Instant?,
+ val maxEpochMillis: Instant?,
+ val shouldShowTime: Boolean
+) : FieldProperties(label, placeholder, description, value, required, editable, visible)
+
+/**
+ * A class to handle the state of a [DateTimeField]. Essential properties are inherited from the
+ * [BaseFieldState].
+ *
+ * @param properties the [TextFieldProperties] associated with this state.
+ * @param initialValue optional initial value to set for this field. It is set to the value of
+ * [DateTimeFieldProperties.value] by default.
+ * @param scope a [CoroutineScope] to start [StateFlow] collectors on.
+ * @param onEditValue a callback to invoke when the user edits result in a change of value. This
+ * is called on [FormTextFieldState.onValueChanged].
+ */
+internal class DateTimeFieldState(
+ properties: DateTimeFieldProperties,
+ initialValue: Instant? = properties.value.value,
+ scope: CoroutineScope,
+ onEditValue: (Any?) -> Unit
+) : BaseFieldState(
+ properties = properties,
+ initialValue = initialValue,
+ scope = scope,
+ onEditValue = onEditValue
+) {
+ val minEpochMillis: Instant? = properties.minEpochMillis
+
+ val maxEpochMillis: Instant? = properties.maxEpochMillis
+
+ val shouldShowTime: Boolean = properties.shouldShowTime
+
+ companion object {
+ fun Saver(
+ field: FieldFormElement,
+ form: FeatureForm,
+ scope: CoroutineScope
+ ): Saver = listSaver(
+ save = {
+ listOf(it.value.value)
+ },
+ restore = { list ->
+ val input = field.input as DateTimePickerFormInput
+ DateTimeFieldState(
+ properties = DateTimeFieldProperties(
+ label = field.label,
+ placeholder = field.hint,
+ description = field.description,
+ value = field.valueFlow(scope),
+ editable = field.isEditable,
+ required = field.isRequired,
+ visible = field.isVisible,
+ minEpochMillis = input.min,
+ maxEpochMillis = input.max,
+ shouldShowTime = input.includeTime
+ ),
+ initialValue = list[0],
+ scope = scope,
+ onEditValue = {
+ form.editValue(field, it)
+ scope.launch { form.evaluateExpressions() }
+ }
+ )
+ }
+ )
+ }
+}
+
+@Composable
+internal fun rememberDateTimeFieldState(
+ field: FieldFormElement,
+ minEpochMillis: Instant?,
+ maxEpochMillis: Instant?,
+ shouldShowTime: Boolean,
+ form: FeatureForm,
+ scope: CoroutineScope
+): DateTimeFieldState = rememberSaveable(
+ saver = DateTimeFieldState.Saver(
+ field = field,
+ form = form,
+ scope = scope
+ )
+) {
+ DateTimeFieldState(
+ properties = DateTimeFieldProperties(
+ label = field.label,
+ placeholder = field.hint,
+ description = field.description,
+ value = field.valueFlow(scope),
+ editable = field.isEditable,
+ required = field.isRequired,
+ visible = field.isVisible,
+ minEpochMillis = minEpochMillis,
+ maxEpochMillis = maxEpochMillis,
+ shouldShowTime = shouldShowTime
+ ),
+ scope = scope,
+ onEditValue = {
+ form.editValue(field, it)
+ scope.launch { form.evaluateExpressions() }
+ }
+ )
+}
+
diff --git a/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/datetime/DateUtil.kt b/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/datetime/DateUtil.kt
new file mode 100644
index 000000000..94772b856
--- /dev/null
+++ b/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/datetime/DateUtil.kt
@@ -0,0 +1,100 @@
+/*
+ *
+ * Copyright 2023 Esri
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package com.arcgismaps.toolkit.featureforms.components.datetime
+
+import java.time.Instant
+import java.time.ZoneOffset
+import java.time.ZonedDateTime
+import java.time.format.DateTimeFormatter
+import java.util.TimeZone
+
+/**
+ * A convenience method to format epoch seconds in the current zone
+ *
+ * @return a zoned date time in the zone of runtime execution.
+ * @since 200.3.0
+ */
+internal fun Long.toZonedDateTime(): ZonedDateTime {
+ val instant = Instant.ofEpochMilli(this)
+ return instant.atZone(TimeZone.getDefault().toZoneId())
+}
+
+/**
+ * A convenience method to help get the components of a date in UTC.
+ *
+ * @return a zoned date time in the UTC zone.
+ * @since 200.3.0
+ */
+internal fun Long.toDateTimeInUtcZone(): ZonedDateTime {
+ val instant = Instant.ofEpochMilli(this)
+ return instant.atZone(ZoneOffset.UTC)
+}
+
+/**
+ * Get the millis for a given datetime at midnight of the same day, in millis.
+ *
+ * @return the millis as of midnight of the same day, i.e. the beginning of the day.
+ * @since 200.3.0
+ */
+internal fun Long.toDateMillis(): Long {
+ val utcDateTime = toDateTimeInUtcZone()
+ val hours = utcDateTime.hour
+ val minutes = utcDateTime.minute
+ val seconds = utcDateTime.second
+
+ return utcDateTime
+ .minusHours(hours.toLong())
+ .minusMinutes(minutes.toLong())
+ .minusSeconds(seconds.toLong())
+ .toEpochSecond() * 1000
+}
+
+/**
+ * Formats an Instant for the current timezone
+ *
+ * @param includeTime format the time if true
+ * @return a string formatted for the value in epoch milliseconds
+ * @since 200.3.0
+ */
+internal fun Instant.formattedDateTime(includeTime: Boolean): String {
+ val formatter = if (includeTime) {
+ DateTimeFormatter.ofPattern("MMM dd, yyyy h:mm a")
+ } else {
+ DateTimeFormatter.ofPattern("MMM dd, yyyy")
+ }
+ return atZone(TimeZone.getDefault().toZoneId()).format(formatter)
+}
+
+/**
+ * Useful for logging
+ *
+ * @param includeTime format the time if true
+ * @return a string formatted for the value in epoch milliseconds
+ * @since 200.3.0
+ */
+@Suppress("unused")
+internal fun Long.formattedUtcDateTime(includeTime: Boolean): String {
+
+ val formatter = if (includeTime) {
+ DateTimeFormatter.ofPattern("MMM dd, yyyy h:mm a")
+ } else {
+ DateTimeFormatter.ofPattern("MMM dd, yyyy")
+ }
+ return this.toDateTimeInUtcZone().format(formatter)
+}
diff --git a/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/datetime/picker/DateTimePicker.kt b/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/datetime/picker/DateTimePicker.kt
new file mode 100644
index 000000000..116a3f4f3
--- /dev/null
+++ b/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/datetime/picker/DateTimePicker.kt
@@ -0,0 +1,397 @@
+/*
+ *
+ * Copyright 2023 Esri
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package com.arcgismaps.toolkit.featureforms.components.datetime.picker
+
+import android.content.res.Configuration
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.requiredWidth
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.layout.wrapContentHeight
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.AccessTime
+import androidx.compose.material.icons.rounded.CalendarMonth
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.DatePicker
+import androidx.compose.material3.DatePickerDefaults
+import androidx.compose.material3.DatePickerState
+import androidx.compose.material3.DisplayMode
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.key
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.composed
+import androidx.compose.ui.draw.scale
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.platform.LocalConfiguration
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.DialogProperties
+import com.arcgismaps.toolkit.featureforms.R
+import com.arcgismaps.toolkit.featureforms.components.datetime.picker.time.TimePicker
+import com.arcgismaps.toolkit.featureforms.components.datetime.picker.time.TimePickerState
+import java.time.Instant
+import java.util.TimeZone
+
+/**
+ * Defines the style of [DateTimePicker].
+ */
+internal enum class DateTimePickerStyle {
+ /**
+ * Date only picker style.
+ */
+ Date,
+
+ /**
+ * Time only picker style.
+ */
+ Time,
+
+ /**
+ * Date and Time picker style.
+ */
+ DateTime
+}
+
+/**
+ * Input type for the [DateTimePicker].
+ */
+internal enum class DateTimePickerInput {
+ Date,
+ Time
+}
+
+/**
+ * A material3 date and time picker presented as an [AlertDialog].
+ *
+ * @param state a state of the DateTimePicker. see [DateTimePickerState].
+ * @param onDismissRequest Dismiss the dialog when the user clicks outside the dialog or on the back button.
+ *
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+internal fun DateTimePicker(
+ state: DateTimePickerState,
+ onDismissRequest: () -> Unit,
+ onCancelled: () -> Unit,
+ onConfirmed: () -> Unit
+) {
+ // if the date time has no value, set a default value
+ if (state.dateTime.value.epochMillis == null) {
+ val now = Instant.now().toEpochMilli()
+ // check if current day and time is a valid timestamp
+ // it should be validated with the current local timestamp hence the added offset
+ if (state.dateTimeValidator(now.plus(now.defaultTimeZoneOffset))) {
+ // set the default timestamp value to the current local time instant
+ state.today(0, 0)
+ state.now()
+ }
+ }
+ // calculate the date ranges from the state
+ val datePickerRange = IntRange(
+ start = state.minDateTime?.atZone(TimeZone.getDefault().toZoneId())?.year
+ ?: DatePickerDefaults.YearRange.first,
+ endInclusive = state.maxDateTime?.atZone(TimeZone.getDefault().toZoneId())?.year
+ ?: DatePickerDefaults.YearRange.last
+ )
+ // The picker input type, date or time.
+ val pickerInput by state.activePickerInput
+ // DateTime from the state's value
+ val dateTime by state.dateTime
+ // create and remember a DatePickerState
+ val datePickerState = rememberSaveable(dateTime, saver = DatePickerState.Saver()) {
+ DatePickerState(
+ initialSelectedDateMillis = dateTime.dateForPicker,
+ initialDisplayedMonthMillis = dateTime.dateForPicker
+ ?: (state.minDateTime?.toEpochMilli() ?: state.maxDateTime?.toEpochMilli()),
+ datePickerRange,
+ DisplayMode.Picker
+ )
+ }
+ // create a DateTimePickerDialog
+ DateTimePickerDialog(
+ onDismissRequest = onDismissRequest
+ ) {
+ // create and remember a TimePickerState that resets when dateTime changes
+ val timePickerState = rememberSaveable(dateTime, saver = TimePickerState.Saver()) {
+ TimePickerState(
+ initialHour = dateTime.hourForPicker,
+ initialMinute = dateTime.minuteForPicker,
+ is24Hour = false,
+ )
+ }
+ PickerContent(
+ label = state.label,
+ description = state.description,
+ state = state,
+ datePickerState = datePickerState,
+ timePickerState = timePickerState,
+ style = state.pickerStyle,
+ picker = pickerInput
+ ) {
+ state.togglePickerInput()
+ }
+ PickerFooter(
+ state = state,
+ confirmEnabled = datePickerState.selectedDateMillis?.let {
+ state.dateTimeValidator(
+ it + timePickerState.hour * 3_600_000 + timePickerState.minute * 60_000
+ )
+ } ?: false,
+ pickerInput = pickerInput,
+ onToday = {
+ state.today(timePickerState.hour, timePickerState.minute)
+ },
+ onNow = {
+ state.now()
+ },
+ onCancelled = onCancelled,
+ onConfirmed = {
+ state.setDateTime(
+ date = datePickerState.selectedDateMillis,
+ hour = timePickerState.hour,
+ minute = timePickerState.minute
+ )
+ onConfirmed()
+ }
+ )
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun (ColumnScope).PickerContent(
+ label: String,
+ description: String,
+ state: DateTimePickerState,
+ datePickerState: DatePickerState,
+ timePickerState: TimePickerState,
+ style: DateTimePickerStyle,
+ picker: DateTimePickerInput,
+ onPickerToggle: () -> Unit
+) {
+ val title: @Composable (ImageVector?) -> Unit = {
+ PickerTitle(
+ label = label,
+ description = description,
+ icon = it,
+ onIconTap = onPickerToggle
+ )
+ }
+ // make the picker content scrollable if the screen height sizing is more restrictive
+ // like in landscape mode
+ LazyColumn(
+ modifier = Modifier.weight(1f, false),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ item {
+ if (picker == DateTimePickerInput.Time) {
+ title(if (style == DateTimePickerStyle.Time) null else Icons.Rounded.CalendarMonth)
+ Spacer(modifier = Modifier.height(10.dp))
+ TimePicker(state = timePickerState, modifier = Modifier.padding(10.dp))
+ } else {
+ key(state.dateTime.value) {
+ DatePicker(
+ state = datePickerState,
+ dateValidator = { timeStamp ->
+ state.dateValidator(timeStamp)
+ },
+ title = { title(if (style == DateTimePickerStyle.Date) null else Icons.Rounded.AccessTime) }
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun PickerTitle(
+ label: String,
+ description: String,
+ icon: ImageVector?,
+ onIconTap: () -> Unit = {}
+) {
+ Row(
+ Modifier
+ .padding(start = 25.dp, end = 15.dp, bottom = 10.dp)
+ .fillMaxWidth(),
+ horizontalArrangement = Arrangement.End,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column(Modifier.weight(1f)) {
+ Text(text = label, style = MaterialTheme.typography.titleMedium)
+ Spacer(modifier = Modifier.height(5.dp))
+ Text(text = description, style = MaterialTheme.typography.bodySmall)
+ }
+ icon?.let {
+ IconButton(onClick = onIconTap) {
+ Icon(
+ imageVector = it,
+ contentDescription = "Set Time"
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun PickerFooter(
+ state: DateTimePickerState,
+ confirmEnabled: Boolean,
+ pickerInput: DateTimePickerInput,
+ onToday: () -> Unit = {},
+ onNow: () -> Unit = {},
+ onCancelled: () -> Unit = {},
+ onConfirmed: () -> Unit = {}
+) {
+ Row(
+ Modifier
+ .wrapContentHeight()
+ .padding(start = 10.dp, end = 10.dp)
+ .fillMaxWidth()
+ ) {
+ if (pickerInput == DateTimePickerInput.Date) {
+ TextButton(
+ onClick = onToday,
+ // only enable Today button if today is within the range if provided
+ // the date validator assumes the Long is from the picker,
+ // i.e. offset from UTC.
+ enabled = state.dateValidator(
+ UtcDateTime.create(Instant.now().toEpochMilli()).dateForPicker!!
+ )
+ ) {
+ Text(stringResource(R.string.today))
+ }
+ } else {
+ TextButton(onClick = onNow) {
+ Text(stringResource(R.string.now))
+ }
+ }
+ Spacer(modifier = Modifier.weight(1f))
+ TextButton(onClick = onCancelled) {
+ Text(stringResource(R.string.cancel))
+ }
+ TextButton(onClick = onConfirmed, enabled = confirmEnabled) {
+ Text(stringResource(R.string.ok))
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun DateTimePickerDialog(
+ onDismissRequest: () -> Unit,
+ modifier: Modifier = Modifier,
+ shape: Shape = DatePickerDefaults.shape,
+ tonalElevation: Dp = DatePickerDefaults.TonalElevation,
+ properties: DialogProperties = DialogProperties(usePlatformDefaultWidth = false),
+ content: @Composable ColumnScope.() -> Unit
+) {
+ AlertDialog(
+ onDismissRequest = onDismissRequest,
+ modifier = modifier.wrapContentHeight(),
+ properties = properties
+ ) {
+ Surface(
+ modifier = Modifier
+ .padding(horizontal = DateTimePickerDialogTokens.horizontalPadding)
+ .widthWithOrientation(DateTimePickerDialogTokens.containerWidth)
+ .height(DateTimePickerDialogTokens.containerHeight)
+ .scaleIfNarrow(DateTimePickerDialogTokens.containerWidth + DateTimePickerDialogTokens.horizontalPadding * 2),
+ shape = shape,
+ color = MaterialTheme.colorScheme.surface,
+ tonalElevation = tonalElevation,
+ ) {
+ Column(
+ modifier = modifier.padding(top = 25.dp, bottom = 10.dp),
+ verticalArrangement = Arrangement.SpaceBetween,
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ content()
+ }
+ }
+ }
+}
+
+/**
+ * Properties for the [DateTimePickerDialog].
+ */
+private object DateTimePickerDialogTokens {
+ val containerHeight = 600.0.dp
+ val containerWidth = 360.0.dp
+ val horizontalPadding = 25.dp
+}
+
+/**
+ * Constraints the width of the content based on the orientation and the [width]. If the
+ * current orientation is portrait, [Modifier.requiredWidth] used. If it is landscape then
+ * [Modifier.widthIn] is used. This is useful when different layouts are needed in portrait
+ * and landscape orientations.
+ */
+internal fun Modifier.widthWithOrientation(width: Dp) : Modifier = composed {
+ val configuration = LocalConfiguration.current
+ if (configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) {
+ this.widthIn(width)
+ } else {
+ this.requiredWidth(width)
+ }
+}
+
+/**
+ * Scales the content appropriately if the current screen width is less than the [minWidth].
+ */
+internal fun Modifier.scaleIfNarrow(minWidth: Dp): Modifier = composed {
+ val screenWidth = LocalConfiguration.current.screenWidthDp.dp
+ val scale = if (screenWidth < minWidth)
+ screenWidth / minWidth
+ else 1f
+ this.scale(scale)
+}
+
+@Preview
+@Composable
+private fun DateTimePickerPreview() {
+ val state = DateTimePickerState(
+ style = DateTimePickerStyle.DateTime,
+ label = "Next Inspection Date",
+ description = "Enter a date in the next six months",
+ pickerInput = DateTimePickerInput.Date
+ )
+ DateTimePicker(state = state, {}, {}, {})
+}
diff --git a/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/datetime/picker/DateTimePickerState.kt b/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/datetime/picker/DateTimePickerState.kt
new file mode 100644
index 000000000..9ef967e24
--- /dev/null
+++ b/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/datetime/picker/DateTimePickerState.kt
@@ -0,0 +1,403 @@
+/*
+ *
+ * Copyright 2023 Esri
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package com.arcgismaps.toolkit.featureforms.components.datetime.picker
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.State
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.Saver
+import androidx.compose.runtime.saveable.listSaver
+import androidx.compose.runtime.saveable.rememberSaveable
+import com.arcgismaps.toolkit.featureforms.components.datetime.toDateMillis
+import com.arcgismaps.toolkit.featureforms.components.datetime.toDateTimeInUtcZone
+import com.arcgismaps.toolkit.featureforms.components.datetime.toZonedDateTime
+import java.time.Instant
+import java.util.TimeZone
+
+/**
+ * A class to hold a DateTime. [date] represents the number of milliseconds of the date (at Midnight) since epoch
+ * (January 1, 1970) in UTC. While [hour], [minute] and [second] represent time in local time zone.
+ */
+internal class UtcDateTime private constructor(
+ val epochMillis: Long?,
+ val date: Long?,
+ val hour: Int = 0,
+ val minute: Int = 0,
+ val second: Int = 0
+) {
+ /**
+ * Force the picker to show a zoned date by providing the utc datetime as millis, plus the zone offset,
+ * and truncated to midnight of the resulting epoch millis.
+ * the picker deals, and shows, dates as millis only, This will add the timezone offset to epoch millis,
+ * and then pass it to the picker to show the current zoned time. It is subsequently subtracted from any
+ * choice made by the user.
+ *
+ * @see createFromPickerValues
+ * @since 200.3.0
+ */
+ internal val dateForPicker: Long?
+ get() = epochMillis?.plus(epochMillis.defaultTimeZoneOffset)?.toDateMillis()
+
+ /**
+ * The hour of the datetime in the current timezone.
+ *
+ * @since 200.3.0
+ */
+ internal val hourForPicker: Int
+ get() = epochMillis?.toZonedDateTime()?.hour ?: hour
+
+ /**
+ * The minutes of the datetime in the current timezone.
+ *
+ * @since 200.3.0
+ */
+ internal val minuteForPicker: Int
+ get() = epochMillis?.toZonedDateTime()?.minute ?: minute
+
+ companion object {
+ /**
+ * Creates an instance of [UtcDateTime] using [epochMillis] with the [hour], [minute] and [second]
+ * representing time in the UTC zone. If the [epochMillis] value is null then the returned
+ * DateTime will have no date with time set to 0:00 hrs.
+ *
+ * @param epochMillis The number of milliseconds since epoch (January 1, 1970) in UTC.
+ * @return a new UtcDateTime
+ */
+ internal fun create(epochMillis: Long?): UtcDateTime {
+ val utcDateTime = epochMillis?.toDateTimeInUtcZone()
+ return UtcDateTime(
+ epochMillis,
+ epochMillis?.toDateMillis(),
+ utcDateTime?.hour ?: 0,
+ utcDateTime?.minute ?: 0,
+ utcDateTime?.second ?: 0
+ )
+ }
+
+ /**
+ * Used to set the datetime from the result of the datetime picker dialog.
+ * Since the date picker works and displays with millis only, in order to show the date and time
+ * in the current zone, we pass to it a long value which is not epoch millis, but epoch millis plus the
+ * current timezone offset millis. This value must now be subtracted off so the result represents epoch milliseconds.
+ *
+ * @param date the midnight UTC epoch millis of the date set in the picker
+ * @param hour the hour selected in the picker 0-23
+ * @param hour the minute selected in the picker 0-59
+ * @see dateForPicker
+ * @return a new UtcDateTime
+ * @since 200.3.0
+ */
+ internal fun createFromPickerValues(date: Long?, hour: Int, minute: Int): UtcDateTime {
+ val epochMillis = if (date != null) {
+ (date + hour * 3_600_000 + minute * 60_000).minus(date.defaultTimeZoneOffset)
+ } else {
+ null
+ }
+ return UtcDateTime(epochMillis, date, hour, minute)
+ }
+ }
+}
+
+/**
+ * State for [DateTimePicker]. Use factory [DateTimePicker()] to create an instance.
+ */
+internal interface DateTimePickerState {
+
+ /**
+ * Minimum date time allowed. This should be null if no range restriction is needed.
+ */
+ val minDateTime: Instant?
+
+ /**
+ * Maximum date time allowed. This should be null if no range restriction is needed.
+ */
+ val maxDateTime: Instant?
+
+ /**
+ * The current date time value. Use [setDateTime] to set this state.
+ */
+ val dateTime: State
+
+ /**
+ * A timestamp that represents the selected date and time in UTC milliseconds from the epoch.
+ * In case no date was selected or provided, this will hold a null value.
+ */
+ val selectedDateTimeMillis: Long?
+
+ /**
+ * Current time zone, calculated automatically based on locale.
+ */
+ val timeZone: TimeZone
+
+ /**
+ * Current time zone offset compared to UTC in milliseconds, calculated from [timeZone]
+ */
+ val timeZoneOffset: Int
+
+ /**
+ * The picker style to use.
+ */
+ val pickerStyle: DateTimePickerStyle
+
+ /**
+ * Current picker input type.
+ */
+ val activePickerInput: State
+
+ /**
+ * The label for the DateTimePicker.
+ */
+ val label: String
+
+ /**
+ * The description for the DateTimePicker.
+ */
+ val description: String
+
+ /**
+ * Sets the [dateTime].
+ *
+ * @param date the epoch millis at the start of the date (i.e. midnight)
+ * @param hour the hour of the day (0-23)
+ * @param minute the minute of the hour (0-59)
+ */
+ fun setDateTime(date: Long?, hour: Int, minute: Int)
+
+ /**
+ * Toggles the current picker input between [DateTimePickerInput.Date] and
+ * [DateTimePickerInput.Time].
+ */
+ fun togglePickerInput()
+
+ /**
+ * Validates if the [timeStamp] is between the given ranges of [minDateTime] and [maxDateTime]
+ * if they were provided. Returns true if the validation was successful, otherwise false
+ * is returned. Both the [minDateTime] and [maxDateTime] are included in the range.
+ */
+ fun dateTimeValidator(timeStamp: Long): Boolean
+
+ /**
+ * Validates if the UTC date of the [timeStamp] is between the dates of the given datetime ranges [minDateTime]
+ * and [maxDateTime] if they were provided. Returns true if the validation was successful, otherwise false
+ * is returned. Both the [minDateTime] and [maxDateTime] are included in the range.
+ */
+ fun dateValidator(timeStamp: Long): Boolean
+
+ /**
+ * Sets the [dateTime]'s time value to the current time instant in local time.
+ */
+ fun now()
+
+ /**
+ * Sets the [dateTime]'s day to the current day while persisting the time information
+ * as specified by the [hour] and [minute].
+ */
+ fun today(hour: Int, minute: Int)
+}
+
+/**
+ * Default implementation for [DateTimePickerState]
+ */
+private class DateTimePickerStateImpl(
+ override val pickerStyle: DateTimePickerStyle,
+ override val minDateTime: Instant?,
+ override val maxDateTime: Instant?,
+ initialValue: Instant?,
+ override val label: String,
+ override val description: String = "",
+ pickerInput: DateTimePickerInput
+) : DateTimePickerState {
+ override var dateTime = mutableStateOf(
+ UtcDateTime.create(initialValue?.toEpochMilli())
+ )
+ override val selectedDateTimeMillis: Long?
+ get() = dateTime.value.epochMillis
+
+ override val timeZone: TimeZone = TimeZone.getDefault()
+
+ override val timeZoneOffset = initialValue?.toEpochMilli()?.let { timeZone.getOffset(it) } ?: 0
+
+ override val activePickerInput = mutableStateOf(pickerInput)
+
+ override fun setDateTime(date: Long?, hour: Int, minute: Int) {
+ dateTime.value = UtcDateTime.createFromPickerValues(date, hour, minute)
+ }
+
+ override fun togglePickerInput() {
+ activePickerInput.value = if (activePickerInput.value == DateTimePickerInput.Date) {
+ DateTimePickerInput.Time
+ } else {
+ DateTimePickerInput.Date
+ }
+ }
+
+ override fun dateTimeValidator(timeStamp: Long): Boolean {
+ // the date time validator is invoked by the date picker,
+ // which operates in milliseconds that are offset from UTC
+ // To compare it to min and max, the input must be converted
+ // to UTC.
+ val utcDateTime = timeStamp.minus(timeStamp.defaultTimeZoneOffset)
+
+ return minDateTime?.toEpochMilli()?.let { min ->
+ maxDateTime?.toEpochMilli()?.let { max ->
+ utcDateTime in min..max
+ } ?: (utcDateTime >= min)
+ } ?: maxDateTime?.toEpochMilli()?.let {
+ utcDateTime <= it
+ } ?: true
+ }
+
+ override fun dateValidator(timeStamp: Long): Boolean {
+ // the date validator is invoked by the date picker,
+ // which operates in milliseconds that are offset from UTC
+ // To compare it to min and max, the input must be converted
+ // to UTC.
+ val utcDate = UtcDateTime.create(timeStamp.minus(timeStamp.defaultTimeZoneOffset)).date!!
+ val minDate = UtcDateTime.create(minDateTime?.toEpochMilli()).date
+ val maxDate = UtcDateTime.create(maxDateTime?.toEpochMilli()).date
+
+ return minDate?.let { min ->
+ maxDate?.let { max ->
+ utcDate in min..max
+ } ?: (utcDate >= min)
+ } ?: maxDate?.let {
+ utcDate <= it
+ } ?: true
+ }
+
+ override fun now() {
+ val now = Instant.now().toEpochMilli().toZonedDateTime()
+ setDateTime(
+ dateTime.value.dateForPicker,
+ now.hour,
+ now.minute
+ )
+ }
+
+ override fun today(hour: Int, minute: Int) {
+ val now = Instant.now().toEpochMilli()
+ setDateTime(
+ now.plus(now.defaultTimeZoneOffset).toDateMillis(),
+ hour,
+ minute
+ )
+ }
+}
+
+/**
+ * Factory function to create a [DateTimePickerState].
+ *
+ * @param style The picker style to use.
+ * @param minDateTime Minimum date time allowed in milliseconds. This should be null if no range
+ * restriction is needed.
+ * @param maxDateTime Maximum date time allowed in milliseconds. This should be null if no range
+ * restriction is needed.
+ * @param initialValue The initial date time value to display in milliseconds.
+ * @param label The label for the DateTimePicker.
+ * @param description The description for the DateTimePicker.
+ */
+internal fun DateTimePickerState(
+ style: DateTimePickerStyle,
+ minDateTime: Instant? = null,
+ maxDateTime: Instant? = null,
+ initialValue: Instant? = null,
+ label: String,
+ description: String = "",
+ pickerInput: DateTimePickerInput
+): DateTimePickerState = DateTimePickerStateImpl(
+ style,
+ minDateTime,
+ maxDateTime,
+ initialValue,
+ label,
+ description,
+ pickerInput
+)
+
+/**
+ * a Composable function to create and remember a DateTimePickerState instance
+ *
+ * @param style the date time picker style
+ * @param minDateTime the minimum pickable date time
+ * @param maxDateTime the maxiimum pickable date time
+ * @param initialValue the initial value in epoch milliseconds for the picker
+ * @param label the label from the element
+ * @param description the description from the element
+ * @param pickerInput the input type to display in the picker.
+ * @return a remembered DateTimePickerState
+ * @since 200.3.0
+ */
+@Composable
+internal fun rememberDateTimePickerState(
+ style: DateTimePickerStyle,
+ minDateTime: Instant? = null,
+ maxDateTime: Instant? = null,
+ initialValue: Instant? = null,
+ label: String,
+ description: String = "",
+ pickerInput: DateTimePickerInput
+): DateTimePickerState = rememberSaveable(saver = dateTimePickerStateSaver()) {
+ DateTimePickerState(
+ style,
+ minDateTime,
+ maxDateTime,
+ initialValue,
+ label,
+ description,
+ pickerInput
+ )
+}
+
+/**
+ * a StateSaver for the DateTimePickerState.
+ *
+ * @return a StateSaver
+ * @since 200.3.0
+ */
+internal fun dateTimePickerStateSaver(): Saver = listSaver(
+ save = {
+ listOf(it.pickerStyle,
+ it.minDateTime?.toEpochMilli(),
+ it.maxDateTime?.toEpochMilli(),
+ it.dateTime.value.epochMillis,
+ it.label,
+ it.description,
+ it.activePickerInput.value
+ )
+ },
+ restore = {
+ // note: passes the date time picker state exactly as saved to
+ // set the initial view of the dialog based on how it was saved,
+ // not on initial conditions.
+ DateTimePickerStateImpl(
+ it[0] as DateTimePickerStyle,
+ it[1]?.let { Instant.ofEpochMilli(it as Long) },
+ it[2]?.let { Instant.ofEpochMilli(it as Long) },
+ it[3]?.let { Instant.ofEpochMilli(it as Long) },
+ it[4] as String,
+ it[5] as String,
+ it[6] as DateTimePickerInput
+ )
+ }
+)
+
+internal val Long?.defaultTimeZoneOffset: Int
+ get() = this?.let {
+ TimeZone.getDefault().getOffset(it)
+ } ?: 0
diff --git a/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/datetime/picker/time/Strings.kt b/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/datetime/picker/time/Strings.kt
new file mode 100644
index 000000000..e620fd151
--- /dev/null
+++ b/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/datetime/picker/time/Strings.kt
@@ -0,0 +1,86 @@
+/*
+ * Copyright 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ *
+ * Modifications copyright (C) 2023 Esri Inc
+ */
+
+package com.arcgismaps.toolkit.featureforms.components.datetime.picker.time
+
+import android.annotation.SuppressLint
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.ReadOnlyComposable
+import androidx.compose.material3.R
+import androidx.compose.ui.platform.LocalConfiguration
+import androidx.compose.ui.platform.LocalContext
+import androidx.core.os.ConfigurationCompat
+import java.util.Locale
+
+@Immutable
+@JvmInline
+internal value class Strings private constructor(
+ @Suppress("unused") private val value: Int = nextId()
+) {
+ companion object {
+ private var id = 0
+ private fun nextId() = id++
+
+ val TimePickerAM = Strings()
+ val TimePickerPM = Strings()
+ val TimePickerPeriodToggle = Strings()
+ val TimePickerHourSelection = Strings()
+ val TimePickerMinuteSelection = Strings()
+ val TimePickerHourSuffix = Strings()
+ val TimePicker24HourSuffix = Strings()
+ val TimePickerMinuteSuffix = Strings()
+ val TimePickerHour = Strings()
+ val TimePickerMinute = Strings()
+ val TimePickerHourTextField = Strings()
+ val TimePickerMinuteTextField = Strings()
+ }
+}
+
+@SuppressLint("PrivateResource")
+@Composable
+@ReadOnlyComposable
+internal fun getString(string: Strings): String {
+ LocalConfiguration.current
+ val resources = LocalContext.current.resources
+ return when (string) {
+ Strings.TimePickerAM -> resources.getString(R.string.time_picker_am)
+ Strings.TimePickerPM -> resources.getString(R.string.time_picker_pm)
+ Strings.TimePickerPeriodToggle -> resources.getString(R.string.time_picker_period_toggle_description)
+ Strings.TimePickerMinuteSelection -> resources.getString(R.string.time_picker_minute_selection)
+ Strings.TimePickerHourSelection -> resources.getString(R.string.time_picker_hour_selection)
+ Strings.TimePickerHourSuffix -> resources.getString(R.string.time_picker_hour_suffix)
+ Strings.TimePickerMinuteSuffix -> resources.getString(R.string.time_picker_minute_suffix)
+ Strings.TimePicker24HourSuffix -> resources.getString(R.string.time_picker_hour_24h_suffix)
+ Strings.TimePickerHour -> resources.getString(R.string.time_picker_hour)
+ Strings.TimePickerMinute -> resources.getString(R.string.time_picker_minute)
+ Strings.TimePickerHourTextField -> resources.getString(R.string.time_picker_hour_text_field)
+ Strings.TimePickerMinuteTextField -> resources.getString(R.string.time_picker_minute_text_field)
+ else -> ""
+ }
+}
+
+@Composable
+@ReadOnlyComposable
+internal fun getString(string: Strings, vararg formatArgs: Any): String {
+ val raw = getString(string)
+ val locale =
+ ConfigurationCompat.getLocales(LocalConfiguration.current).get(0) ?: Locale.getDefault()
+ return String.format(locale, raw, *formatArgs)
+}
diff --git a/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/datetime/picker/time/TimePicker.kt b/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/datetime/picker/time/TimePicker.kt
new file mode 100644
index 000000000..20ae4c384
--- /dev/null
+++ b/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/datetime/picker/time/TimePicker.kt
@@ -0,0 +1,1682 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ *
+ * Modifications copyright (C) 2023 Esri Inc
+ */
+@file:OptIn(ExperimentalMaterial3Api::class)
+
+package com.arcgismaps.toolkit.featureforms.components.datetime.picker.time
+
+import android.text.format.DateFormat.is24HourFormat
+import androidx.compose.animation.Crossfade
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.MutatePriority
+import androidx.compose.foundation.MutatorMutex
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.focusable
+import androidx.compose.foundation.gestures.detectDragGestures
+import androidx.compose.foundation.gestures.detectTapGestures
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.selection.selectableGroup
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.CornerBasedShape
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.LocalContentColor
+import androidx.compose.material3.LocalTextStyle
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextFieldDefaults
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TimePicker
+import androidx.compose.material3.minimumInteractiveComponentSize
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.ReadOnlyComposable
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.saveable.Saver
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.structuralEqualityPolicy
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.composed
+import androidx.compose.ui.draw.drawWithContent
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.center
+import androidx.compose.ui.graphics.BlendMode
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.input.key.onKeyEvent
+import androidx.compose.ui.input.key.onPreviewKeyEvent
+import androidx.compose.ui.input.key.utf16CodePoint
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.layout.Layout
+import androidx.compose.ui.layout.LayoutModifier
+import androidx.compose.ui.layout.Measurable
+import androidx.compose.ui.layout.MeasurePolicy
+import androidx.compose.ui.layout.MeasureResult
+import androidx.compose.ui.layout.MeasureScope
+import androidx.compose.ui.layout.boundsInParent
+import androidx.compose.ui.layout.layoutId
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.layout.onSizeChanged
+import androidx.compose.ui.platform.InspectorInfo
+import androidx.compose.ui.platform.InspectorValueInfo
+import androidx.compose.ui.platform.LocalConfiguration
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.debugInspectorInfo
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.clearAndSetSemantics
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.isContainer
+import androidx.compose.ui.semantics.onClick
+import androidx.compose.ui.semantics.role
+import androidx.compose.ui.semantics.selectableGroup
+import androidx.compose.ui.semantics.selected
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.TextFieldValue
+import androidx.compose.ui.text.input.VisualTransformation
+import androidx.compose.ui.text.style.LineHeightStyle
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.Constraints
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.DpOffset
+import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.unit.center
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.zIndex
+import com.arcgismaps.toolkit.featureforms.components.datetime.picker.time.TimeInputTokens.PeriodSelectorContainerHeight
+import com.arcgismaps.toolkit.featureforms.components.datetime.picker.time.TimeInputTokens.PeriodSelectorContainerWidth
+import com.arcgismaps.toolkit.featureforms.components.datetime.picker.time.TimeInputTokens.TimeFieldContainerHeight
+import com.arcgismaps.toolkit.featureforms.components.datetime.picker.time.TimeInputTokens.TimeFieldContainerWidth
+import com.arcgismaps.toolkit.featureforms.components.datetime.picker.time.TimeInputTokens.TimeFieldSeparatorColor
+import com.arcgismaps.toolkit.featureforms.components.datetime.picker.time.TimePickerTokens.ClockDialContainerSize
+import com.arcgismaps.toolkit.featureforms.components.datetime.picker.time.TimePickerTokens.ClockDialLabelTextFont
+import com.arcgismaps.toolkit.featureforms.components.datetime.picker.time.TimePickerTokens.ClockDialSelectorCenterContainerSize
+import com.arcgismaps.toolkit.featureforms.components.datetime.picker.time.TimePickerTokens.ClockDialSelectorHandleContainerSize
+import com.arcgismaps.toolkit.featureforms.components.datetime.picker.time.TimePickerTokens.ClockDialSelectorTrackContainerWidth
+import com.arcgismaps.toolkit.featureforms.components.datetime.picker.time.TimePickerTokens.PeriodSelectorContainerShape
+import com.arcgismaps.toolkit.featureforms.components.datetime.picker.time.TimePickerTokens.PeriodSelectorHorizontalContainerHeight
+import com.arcgismaps.toolkit.featureforms.components.datetime.picker.time.TimePickerTokens.PeriodSelectorHorizontalContainerWidth
+import com.arcgismaps.toolkit.featureforms.components.datetime.picker.time.TimePickerTokens.PeriodSelectorOutlineColor
+import com.arcgismaps.toolkit.featureforms.components.datetime.picker.time.TimePickerTokens.PeriodSelectorVerticalContainerHeight
+import com.arcgismaps.toolkit.featureforms.components.datetime.picker.time.TimePickerTokens.PeriodSelectorVerticalContainerWidth
+import com.arcgismaps.toolkit.featureforms.components.datetime.picker.time.TimePickerTokens.TimeSelectorContainerHeight
+import com.arcgismaps.toolkit.featureforms.components.datetime.picker.time.TimePickerTokens.TimeSelectorContainerShape
+import com.arcgismaps.toolkit.featureforms.components.datetime.picker.time.TimePickerTokens.TimeSelectorContainerWidth
+import com.arcgismaps.toolkit.featureforms.components.datetime.picker.time.TimePickerTokens.TimeSelectorLabelTextFont
+import kotlinx.coroutines.launch
+import java.text.NumberFormat
+import kotlin.math.PI
+import kotlin.math.abs
+import kotlin.math.atan2
+import kotlin.math.cos
+import kotlin.math.hypot
+import kotlin.math.roundToInt
+import kotlin.math.sin
+
+/**
+ * Material Design time picker.
+ *
+ * Time pickers help users select and set a specific time.
+ *
+ * Shows a picker that allows the user to select time.
+ * Subscribe to updates through [TimePickerState]
+ *
+ * 
+ *
+ * @sample androidx.compose.material3.samples.TimePickerSample
+ * @sample androidx.compose.material3.samples.TimePickerSwitchableSample
+ *
+ * [state] state for this timepicker, allows to subscribe to changes to [TimePickerState.hour] and
+ * [TimePickerState.minute], and set the initial time for this picker.
+ *
+ * @param state state for this time input, allows to subscribe to changes to [TimePickerState.hour]
+ * and [TimePickerState.minute], and set the initial time for this input.
+ * @param modifier the [Modifier] to be applied to this time input
+ * @param colors colors [TimePickerColors] that will be used to resolve the colors used for this
+ * time picker in different states. See [TimePickerDefaults.colors].
+ * @param layoutType, the different [TimePickerLayoutType] supported by this time picker,
+ * it will change the position and sizing of different components of the timepicker.
+ */
+@Composable
+@ExperimentalMaterial3Api
+internal fun TimePicker(
+ state: TimePickerState,
+ modifier: Modifier = Modifier,
+ colors: TimePickerColors = TimePickerDefaults.colors(),
+ layoutType: TimePickerLayoutType = TimePickerDefaults.layoutType(),
+) {
+ val touchExplorationServicesEnabled by touchExplorationState()
+
+ if (layoutType == TimePickerLayoutType.Vertical) {
+ VerticalTimePicker(
+ state = state,
+ modifier = modifier,
+ colors = colors,
+ autoSwitchToMinute = !touchExplorationServicesEnabled
+ )
+ } else {
+ HorizontalTimePicker(
+ state = state,
+ modifier = modifier,
+ colors = colors,
+ autoSwitchToMinute = !touchExplorationServicesEnabled
+ )
+ }
+}
+
+/**
+ * Time pickers help users select and set a specific time.
+ *
+ * Shows a time input that allows the user to enter the time via
+ * two text fields, one for minutes and one for hours
+ * Subscribe to updates through [TimePickerState]
+ *
+ * @sample androidx.compose.material3.samples.TimeInputSample
+ *
+ * @param state state for this timepicker, allows to subscribe to changes to [TimePickerState.hour]
+ * and [TimePickerState.minute], and set the initial time for this picker.
+ * @param modifier the [Modifier] to be applied to this time input
+ * @param colors colors [TimePickerColors] that will be used to resolve the colors used for this
+ * time input in different states. See [TimePickerDefaults.colors].
+ */
+@Composable
+@ExperimentalMaterial3Api
+internal fun TimeInput(
+ state: TimePickerState,
+ modifier: Modifier = Modifier,
+ colors: TimePickerColors = TimePickerDefaults.colors(),
+) {
+ TimeInputImpl(modifier, colors, state)
+}
+
+/**
+ * Contains the default values used by [TimePicker]
+ */
+@ExperimentalMaterial3Api
+@Stable
+internal object TimePickerDefaults {
+
+ /**
+ * Default colors used by a [TimePicker] in different states
+ *
+ * @param clockDialColor The color of the clock dial.
+ * @param clockDialSelectedContentColor the color of the numbers of the clock dial when they
+ * are selected or overlapping with the selector
+ * @param clockDialUnselectedContentColor the color of the numbers of the clock dial when they
+ * are unselected
+ * @param selectorColor The color of the clock dial selector.
+ * @param containerColor The container color of the time picker.
+ * @param periodSelectorBorderColor the color used for the border of the AM/PM toggle.
+ * @param periodSelectorSelectedContainerColor the color used for the selected container of
+ * the AM/PM toggle
+ * @param periodSelectorUnselectedContainerColor the color used for the unselected container
+ * of the AM/PM toggle
+ * @param periodSelectorSelectedContentColor color used for the selected content of
+ * the AM/PM toggle
+ * @param periodSelectorUnselectedContentColor color used for the unselected content
+ * of the AM/PM toggle
+ * @param timeSelectorSelectedContainerColor color used for the selected container of the
+ * display buttons to switch between hour and minutes
+ * @param timeSelectorUnselectedContainerColor color used for the unselected container of the
+ * display buttons to switch between hour and minutes
+ * @param timeSelectorSelectedContentColor color used for the selected content of the display
+ * buttons to switch between hour and minutes
+ * @param timeSelectorUnselectedContentColor color used for the unselected content of the
+ * display buttons to switch between hour and minutes
+ */
+ @Composable
+ fun colors(
+ clockDialColor: Color = MaterialTheme.colorScheme.surfaceVariant,
+ clockDialSelectedContentColor: Color = MaterialTheme.colorScheme.onPrimary,
+ clockDialUnselectedContentColor: Color = MaterialTheme.colorScheme.onSurface,
+ selectorColor: Color = MaterialTheme.colorScheme.primary,
+ containerColor: Color = MaterialTheme.colorScheme.surface,
+ periodSelectorBorderColor: Color = MaterialTheme.colorScheme.outline,
+ periodSelectorSelectedContainerColor: Color =
+ MaterialTheme.colorScheme.tertiaryContainer,
+ periodSelectorUnselectedContainerColor: Color = Color.Transparent,
+ periodSelectorSelectedContentColor: Color =
+ MaterialTheme.colorScheme.onTertiaryContainer,
+ periodSelectorUnselectedContentColor: Color =
+ MaterialTheme.colorScheme.onSurfaceVariant,
+ timeSelectorSelectedContainerColor: Color =
+ MaterialTheme.colorScheme.primaryContainer,
+ timeSelectorUnselectedContainerColor: Color =
+ MaterialTheme.colorScheme.surfaceVariant,
+ timeSelectorSelectedContentColor: Color =
+ MaterialTheme.colorScheme.onPrimaryContainer,
+ timeSelectorUnselectedContentColor: Color =
+ MaterialTheme.colorScheme.onSurface,
+ ) = TimePickerColors(
+ clockDialColor = clockDialColor,
+ clockDialSelectedContentColor = clockDialSelectedContentColor,
+ clockDialUnselectedContentColor = clockDialUnselectedContentColor,
+ selectorColor = selectorColor,
+ containerColor = containerColor,
+ periodSelectorBorderColor = periodSelectorBorderColor,
+ periodSelectorSelectedContainerColor = periodSelectorSelectedContainerColor,
+ periodSelectorUnselectedContainerColor = periodSelectorUnselectedContainerColor,
+ periodSelectorSelectedContentColor = periodSelectorSelectedContentColor,
+ periodSelectorUnselectedContentColor = periodSelectorUnselectedContentColor,
+ timeSelectorSelectedContainerColor = timeSelectorSelectedContainerColor,
+ timeSelectorUnselectedContainerColor = timeSelectorUnselectedContainerColor,
+ timeSelectorSelectedContentColor = timeSelectorSelectedContentColor,
+ timeSelectorUnselectedContentColor = timeSelectorUnselectedContentColor
+ )
+
+ /** Default layout type, uses the screen dimensions to choose an appropriate layout. */
+ @ReadOnlyComposable
+ @Composable
+ fun layoutType(): TimePickerLayoutType = defaultTimePickerLayoutType
+}
+
+/**
+ * Represents the colors used by a [TimePicker] in different states
+ *
+ * See [TimePickerDefaults.colors] for the default implementation that follows Material
+ * specifications.
+ */
+@Immutable
+@ExperimentalMaterial3Api
+internal class TimePickerColors internal constructor(
+ internal val clockDialColor: Color,
+ internal val selectorColor: Color,
+ internal val containerColor: Color,
+ internal val periodSelectorBorderColor: Color,
+ private val clockDialSelectedContentColor: Color,
+ private val clockDialUnselectedContentColor: Color,
+ private val periodSelectorSelectedContainerColor: Color,
+ private val periodSelectorUnselectedContainerColor: Color,
+ private val periodSelectorSelectedContentColor: Color,
+ private val periodSelectorUnselectedContentColor: Color,
+ private val timeSelectorSelectedContainerColor: Color,
+ private val timeSelectorUnselectedContainerColor: Color,
+ private val timeSelectorSelectedContentColor: Color,
+ private val timeSelectorUnselectedContentColor: Color,
+) {
+ internal fun periodSelectorContainerColor(selected: Boolean) =
+ if (selected) {
+ periodSelectorSelectedContainerColor
+ } else {
+ periodSelectorUnselectedContainerColor
+ }
+
+ internal fun periodSelectorContentColor(selected: Boolean) =
+ if (selected) {
+ periodSelectorSelectedContentColor
+ } else {
+ periodSelectorUnselectedContentColor
+ }
+
+ internal fun timeSelectorContainerColor(selected: Boolean) =
+ if (selected) {
+ timeSelectorSelectedContainerColor
+ } else {
+ timeSelectorUnselectedContainerColor
+ }
+
+ internal fun timeSelectorContentColor(selected: Boolean) =
+ if (selected) {
+ timeSelectorSelectedContentColor
+ } else {
+ timeSelectorUnselectedContentColor
+ }
+
+ internal fun clockDialContentColor(selected: Boolean) =
+ if (selected) {
+ clockDialSelectedContentColor
+ } else {
+ clockDialUnselectedContentColor
+ }
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (javaClass != other?.javaClass) return false
+
+ other as TimePickerColors
+
+ if (clockDialColor != other.clockDialColor) return false
+ if (selectorColor != other.selectorColor) return false
+ if (containerColor != other.containerColor) return false
+ if (periodSelectorBorderColor != other.periodSelectorBorderColor) return false
+ if (periodSelectorSelectedContainerColor != other.periodSelectorSelectedContainerColor)
+ return false
+ if (periodSelectorUnselectedContainerColor != other.periodSelectorUnselectedContainerColor)
+ return false
+ if (periodSelectorSelectedContentColor != other.periodSelectorSelectedContentColor)
+ return false
+ if (periodSelectorUnselectedContentColor != other.periodSelectorUnselectedContentColor)
+ return false
+ if (timeSelectorSelectedContainerColor != other.timeSelectorSelectedContainerColor)
+ return false
+ if (timeSelectorUnselectedContainerColor != other.timeSelectorUnselectedContainerColor)
+ return false
+ if (timeSelectorSelectedContentColor != other.timeSelectorSelectedContentColor)
+ return false
+ if (timeSelectorUnselectedContentColor != other.timeSelectorUnselectedContentColor)
+ return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = clockDialColor.hashCode()
+ result = 31 * result + selectorColor.hashCode()
+ result = 31 * result + containerColor.hashCode()
+ result = 31 * result + periodSelectorBorderColor.hashCode()
+ result = 31 * result + periodSelectorSelectedContainerColor.hashCode()
+ result = 31 * result + periodSelectorUnselectedContainerColor.hashCode()
+ result = 31 * result + periodSelectorSelectedContentColor.hashCode()
+ result = 31 * result + periodSelectorUnselectedContentColor.hashCode()
+ result = 31 * result + timeSelectorSelectedContainerColor.hashCode()
+ result = 31 * result + timeSelectorUnselectedContainerColor.hashCode()
+ result = 31 * result + timeSelectorSelectedContentColor.hashCode()
+ result = 31 * result + timeSelectorUnselectedContentColor.hashCode()
+ return result
+ }
+}
+
+/**
+ * Creates a [TimePickerState] for a time picker that is remembered across compositions
+ * and configuration changes.
+ *
+ * @param initialHour starting hour for this state, will be displayed in the time picker when launched
+ * Ranges from 0 to 23
+ * @param initialMinute starting minute for this state, will be displayed in the time picker when
+ * launched. Ranges from 0 to 59
+ * @param is24Hour The format for this time picker. `false` for 12 hour format with an AM/PM toggle
+ * or `true` for 24 hour format without toggle. Defaults to follow system setting.
+ */
+@Composable
+@ExperimentalMaterial3Api
+internal fun rememberTimePickerState(
+ initialHour: Int = 0,
+ initialMinute: Int = 0,
+ is24Hour: Boolean = is24HourFormat(LocalContext.current),
+): TimePickerState = rememberSaveable(
+ saver = TimePickerState.Saver()
+) {
+ TimePickerState(
+ initialHour = initialHour,
+ initialMinute = initialMinute,
+ is24Hour = is24Hour,
+ )
+}
+
+/**
+ * Represents the different configurations for the layout of the Time Picker
+ */
+@Immutable
+@JvmInline
+@ExperimentalMaterial3Api
+internal value class TimePickerLayoutType internal constructor(internal val value: Int) {
+
+ companion object {
+ /** Displays the Time picker with a horizontal layout. Should be used in landscape mode. */
+ val Horizontal = TimePickerLayoutType(0)
+
+ /** Displays the Time picker with a vertical layout. Should be used in portrait mode.*/
+ val Vertical = TimePickerLayoutType(1)
+ }
+
+ override fun toString() = when (this) {
+ Horizontal -> "Horizontal"
+ Vertical -> "Vertical"
+ else -> "Unknown"
+ }
+}
+
+/**
+ * A class to handle state changes in a [TimePicker]
+ *
+ * @sample androidx.compose.material3.samples.TimePickerSample
+ *
+ * @param initialHour
+ * starting hour for this state, will be displayed in the time picker when launched
+ * Ranges from 0 to 23
+ * @param initialMinute
+ * starting minute for this state, will be displayed in the time picker when launched.
+ * Ranges from 0 to 59
+ * @param is24Hour The format for this time picker `false` for 12 hour format with an AM/PM toggle
+ * or `true` for 24 hour format without toggle.
+ */
+@Stable
+@ExperimentalMaterial3Api
+internal class TimePickerState(
+ initialHour: Int,
+ initialMinute: Int,
+ is24Hour: Boolean,
+) {
+ init {
+ require(initialHour in 0..23) { "initialHour should in [0..23] range" }
+ require(initialHour in 0..59) { "initialMinute should be in [0..59] range" }
+ }
+
+ val minute: Int get() = minuteAngle.toMinute()
+ val hour: Int get() = hourAngle.toHour() + if (isAfternoon) 12 else 0
+ val is24hour: Boolean = is24Hour
+
+ internal val hourForDisplay: Int get() = hourForDisplay(hour)
+ internal val selectorPos by derivedStateOf(structuralEqualityPolicy()) {
+ val inInnerCircle = isInnerCircle
+ val handleRadiusPx = ClockDialSelectorHandleContainerSize / 2
+ val selectorLength = if (is24Hour && inInnerCircle && selection == Selection.Hour) {
+ InnerCircleRadius
+ } else {
+ OuterCircleSizeRadius
+ }.minus(handleRadiusPx)
+
+ val length = selectorLength + handleRadiusPx
+ val offsetX = length * cos(currentAngle.value) + ClockDialContainerSize / 2
+ val offsetY = length * sin(currentAngle.value) + ClockDialContainerSize / 2
+
+ DpOffset(offsetX, offsetY)
+ }
+
+ internal var center by mutableStateOf(IntOffset.Zero)
+ internal val values get() = if (selection == Selection.Minute) Minutes else Hours
+
+ internal var selection by mutableStateOf(Selection.Hour)
+ // fix : its afternoon if hour is greater than 11
+ internal var isAfternoonToggle by mutableStateOf(initialHour > 11 && !is24Hour)
+ internal var isInnerCircle by mutableStateOf(initialHour >= 13)
+ // fix : (initialHour % 12) braces missing
+ internal var hourAngle by mutableStateOf(RadiansPerHour * (initialHour % 12) - FullCircle / 4)
+ internal var minuteAngle by mutableStateOf(RadiansPerMinute * initialMinute - FullCircle / 4)
+
+ private val mutex = MutatorMutex()
+ private val isAfternoon by derivedStateOf { is24hour && isInnerCircle || isAfternoonToggle }
+
+ internal val currentAngle = Animatable(hourAngle)
+
+ internal fun setMinute(minute: Int) {
+ minuteAngle = RadiansPerMinute * minute - FullCircle / 4
+ }
+
+ internal fun setHour(hour: Int) {
+ isInnerCircle = hour > 12 || hour == 0
+ hourAngle = RadiansPerHour * hour % 12 - FullCircle / 4
+ }
+
+ internal fun moveSelector(x: Float, y: Float, maxDist: Float) {
+ if (selection == Selection.Hour && is24hour) {
+ isInnerCircle = dist(x, y, center.x, center.y) < maxDist
+ }
+ }
+
+ internal fun isSelected(value: Int): Boolean =
+ if (selection == Selection.Minute) {
+ value == minute
+ } else {
+ hour == (value + if (isAfternoon) 12 else 0)
+ }
+
+ internal suspend fun update(value: Float, fromTap: Boolean = false) {
+ mutex.mutate(MutatePriority.UserInput) {
+ if (selection == Selection.Hour) {
+ hourAngle = value.toHour() % 12 * RadiansPerHour
+ } else if (fromTap) {
+ minuteAngle = (value.toMinute() - value.toMinute() % 5) * RadiansPerMinute
+ } else {
+ minuteAngle = value.toMinute() * RadiansPerMinute
+ }
+
+ if (fromTap) {
+ currentAngle.snapTo(minuteAngle)
+ } else {
+ currentAngle.snapTo(offsetHour(value))
+ }
+ }
+ }
+
+ internal suspend fun animateToCurrent() {
+ val (start, end) = if (selection == Selection.Hour) {
+ valuesForAnimation(minuteAngle, hourAngle)
+ } else {
+ valuesForAnimation(hourAngle, minuteAngle)
+ }
+
+ currentAngle.snapTo(start)
+ currentAngle.animateTo(end, tween(200))
+ }
+
+ private fun hourForDisplay(hour: Int): Int = when {
+ is24hour && isInnerCircle && hour == 0 -> 12
+ is24hour -> hour % 24
+ hour % 12 == 0 -> 12
+ isAfternoon -> hour - 12
+ else -> hour
+ }
+
+ private fun offsetHour(angle: Float): Float {
+ val ret = angle + QuarterCircle.toFloat()
+ return if (ret < 0) ret + FullCircle else ret
+ }
+
+ private fun Float.toHour(): Int {
+ val hourOffset: Float = RadiansPerHour / 2
+ val totalOffset = hourOffset + QuarterCircle
+ return ((this + totalOffset) / RadiansPerHour).toInt() % 12
+ }
+
+ private fun Float.toMinute(): Int {
+ val hourOffset: Float = RadiansPerMinute / 2
+ val totalOffset = hourOffset + QuarterCircle
+ return ((this + totalOffset) / RadiansPerMinute).toInt() % 60
+ }
+
+ suspend fun settle() {
+ val targetValue = valuesForAnimation(currentAngle.value, minuteAngle)
+ currentAngle.snapTo(targetValue.first)
+ currentAngle.animateTo(targetValue.second, tween(200))
+ }
+
+ internal suspend fun onTap(x: Float, y: Float, maxDist: Float, autoSwitchToMinute: Boolean) {
+ update(atan(y - center.y, x - center.x), true)
+ moveSelector(x, y, maxDist)
+
+ if (selection == Selection.Hour) {
+ if (autoSwitchToMinute) {
+ selection = Selection.Minute
+ } else {
+ val targetValue = valuesForAnimation(currentAngle.value, hourAngle)
+ currentAngle.snapTo(targetValue.first)
+ currentAngle.animateTo(targetValue.second, tween(200))
+ }
+ } else {
+ settle()
+ }
+ }
+
+ companion object {
+ /**
+ * The default [Saver] implementation for [TimePickerState].
+ */
+ fun Saver(): Saver = Saver(
+ save = {
+ listOf(
+ it.hour,
+ it.minute,
+ it.is24hour
+ )
+ },
+ restore = { value ->
+ TimePickerState(
+ initialHour = value[0] as Int,
+ initialMinute = value[1] as Int,
+ is24Hour = value[2] as Boolean
+ )
+ }
+ )
+ }
+}
+
+@Composable
+@ExperimentalMaterial3Api
+internal fun VerticalTimePicker(
+ state: TimePickerState,
+ modifier: Modifier = Modifier,
+ colors: TimePickerColors = TimePickerDefaults.colors(),
+ autoSwitchToMinute: Boolean
+) {
+ Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) {
+ VerticalClockDisplay(state, colors)
+ Spacer(modifier = Modifier.height(ClockDisplayBottomMargin))
+ ClockFace(state, colors, autoSwitchToMinute)
+ Spacer(modifier = Modifier.height(ClockFaceBottomMargin))
+ }
+}
+
+@Composable
+internal fun HorizontalTimePicker(
+ state: TimePickerState,
+ modifier: Modifier = Modifier,
+ colors: TimePickerColors = TimePickerDefaults.colors(),
+ autoSwitchToMinute: Boolean
+) {
+ Row(
+ modifier = modifier.padding(bottom = ClockFaceBottomMargin),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ HorizontalClockDisplay(state, colors)
+ Spacer(modifier = Modifier.width(ClockDisplayBottomMargin))
+ ClockFace(state, colors, autoSwitchToMinute)
+ }
+}
+
+@Composable
+private fun TimeInputImpl(
+ modifier: Modifier,
+ colors: TimePickerColors,
+ state: TimePickerState,
+) {
+ var hourValue by rememberSaveable(stateSaver = TextFieldValue.Saver) {
+ mutableStateOf(TextFieldValue(text = state.hourForDisplay.toLocalString(2)))
+ }
+ var minuteValue by rememberSaveable(stateSaver = TextFieldValue.Saver) {
+ mutableStateOf(TextFieldValue(text = state.minute.toLocalString(2)))
+ }
+
+ Row(
+ modifier = modifier.padding(bottom = TimeInputBottomPadding),
+ verticalAlignment = Alignment.Top
+ ) {
+ val textStyle = MaterialTheme.typography.fromToken(TimeInputTokens.TimeFieldLabelTextFont)
+ .copy(
+ textAlign = TextAlign.Center,
+ color = colors.timeSelectorContentColor(true)
+ )
+
+ CompositionLocalProvider(LocalTextStyle provides textStyle) {
+
+ TimePickerTextField(
+ modifier = Modifier
+ .onKeyEvent { event ->
+ // Zero == 48, Nine == 57
+ val switchFocus = event.utf16CodePoint in 48..57 &&
+ hourValue.selection.start == 2 && hourValue.text.length == 2
+
+ if (switchFocus) {
+ state.selection = Selection.Minute
+ }
+
+ false
+ },
+ value = hourValue,
+ onValueChange = { newValue ->
+ timeInputOnChange(
+ selection = Selection.Hour,
+ state = state,
+ value = newValue,
+ prevValue = hourValue,
+ max = if (state.is24hour) 23 else 12,
+ ) { hourValue = it }
+ },
+ state = state,
+ selection = Selection.Hour,
+ keyboardOptions = KeyboardOptions(
+ imeAction = ImeAction.Next,
+ keyboardType = KeyboardType.Number
+ ),
+ keyboardActions = KeyboardActions(onNext = { state.selection = Selection.Minute }),
+ colors = colors,
+ )
+ DisplaySeparator(Modifier.size(DisplaySeparatorWidth, PeriodSelectorContainerHeight))
+ TimePickerTextField(
+ modifier = Modifier
+ .onPreviewKeyEvent { event ->
+ // 0 == KEYCODE_DEL
+ val switchFocus = event.utf16CodePoint == 0 &&
+ minuteValue.selection.start == 0
+
+ if (switchFocus) {
+ state.selection = Selection.Hour
+ }
+
+ switchFocus
+ },
+
+ value = minuteValue,
+ onValueChange = { newValue ->
+ timeInputOnChange(
+ selection = Selection.Minute,
+ state = state,
+ value = newValue,
+ prevValue = minuteValue,
+ max = 59,
+ ) { minuteValue = it }
+ },
+ state = state,
+ selection = Selection.Minute,
+ keyboardOptions = KeyboardOptions(
+ imeAction = ImeAction.Done,
+ keyboardType = KeyboardType.Number
+ ),
+ keyboardActions = KeyboardActions(onNext = { state.selection = Selection.Minute }),
+ colors = colors,
+ )
+ }
+
+ if (!state.is24hour) {
+ Box(modifier.padding(start = PeriodToggleMargin)) {
+ VerticalPeriodToggle(
+ modifier = Modifier.size(
+ PeriodSelectorContainerWidth,
+ PeriodSelectorContainerHeight
+ ),
+ state = state,
+ colors = colors,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun HorizontalClockDisplay(state: TimePickerState, colors: TimePickerColors) {
+ Column(verticalArrangement = Arrangement.Center) {
+ ClockDisplayNumbers(state, colors)
+ if (!state.is24hour) {
+ Box(modifier = Modifier.padding(top = PeriodToggleMargin)) {
+ HorizontalPeriodToggle(
+ modifier = Modifier.size(
+ PeriodSelectorHorizontalContainerWidth,
+ PeriodSelectorHorizontalContainerHeight
+ ),
+ state = state,
+ colors = colors,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun VerticalClockDisplay(state: TimePickerState, colors: TimePickerColors) {
+ Row(horizontalArrangement = Arrangement.Center) {
+ ClockDisplayNumbers(state, colors)
+ if (!state.is24hour) {
+ Box(modifier = Modifier.padding(start = PeriodToggleMargin)) {
+ VerticalPeriodToggle(
+ modifier = Modifier.size(
+ PeriodSelectorVerticalContainerWidth,
+ PeriodSelectorVerticalContainerHeight
+ ),
+ state = state,
+ colors = colors,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun ClockDisplayNumbers(
+ state: TimePickerState,
+ colors: TimePickerColors
+) {
+ CompositionLocalProvider(
+ LocalTextStyle provides MaterialTheme.typography.fromToken(TimeSelectorLabelTextFont)
+ ) {
+ Row {
+ TimeSelector(
+ modifier = Modifier.size(
+ TimeSelectorContainerWidth,
+ TimeSelectorContainerHeight
+ ),
+ value = state.hourForDisplay,
+ state = state,
+ selection = Selection.Hour,
+ colors = colors,
+ )
+ DisplaySeparator(
+ Modifier.size(
+ DisplaySeparatorWidth,
+ PeriodSelectorVerticalContainerHeight
+ )
+ )
+ TimeSelector(
+ modifier = Modifier.size(
+ TimeSelectorContainerWidth,
+ TimeSelectorContainerHeight
+ ),
+ value = state.minute,
+ state = state,
+ selection = Selection.Minute,
+ colors = colors,
+ )
+ }
+ }
+}
+
+@Composable
+private fun HorizontalPeriodToggle(
+ modifier: Modifier,
+ state: TimePickerState,
+ colors: TimePickerColors,
+) {
+ val measurePolicy = remember {
+ MeasurePolicy { measurables, constraints ->
+ val spacer = measurables.first { it.layoutId == "Spacer" }
+ val spacerPlaceable = spacer.measure(
+ constraints.copy(
+ minWidth = 0,
+ maxWidth = TimePickerTokens.PeriodSelectorOutlineWidth.toPx().roundToInt(),
+ )
+ )
+
+ val items = measurables.filter { it.layoutId != "Spacer" }.map { item ->
+ item.measure(constraints.copy(
+ minWidth = 0,
+ maxWidth = constraints.maxWidth / 2
+ ))
+ }
+
+ layout(constraints.maxWidth, constraints.maxHeight) {
+ items[0].place(0, 0)
+ items[1].place(items[0].width, 0)
+ spacerPlaceable.place(items[0].width - spacerPlaceable.width / 2, 0)
+ }
+ }
+ }
+
+ val shape = PeriodSelectorContainerShape.toShape() as CornerBasedShape
+
+ PeriodToggleImpl(
+ modifier = modifier,
+ state = state,
+ colors = colors,
+ measurePolicy = measurePolicy,
+ startShape = shape.start(),
+ endShape = shape.end()
+ )
+}
+
+@Composable
+private fun VerticalPeriodToggle(
+ modifier: Modifier,
+ state: TimePickerState,
+ colors: TimePickerColors,
+) {
+ val measurePolicy = remember {
+ MeasurePolicy { measurables, constraints ->
+ val spacer = measurables.first { it.layoutId == "Spacer" }
+ val spacerPlaceable = spacer.measure(
+ constraints.copy(
+ minHeight = 0,
+ maxHeight = TimePickerTokens.PeriodSelectorOutlineWidth.toPx().roundToInt()
+ )
+ )
+
+ val items = measurables.filter { it.layoutId != "Spacer" }.map { item ->
+ item.measure(constraints.copy(
+ minHeight = 0,
+ maxHeight = constraints.maxHeight / 2
+ ))
+ }
+
+ layout(constraints.maxWidth, constraints.maxHeight) {
+ items[0].place(0, 0)
+ items[1].place(0, items[0].height)
+ spacerPlaceable.place(0, items[0].height - spacerPlaceable.height / 2)
+ }
+ }
+ }
+
+ val shape = PeriodSelectorContainerShape.toShape() as CornerBasedShape
+
+ PeriodToggleImpl(
+ modifier = modifier,
+ state = state,
+ colors = colors,
+ measurePolicy = measurePolicy,
+ startShape = shape.top(),
+ endShape = shape.bottom()
+ )
+}
+
+@Composable
+private fun PeriodToggleImpl(
+ modifier: Modifier,
+ state: TimePickerState,
+ colors: TimePickerColors,
+ measurePolicy: MeasurePolicy,
+ startShape: Shape,
+ endShape: Shape,
+) {
+ val borderStroke = BorderStroke(
+ TimePickerTokens.PeriodSelectorOutlineWidth,
+ colors.periodSelectorBorderColor
+ )
+
+ val shape = PeriodSelectorContainerShape.toShape() as CornerBasedShape
+ val contentDescription = getString(Strings.TimePickerPeriodToggle)
+ Layout(
+ modifier = modifier
+ .semantics {
+ isContainer = true
+ this.contentDescription = contentDescription
+ }
+ .selectableGroup()
+ .then(modifier)
+ .border(border = borderStroke, shape = shape),
+ measurePolicy = measurePolicy,
+ content = {
+ ToggleItem(
+ checked = !state.isAfternoonToggle,
+ shape = startShape,
+ onClick = {
+ state.isAfternoonToggle = false
+ },
+ colors = colors,
+ ) { Text(text = getString(string = Strings.TimePickerAM)) }
+ Spacer(
+ Modifier
+ .layoutId("Spacer")
+ .zIndex(SeparatorZIndex)
+ .fillMaxSize()
+ .background(color = PeriodSelectorOutlineColor.toColor())
+ )
+ ToggleItem(
+ checked =
+ state.isAfternoonToggle,
+ shape = endShape,
+ onClick = {
+ state.isAfternoonToggle = true
+ },
+ colors = colors,
+ ) { Text(getString(string = Strings.TimePickerPM)) }
+ }
+ )
+}
+
+@Composable
+private fun ToggleItem(
+ checked: Boolean,
+ shape: Shape,
+ onClick: () -> Unit,
+ colors: TimePickerColors,
+ content: @Composable RowScope.() -> Unit,
+) {
+ val contentColor = colors.periodSelectorContentColor(checked)
+ val containerColor = colors.periodSelectorContainerColor(checked)
+
+ TextButton(
+ modifier = Modifier
+ .zIndex(if (checked) 0f else 1f)
+ .fillMaxSize()
+ .semantics { selected = checked },
+ contentPadding = PaddingValues(0.dp),
+ shape = shape,
+ onClick = onClick,
+ content = content,
+ colors = ButtonDefaults.textButtonColors(
+ contentColor = contentColor,
+ containerColor = containerColor
+ )
+ )
+}
+
+@Composable
+private fun DisplaySeparator(modifier: Modifier) {
+ val style = copyAndSetFontPadding(
+ style = LocalTextStyle.current.copy(
+ textAlign = TextAlign.Center,
+ lineHeightStyle = LineHeightStyle(
+ alignment = LineHeightStyle.Alignment.Center, trim = LineHeightStyle.Trim.Both
+ )
+ ), includeFontPadding = false
+ )
+
+ Box(
+ modifier = modifier.clearAndSetSemantics { },
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = ":",
+ color = TimeFieldSeparatorColor.toColor(),
+ style = style
+ )
+ }
+}
+
+@Composable
+@OptIn(ExperimentalMaterial3Api::class)
+private fun TimeSelector(
+ modifier: Modifier,
+ value: Int,
+ state: TimePickerState,
+ selection: Selection,
+ colors: TimePickerColors,
+) {
+ val selected = state.selection == selection
+ val selectorContentDescription = getString(
+ if (selection == Selection.Hour) {
+ Strings.TimePickerHourSelection
+ } else {
+ Strings.TimePickerMinuteSelection
+ }
+ )
+
+ val containerColor = colors.timeSelectorContainerColor(selected)
+ val contentColor = colors.timeSelectorContentColor(selected)
+ val scope = rememberCoroutineScope()
+ Surface(
+ modifier = modifier
+ .semantics(mergeDescendants = true) {
+ role = Role.RadioButton
+ this.contentDescription = selectorContentDescription
+ },
+ onClick = {
+ if (selection != state.selection) {
+ state.selection = selection
+ scope.launch {
+ state.animateToCurrent()
+ }
+ }
+ },
+ selected = selected,
+ shape = TimeSelectorContainerShape.toShape(),
+ color = containerColor,
+ ) {
+ val valueContentDescription =
+ numberContentDescription(
+ selection = selection,
+ is24Hour = state.is24hour,
+ number = value
+ )
+
+ Box(contentAlignment = Alignment.Center) {
+ Text(
+ modifier = Modifier.semantics { contentDescription = valueContentDescription },
+ text = value.toLocalString(minDigits = 2),
+ color = contentColor,
+ )
+ }
+ }
+}
+
+@Composable
+internal fun ClockFace(
+ state: TimePickerState,
+ colors: TimePickerColors,
+ autoSwitchToMinute: Boolean
+) {
+ Crossfade(
+ modifier = Modifier
+ .background(shape = CircleShape, color = colors.clockDialColor)
+ .size(ClockDialContainerSize)
+ .semantics {
+ isContainer = false
+ selectableGroup()
+ },
+ targetState = state.values,
+ animationSpec = tween(durationMillis = 350)
+ ) { screen ->
+ CircularLayout(
+ modifier = Modifier
+ .clockDial(state, autoSwitchToMinute)
+ .size(ClockDialContainerSize)
+ .drawSelector(state, colors),
+ radius = OuterCircleSizeRadius,
+ ) {
+ CompositionLocalProvider(
+ LocalContentColor provides colors.clockDialContentColor(false)
+ ) {
+ repeat(screen.size) {
+ val outerValue = if (!state.is24hour || state.selection == Selection.Minute) {
+ screen[it]
+ } else {
+ screen[it] % 12
+ }
+ ClockText(state = state, value = outerValue, autoSwitchToMinute)
+ }
+
+ if (state.selection == Selection.Hour && state.is24hour) {
+ CircularLayout(
+ modifier = Modifier
+ .layoutId(LayoutId.InnerCircle)
+ .size(ClockDialContainerSize)
+ .background(shape = CircleShape, color = Color.Transparent),
+ radius = InnerCircleRadius
+ ) {
+ repeat(ExtraHours.size) {
+ val innerValue = ExtraHours[it]
+ ClockText(state = state, value = innerValue, autoSwitchToMinute)
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+private fun Modifier.drawSelector(
+ state: TimePickerState,
+ colors: TimePickerColors,
+): Modifier = this.drawWithContent {
+ val selectorOffsetPx = Offset(state.selectorPos.x.toPx(), state.selectorPos.y.toPx())
+
+ val selectorRadius = ClockDialSelectorHandleContainerSize.toPx() / 2
+ val selectorColor = colors.selectorColor
+
+ // clear out the selector section
+ drawCircle(
+ radius = selectorRadius,
+ center = selectorOffsetPx,
+ color = Color.Black,
+ blendMode = BlendMode.Clear,
+ )
+
+ // draw the text composables
+ drawContent()
+
+ // draw the selector and clear out the numbers overlapping
+ drawCircle(
+ radius = selectorRadius,
+ center = selectorOffsetPx,
+ color = selectorColor,
+ blendMode = BlendMode.Xor
+ )
+
+ val strokeWidth = ClockDialSelectorTrackContainerWidth.toPx()
+ val lineLength = selectorOffsetPx.minus(
+ Offset(
+ (selectorRadius * cos(state.currentAngle.value)),
+ (selectorRadius * sin(state.currentAngle.value))
+ )
+ )
+
+ // draw the selector line
+ drawLine(
+ start = size.center,
+ strokeWidth = strokeWidth,
+ end = lineLength,
+ color = selectorColor,
+ blendMode = BlendMode.SrcOver
+ )
+
+ // draw the selector small dot
+ drawCircle(
+ radius = ClockDialSelectorCenterContainerSize.toPx() / 2,
+ center = size.center,
+ color = selectorColor,
+ )
+
+ // draw the portion of the number that was overlapping
+ drawCircle(
+ radius = selectorRadius,
+ center = selectorOffsetPx,
+ color = colors.clockDialContentColor(selected = true),
+ blendMode = BlendMode.DstOver
+ )
+}
+
+private fun Modifier.clockDial(state: TimePickerState, autoSwitchToMinute: Boolean): Modifier =
+ composed(debugInspectorInfo {
+ name = "clockDial"
+ properties["state"] = state
+ }) {
+ var offsetX by remember { mutableStateOf(0f) }
+ var offsetY by remember { mutableStateOf(0f) }
+ val center by remember { mutableStateOf(IntOffset.Zero) }
+ val scope = rememberCoroutineScope()
+ val maxDist = with(LocalDensity.current) { MaxDistance.toPx() }
+
+ Modifier
+ .onSizeChanged { state.center = it.center }
+ .pointerInput(state, center, maxDist) {
+ detectTapGestures(
+ onPress = {
+ offsetX = it.x
+ offsetY = it.y
+ },
+ onTap = {
+ scope.launch { state.onTap(it.x, it.y, maxDist, autoSwitchToMinute) }
+ },
+ )
+ }
+ .pointerInput(state, center, maxDist) {
+ detectDragGestures(onDragEnd = {
+ scope.launch {
+ if (state.selection == Selection.Hour && autoSwitchToMinute) {
+ state.selection = Selection.Minute
+ state.animateToCurrent()
+ } else if (state.selection == Selection.Minute) {
+ state.settle()
+ }
+ }
+ }) { _, dragAmount ->
+ scope.launch {
+ offsetX += dragAmount.x
+ offsetY += dragAmount.y
+ state.update(atan(offsetY - state.center.y, offsetX - state.center.x))
+ }
+ state.moveSelector(offsetX, offsetY, maxDist)
+ }
+ }
+ }
+
+@Composable
+private fun ClockText(state: TimePickerState, value: Int, autoSwitchToMinute: Boolean) {
+ val style = MaterialTheme.typography.fromToken(ClockDialLabelTextFont).let {
+ copyAndSetFontPadding(style = it, false)
+ }
+
+ val maxDist = with(LocalDensity.current) { MaxDistance.toPx() }
+ var center by remember { mutableStateOf(Offset.Zero) }
+ val scope = rememberCoroutineScope()
+ val contentDescription =
+ numberContentDescription(
+ selection = state.selection,
+ is24Hour = state.is24hour,
+ number = value
+ )
+
+ val text = value.toLocalString(minDigits = 1)
+ val selected = if (state.selection == Selection.Minute) {
+ state.minute.toLocalString(minDigits = 1) == text
+ } else {
+ state.hour.toLocalString(minDigits = 1) == text
+ }
+
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier
+ .minimumInteractiveComponentSize()
+ .size(MinimumInteractiveSize)
+ .onGloballyPositioned { center = it.boundsInParent().center }
+ .focusable()
+ .semantics(mergeDescendants = true) {
+ onClick {
+ scope.launch { state.onTap(center.x, center.y, maxDist, autoSwitchToMinute) }
+ true
+ }
+ this.selected = selected
+ }
+ ) {
+ Text(
+ modifier = Modifier.clearAndSetSemantics {
+ this.contentDescription = contentDescription
+ },
+ text = text,
+ style = style,
+ )
+ }
+}
+
+private fun timeInputOnChange(
+ selection: Selection,
+ state: TimePickerState,
+ value: TextFieldValue,
+ prevValue: TextFieldValue,
+ max: Int,
+ onNewValue: (value: TextFieldValue) -> Unit
+) {
+ if (value.text == prevValue.text) {
+ // just selection change
+ onNewValue(value)
+ return
+ }
+
+ if (value.text.isEmpty()) {
+ if (selection == Selection.Hour) state.setHour(0) else state.setMinute(0)
+ onNewValue(value.copy(text = ""))
+ return
+ }
+
+ try {
+ val newValue = if (value.text.length == 3 && value.selection.start == 1) {
+ value.text[0].digitToInt()
+ } else {
+ value.text.toInt()
+ }
+
+ if (newValue <= max) {
+ if (selection == Selection.Hour) {
+ state.setHour(newValue)
+ if (newValue > 1 && !state.is24hour) {
+ state.selection = Selection.Minute
+ }
+ } else {
+ state.setMinute(newValue)
+ }
+
+ onNewValue(
+ if (value.text.length <= 2) {
+ value
+ } else {
+ value.copy(text = value.text[0].toString())
+ }
+ )
+ }
+ } catch (_: NumberFormatException) {
+ } catch (_: IllegalArgumentException) {
+ // do nothing no state update
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun TimePickerTextField(
+ modifier: Modifier,
+ value: TextFieldValue,
+ onValueChange: (TextFieldValue) -> Unit,
+ state: TimePickerState,
+ selection: Selection,
+ keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
+ keyboardActions: KeyboardActions = KeyboardActions.Default,
+ colors: TimePickerColors,
+) {
+ val interactionSource = remember { MutableInteractionSource() }
+ val focusRequester = remember { FocusRequester() }
+ val textFieldColors = OutlinedTextFieldDefaults.colors(
+ focusedContainerColor = colors.timeSelectorContainerColor(true),
+ unfocusedContainerColor = colors.timeSelectorContainerColor(true),
+ focusedTextColor = colors.timeSelectorContentColor(true),
+ )
+ val selected = selection == state.selection
+ Column(modifier = modifier) {
+ if (!selected) {
+ TimeSelector(
+ modifier = Modifier.size(TimeFieldContainerWidth, TimeFieldContainerHeight),
+ value = if (selection == Selection.Hour) state.hourForDisplay else state.minute,
+ state = state,
+ selection = selection,
+ colors = colors,
+ )
+ }
+
+ val contentDescription = getString(
+ if (selection == Selection.Minute) {
+ Strings.TimePickerMinuteTextField
+ } else {
+ Strings.TimePickerHourTextField
+ }
+ )
+
+ Box(Modifier.visible(selected)) {
+ BasicTextField(
+ value = value,
+ onValueChange = onValueChange,
+ modifier = Modifier
+ .focusRequester(focusRequester)
+ .size(TimeFieldContainerWidth, TimeFieldContainerHeight)
+ .semantics {
+ this.contentDescription = contentDescription
+ },
+ interactionSource = interactionSource,
+ keyboardOptions = keyboardOptions,
+ keyboardActions = keyboardActions,
+ textStyle = LocalTextStyle.current,
+ enabled = true,
+ singleLine = true,
+ cursorBrush = Brush.verticalGradient(
+ 0.00f to Color.Transparent,
+ 0.10f to Color.Transparent,
+ 0.10f to MaterialTheme.colorScheme.primary,
+ 0.90f to MaterialTheme.colorScheme.primary,
+ 0.90f to Color.Transparent,
+ 1.00f to Color.Transparent
+ )
+ ) {
+ OutlinedTextFieldDefaults.DecorationBox(
+ value = value.text,
+ visualTransformation = VisualTransformation.None,
+ innerTextField = it,
+ singleLine = true,
+ colors = textFieldColors,
+ enabled = true,
+ interactionSource = interactionSource,
+ contentPadding = PaddingValues(0.dp),
+ container = {
+ OutlinedTextFieldDefaults.ContainerBox(
+ enabled = true,
+ isError = false,
+ interactionSource = interactionSource,
+ shape = TimeInputTokens.TimeFieldContainerShape.toShape(),
+ colors = textFieldColors,
+ )
+ }
+ )
+ }
+ }
+
+ Text(
+ modifier = Modifier
+ .offset(y = SupportLabelTop)
+ .clearAndSetSemantics {},
+ text = getString(
+ if (selection == Selection.Hour) {
+ Strings.TimePickerHour
+ } else {
+ Strings.TimePickerMinute
+ }
+ ),
+ color = TimeInputTokens.TimeFieldSupportingTextColor.toColor(),
+ style = MaterialTheme
+ .typography
+ .fromToken(TimeInputTokens.TimeFieldSupportingTextFont)
+ )
+ }
+
+ LaunchedEffect(state.selection) {
+ if (state.selection == selection) {
+ focusRequester.requestFocus()
+ }
+ }
+}
+
+/** Distribute elements evenly on a circle of [radius] */
+@Composable
+private fun CircularLayout(
+ modifier: Modifier = Modifier,
+ radius: Dp,
+ content: @Composable () -> Unit,
+) {
+ Layout(
+ modifier = modifier, content = content
+ ) { measurables, constraints ->
+ val radiusPx = radius.toPx()
+ val itemConstraints = constraints.copy(minWidth = 0, minHeight = 0)
+ val placeables = measurables.filter {
+ it.layoutId != LayoutId.Selector && it.layoutId != LayoutId.InnerCircle
+ }.map { measurable -> measurable.measure(itemConstraints) }
+ val selectorMeasurable = measurables.find { it.layoutId == LayoutId.Selector }
+ val innerMeasurable = measurables.find { it.layoutId == LayoutId.InnerCircle }
+ val theta = FullCircle / (placeables.count())
+ val selectorPlaceable = selectorMeasurable?.measure(itemConstraints)
+ val innerCirclePlaceable = innerMeasurable?.measure(itemConstraints)
+
+ layout(
+ width = constraints.minWidth,
+ height = constraints.minHeight,
+ ) {
+ selectorPlaceable?.place(0, 0)
+
+ placeables.forEachIndexed { i, it ->
+ val centerOffsetX = constraints.maxWidth / 2 - it.width / 2
+ val centerOffsetY = constraints.maxHeight / 2 - it.height / 2
+ val offsetX = radiusPx * cos(theta * i - QuarterCircle) + centerOffsetX
+ val offsetY = radiusPx * sin(theta * i - QuarterCircle) + centerOffsetY
+ it.place(
+ x = offsetX.roundToInt(), y = offsetY.roundToInt()
+ )
+ }
+
+ innerCirclePlaceable?.place(
+ (constraints.minWidth - innerCirclePlaceable.width) / 2,
+ (constraints.minHeight - innerCirclePlaceable.height) / 2
+ )
+ }
+ }
+}
+
+@Composable
+@ReadOnlyComposable
+internal fun numberContentDescription(
+ selection: Selection,
+ is24Hour: Boolean,
+ number: Int
+): String {
+ val id = if (selection == Selection.Minute) {
+ Strings.TimePickerMinuteSuffix
+ } else if (is24Hour) {
+ Strings.TimePicker24HourSuffix
+ } else {
+ Strings.TimePickerHourSuffix
+ }
+
+ return getString(id, number)
+}
+
+private fun valuesForAnimation(current: Float, new: Float): Pair {
+ var start = current
+ var end = new
+ if (abs(start - end) <= PI) {
+ return Pair(start, end)
+ }
+
+ if (start > PI && end < PI) {
+ end += FullCircle
+ } else if (start < PI && end > PI) {
+ start += FullCircle
+ }
+
+ return Pair(start, end)
+}
+
+private fun dist(x1: Float, y1: Float, x2: Int, y2: Int): Float {
+ val x = x2 - x1
+ val y = y2 - y1
+ return hypot(x.toDouble(), y.toDouble()).toFloat()
+}
+
+private fun atan(y: Float, x: Float): Float {
+ val ret = atan2(y, x) - QuarterCircle.toFloat()
+ return if (ret < 0) ret + FullCircle else ret
+}
+
+private enum class LayoutId {
+ Selector, InnerCircle,
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+internal val defaultTimePickerLayoutType: TimePickerLayoutType
+ @Composable
+ @ReadOnlyComposable get() = with(LocalConfiguration.current) {
+ if (screenHeightDp < screenWidthDp) {
+ TimePickerLayoutType.Horizontal
+ } else {
+ TimePickerLayoutType.Vertical
+ }
+ }
+
+
+@JvmInline
+internal value class Selection private constructor(val value: Int) {
+ companion object {
+ val Hour = Selection(0)
+ val Minute = Selection(1)
+ }
+}
+
+private const val FullCircle: Float = (PI * 2).toFloat()
+private const val QuarterCircle = PI / 2
+private const val RadiansPerMinute: Float = FullCircle / 60
+private const val RadiansPerHour: Float = FullCircle / 12f
+private const val SeparatorZIndex = 2f
+
+private val OuterCircleSizeRadius = 101.dp
+private val InnerCircleRadius = 69.dp
+private val ClockDisplayBottomMargin = 36.dp
+private val ClockFaceBottomMargin = 24.dp
+private val DisplaySeparatorWidth = 24.dp
+
+private val SupportLabelTop = 7.dp
+private val TimeInputBottomPadding = 24.dp
+private val MaxDistance = 74.dp
+private val MinimumInteractiveSize = 48.dp
+private val Minutes = listOf(0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55)
+private val Hours = listOf(12, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11)
+private val ExtraHours = Hours.map { (it % 12 + 12) }
+private val PeriodToggleMargin = 12.dp
+
+/**
+ * Measure the composable with 0,0 so that it stays on the screen. Necessary to correctly
+ * handle focus
+ */
+@Stable
+private fun Modifier.visible(visible: Boolean) = this.then(
+ VisibleModifier(
+ visible,
+ debugInspectorInfo {
+ name = "visible"
+ properties["visible"] = visible
+ }
+ )
+)
+
+private class VisibleModifier(
+ val visible: Boolean,
+ inspectorInfo: InspectorInfo.() -> Unit
+) : LayoutModifier, InspectorValueInfo(inspectorInfo) {
+
+ override fun MeasureScope.measure(
+ measurable: Measurable,
+ constraints: Constraints
+ ): MeasureResult {
+ val placeable = measurable.measure(constraints)
+
+ if (!visible) {
+ return layout(0, 0) {}
+ }
+ return layout(placeable.width, placeable.height) {
+ placeable.place(0, 0)
+ }
+ }
+
+ override fun hashCode(): Int = visible.hashCode()
+
+ override fun equals(other: Any?): Boolean {
+ val otherModifier = other as? VisibleModifier ?: return false
+ return visible == otherModifier.visible
+ }
+}
+
+private fun Int.toLocalString(minDigits: Int): String {
+ val formatter = NumberFormat.getIntegerInstance()
+ // Eliminate any use of delimiters when formatting the integer.
+ formatter.isGroupingUsed = false
+ formatter.minimumIntegerDigits = minDigits
+ return formatter.format(this)
+}
diff --git a/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/datetime/picker/time/TimePickerTokens.kt b/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/datetime/picker/time/TimePickerTokens.kt
new file mode 100644
index 000000000..b5eac8d49
--- /dev/null
+++ b/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/datetime/picker/time/TimePickerTokens.kt
@@ -0,0 +1,329 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ *
+ * Modifications copyright (C) 2023 Esri Inc
+ */
+
+package com.arcgismaps.toolkit.featureforms.components.datetime.picker.time
+
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.CornerBasedShape
+import androidx.compose.foundation.shape.CornerSize
+import androidx.compose.material3.ColorScheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Shapes
+import androidx.compose.material3.Typography
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.ReadOnlyComposable
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.RectangleShape
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.text.PlatformTextStyle
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.unit.dp
+
+internal object TimePickerTokens {
+ val ClockDialColor = ColorSchemeKeyTokens.SurfaceVariant
+ val ClockDialContainerSize = 256.0.dp
+ val ClockDialLabelTextFont = TypographyKeyTokens.BodyLarge
+ val ClockDialSelectedLabelTextColor = ColorSchemeKeyTokens.OnPrimary
+ val ClockDialSelectorCenterContainerColor = ColorSchemeKeyTokens.Primary
+ val ClockDialSelectorCenterContainerShape = ShapeKeyTokens.CornerFull
+ val ClockDialSelectorCenterContainerSize = 8.0.dp
+ val ClockDialSelectorHandleContainerColor = ColorSchemeKeyTokens.Primary
+ val ClockDialSelectorHandleContainerShape = ShapeKeyTokens.CornerFull
+ val ClockDialSelectorHandleContainerSize = 48.0.dp
+ val ClockDialSelectorTrackContainerColor = ColorSchemeKeyTokens.Primary
+ val ClockDialSelectorTrackContainerWidth = 2.0.dp
+ val ClockDialShape = ShapeKeyTokens.CornerFull
+ val ClockDialUnselectedLabelTextColor = ColorSchemeKeyTokens.OnSurface
+ val ContainerColor = ColorSchemeKeyTokens.Surface
+ val ContainerElevation = ElevationTokens.Level3
+ val ContainerShape = ShapeKeyTokens.CornerExtraLarge
+ val HeadlineColor = ColorSchemeKeyTokens.OnSurfaceVariant
+ val HeadlineFont = TypographyKeyTokens.LabelMedium
+ val PeriodSelectorContainerShape = ShapeKeyTokens.CornerSmall
+ val PeriodSelectorHorizontalContainerHeight = 38.0.dp
+ val PeriodSelectorHorizontalContainerWidth = 216.0.dp
+ val PeriodSelectorLabelTextFont = TypographyKeyTokens.TitleMedium
+ val PeriodSelectorOutlineColor = ColorSchemeKeyTokens.Outline
+ val PeriodSelectorOutlineWidth = 1.0.dp
+ val PeriodSelectorSelectedContainerColor = ColorSchemeKeyTokens.TertiaryContainer
+ val PeriodSelectorSelectedFocusLabelTextColor = ColorSchemeKeyTokens.OnTertiaryContainer
+ val PeriodSelectorSelectedHoverLabelTextColor = ColorSchemeKeyTokens.OnTertiaryContainer
+ val PeriodSelectorSelectedLabelTextColor = ColorSchemeKeyTokens.OnTertiaryContainer
+ val PeriodSelectorSelectedPressedLabelTextColor = ColorSchemeKeyTokens.OnTertiaryContainer
+ val PeriodSelectorUnselectedFocusLabelTextColor = ColorSchemeKeyTokens.OnSurfaceVariant
+ val PeriodSelectorUnselectedHoverLabelTextColor = ColorSchemeKeyTokens.OnSurfaceVariant
+ val PeriodSelectorUnselectedLabelTextColor = ColorSchemeKeyTokens.OnSurfaceVariant
+ val PeriodSelectorUnselectedPressedLabelTextColor = ColorSchemeKeyTokens.OnSurfaceVariant
+ val PeriodSelectorVerticalContainerHeight = 80.0.dp
+ val PeriodSelectorVerticalContainerWidth = 52.0.dp
+ val SurfaceTintLayerColor = ColorSchemeKeyTokens.SurfaceTint
+ val TimeSelector24HVerticalContainerWidth = 114.0.dp
+ val TimeSelectorContainerHeight = 80.0.dp
+ val TimeSelectorContainerShape = ShapeKeyTokens.CornerSmall
+ val TimeSelectorContainerWidth = 96.0.dp
+ val TimeSelectorLabelTextFont = TypographyKeyTokens.DisplayLarge
+ val TimeSelectorSelectedContainerColor = ColorSchemeKeyTokens.PrimaryContainer
+ val TimeSelectorSelectedFocusLabelTextColor = ColorSchemeKeyTokens.OnPrimaryContainer
+ val TimeSelectorSelectedHoverLabelTextColor = ColorSchemeKeyTokens.OnPrimaryContainer
+ val TimeSelectorSelectedLabelTextColor = ColorSchemeKeyTokens.OnPrimaryContainer
+ val TimeSelectorSelectedPressedLabelTextColor = ColorSchemeKeyTokens.OnPrimaryContainer
+ val TimeSelectorSeparatorColor = ColorSchemeKeyTokens.OnSurface
+ val TimeSelectorSeparatorFont = TypographyKeyTokens.DisplayLarge
+ val TimeSelectorUnselectedContainerColor = ColorSchemeKeyTokens.SurfaceVariant
+ val TimeSelectorUnselectedFocusLabelTextColor = ColorSchemeKeyTokens.OnSurface
+ val TimeSelectorUnselectedHoverLabelTextColor = ColorSchemeKeyTokens.OnSurface
+ val TimeSelectorUnselectedLabelTextColor = ColorSchemeKeyTokens.OnSurface
+ val TimeSelectorUnselectedPressedLabelTextColor = ColorSchemeKeyTokens.OnSurface
+}
+
+internal object TimeInputTokens {
+ val ContainerColor = ColorSchemeKeyTokens.Surface
+ val ContainerElevation = ElevationTokens.Level3
+ val ContainerShape = ShapeKeyTokens.CornerExtraLarge
+ val HeadlineColor = ColorSchemeKeyTokens.OnSurfaceVariant
+ val HeadlineFont = TypographyKeyTokens.LabelMedium
+ val PeriodSelectorContainerHeight = 72.0.dp
+ val PeriodSelectorContainerShape = ShapeKeyTokens.CornerSmall
+ val PeriodSelectorContainerWidth = 52.0.dp
+ val PeriodSelectorLabelTextFont = TypographyKeyTokens.TitleMedium
+ val PeriodSelectorOutlineColor = ColorSchemeKeyTokens.Outline
+ val PeriodSelectorOutlineWidth = 1.0.dp
+ val PeriodSelectorSelectedContainerColor = ColorSchemeKeyTokens.TertiaryContainer
+ val PeriodSelectorSelectedFocusLabelTextColor = ColorSchemeKeyTokens.OnTertiaryContainer
+ val PeriodSelectorSelectedHoverLabelTextColor = ColorSchemeKeyTokens.OnTertiaryContainer
+ val PeriodSelectorSelectedLabelTextColor = ColorSchemeKeyTokens.OnTertiaryContainer
+ val PeriodSelectorSelectedPressedLabelTextColor = ColorSchemeKeyTokens.OnTertiaryContainer
+ val PeriodSelectorUnselectedFocusLabelTextColor = ColorSchemeKeyTokens.OnSurfaceVariant
+ val PeriodSelectorUnselectedHoverLabelTextColor = ColorSchemeKeyTokens.OnSurfaceVariant
+ val PeriodSelectorUnselectedLabelTextColor = ColorSchemeKeyTokens.OnSurfaceVariant
+ val PeriodSelectorUnselectedPressedLabelTextColor = ColorSchemeKeyTokens.OnSurfaceVariant
+ val SurfaceTintLayerColor = ColorSchemeKeyTokens.SurfaceTint
+ val TimeFieldContainerColor = ColorSchemeKeyTokens.SurfaceVariant
+ val TimeFieldContainerHeight = 72.0.dp
+ val TimeFieldContainerShape = ShapeKeyTokens.CornerSmall
+ val TimeFieldContainerWidth = 96.0.dp
+ val TimeFieldFocusContainerColor = ColorSchemeKeyTokens.PrimaryContainer
+ val TimeFieldFocusLabelTextColor = ColorSchemeKeyTokens.OnPrimaryContainer
+ val TimeFieldFocusOutlineColor = ColorSchemeKeyTokens.Primary
+ val TimeFieldFocusOutlineWidth = 2.0.dp
+ val TimeFieldHoverLabelTextColor = ColorSchemeKeyTokens.OnSurface
+ val TimeFieldLabelTextColor = ColorSchemeKeyTokens.OnSurface
+ val TimeFieldLabelTextFont = TypographyKeyTokens.DisplayMedium
+ val TimeFieldSeparatorColor = ColorSchemeKeyTokens.OnSurface
+ val TimeFieldSeparatorFont = TypographyKeyTokens.DisplayLarge
+ val TimeFieldSupportingTextColor = ColorSchemeKeyTokens.OnSurfaceVariant
+ val TimeFieldSupportingTextFont = TypographyKeyTokens.BodySmall
+}
+
+internal enum class TypographyKeyTokens {
+ BodyLarge,
+ BodyMedium,
+ BodySmall,
+ DisplayLarge,
+ DisplayMedium,
+ DisplaySmall,
+ HeadlineLarge,
+ HeadlineMedium,
+ HeadlineSmall,
+ LabelLarge,
+ LabelMedium,
+ LabelSmall,
+ TitleLarge,
+ TitleMedium,
+ TitleSmall,
+}
+
+internal enum class ColorSchemeKeyTokens {
+ Background,
+ Error,
+ ErrorContainer,
+ InverseOnSurface,
+ InversePrimary,
+ InverseSurface,
+ OnBackground,
+ OnError,
+ OnErrorContainer,
+ OnPrimary,
+ OnPrimaryContainer,
+ OnSecondary,
+ OnSecondaryContainer,
+ OnSurface,
+ OnSurfaceVariant,
+ OnTertiary,
+ OnTertiaryContainer,
+ Outline,
+ OutlineVariant,
+ Primary,
+ PrimaryContainer,
+ Scrim,
+ Secondary,
+ SecondaryContainer,
+ Surface,
+ SurfaceTint,
+ SurfaceVariant,
+ Tertiary,
+ TertiaryContainer,
+}
+
+internal object ElevationTokens {
+ val Level0 = 0.0.dp
+ val Level1 = 1.0.dp
+ val Level2 = 3.0.dp
+ val Level3 = 6.0.dp
+ val Level4 = 8.0.dp
+ val Level5 = 12.0.dp
+}
+
+internal enum class ShapeKeyTokens {
+ CornerExtraLarge,
+ CornerExtraLargeTop,
+ CornerExtraSmall,
+ CornerExtraSmallTop,
+ CornerFull,
+ CornerLarge,
+ CornerLargeEnd,
+ CornerLargeTop,
+ CornerMedium,
+ CornerNone,
+ CornerSmall,
+}
+
+/**
+ * Helper function for component typography tokens.
+ */
+internal fun Typography.fromToken(value: TypographyKeyTokens): TextStyle {
+ return when (value) {
+ TypographyKeyTokens.DisplayLarge -> displayLarge
+ TypographyKeyTokens.DisplayMedium -> displayMedium
+ TypographyKeyTokens.DisplaySmall -> displaySmall
+ TypographyKeyTokens.HeadlineLarge -> headlineLarge
+ TypographyKeyTokens.HeadlineMedium -> headlineMedium
+ TypographyKeyTokens.HeadlineSmall -> headlineSmall
+ TypographyKeyTokens.TitleLarge -> titleLarge
+ TypographyKeyTokens.TitleMedium -> titleMedium
+ TypographyKeyTokens.TitleSmall -> titleSmall
+ TypographyKeyTokens.BodyLarge -> bodyLarge
+ TypographyKeyTokens.BodyMedium -> bodyMedium
+ TypographyKeyTokens.BodySmall -> bodySmall
+ TypographyKeyTokens.LabelLarge -> labelLarge
+ TypographyKeyTokens.LabelMedium -> labelMedium
+ TypographyKeyTokens.LabelSmall -> labelSmall
+ }
+}
+
+/** Helper function for component shape tokens. Used to grab the top values of a shape parameter. */
+internal fun CornerBasedShape.top(): CornerBasedShape {
+ return copy(bottomStart = CornerSize(0.0.dp), bottomEnd = CornerSize(0.0.dp))
+}
+
+/**
+ * Helper function for component shape tokens. Used to grab the bottom values of a shape parameter.
+ */
+internal fun CornerBasedShape.bottom(): CornerBasedShape {
+ return copy(topStart = CornerSize(0.0.dp), topEnd = CornerSize(0.0.dp))
+}
+
+/** Helper function for component shape tokens. Used to grab the start values of a shape parameter. */
+internal fun CornerBasedShape.start(): CornerBasedShape {
+ return copy(topEnd = CornerSize(0.0.dp), bottomEnd = CornerSize(0.0.dp))
+}
+
+/** Helper function for component shape tokens. Used to grab the end values of a shape parameter. */
+internal fun CornerBasedShape.end(): CornerBasedShape {
+ return copy(topStart = CornerSize(0.0.dp), bottomStart = CornerSize(0.0.dp))
+}
+
+/**
+ * Helper function for component shape tokens. Here is an example on how to use component color
+ * tokens:
+ * ``MaterialTheme.shapes.fromToken(FabPrimarySmallTokens.ContainerShape)``
+ */
+internal fun Shapes.fromToken(value: ShapeKeyTokens): Shape {
+ return when (value) {
+ ShapeKeyTokens.CornerExtraLarge -> extraLarge
+ ShapeKeyTokens.CornerExtraLargeTop -> extraLarge.top()
+ ShapeKeyTokens.CornerExtraSmall -> extraSmall
+ ShapeKeyTokens.CornerExtraSmallTop -> extraSmall.top()
+ ShapeKeyTokens.CornerFull -> CircleShape
+ ShapeKeyTokens.CornerLarge -> large
+ ShapeKeyTokens.CornerLargeEnd -> large.end()
+ ShapeKeyTokens.CornerLargeTop -> large.top()
+ ShapeKeyTokens.CornerMedium -> medium
+ ShapeKeyTokens.CornerNone -> RectangleShape
+ ShapeKeyTokens.CornerSmall -> small
+ }
+}
+
+@Composable
+@ReadOnlyComposable
+internal fun ShapeKeyTokens.toShape(): Shape {
+ return MaterialTheme.shapes.fromToken(this)
+}
+
+/**
+ * Helper function for component color tokens. Here is an example on how to use component color
+ * tokens:
+ * ``MaterialTheme.colorScheme.fromToken(ExtendedFabBranded.BrandedContainerColor)``
+ */
+internal fun ColorScheme.fromToken(value: ColorSchemeKeyTokens): Color {
+ return when (value) {
+ ColorSchemeKeyTokens.Background -> background
+ ColorSchemeKeyTokens.Error -> error
+ ColorSchemeKeyTokens.ErrorContainer -> errorContainer
+ ColorSchemeKeyTokens.InverseOnSurface -> inverseOnSurface
+ ColorSchemeKeyTokens.InversePrimary -> inversePrimary
+ ColorSchemeKeyTokens.InverseSurface -> inverseSurface
+ ColorSchemeKeyTokens.OnBackground -> onBackground
+ ColorSchemeKeyTokens.OnError -> onError
+ ColorSchemeKeyTokens.OnErrorContainer -> onErrorContainer
+ ColorSchemeKeyTokens.OnPrimary -> onPrimary
+ ColorSchemeKeyTokens.OnPrimaryContainer -> onPrimaryContainer
+ ColorSchemeKeyTokens.OnSecondary -> onSecondary
+ ColorSchemeKeyTokens.OnSecondaryContainer -> onSecondaryContainer
+ ColorSchemeKeyTokens.OnSurface -> onSurface
+ ColorSchemeKeyTokens.OnSurfaceVariant -> onSurfaceVariant
+ ColorSchemeKeyTokens.SurfaceTint -> surfaceTint
+ ColorSchemeKeyTokens.OnTertiary -> onTertiary
+ ColorSchemeKeyTokens.OnTertiaryContainer -> onTertiaryContainer
+ ColorSchemeKeyTokens.Outline -> outline
+ ColorSchemeKeyTokens.OutlineVariant -> outlineVariant
+ ColorSchemeKeyTokens.Primary -> primary
+ ColorSchemeKeyTokens.PrimaryContainer -> primaryContainer
+ ColorSchemeKeyTokens.Scrim -> scrim
+ ColorSchemeKeyTokens.Secondary -> secondary
+ ColorSchemeKeyTokens.SecondaryContainer -> secondaryContainer
+ ColorSchemeKeyTokens.Surface -> surface
+ ColorSchemeKeyTokens.SurfaceVariant -> surfaceVariant
+ ColorSchemeKeyTokens.Tertiary -> tertiary
+ ColorSchemeKeyTokens.TertiaryContainer -> tertiaryContainer
+ }
+}
+
+@ReadOnlyComposable
+@Composable
+internal fun ColorSchemeKeyTokens.toColor(): Color {
+ return MaterialTheme.colorScheme.fromToken(this)
+}
+
+@Suppress("DEPRECATION")
+internal fun copyAndSetFontPadding(
+ style: TextStyle,
+ includeFontPadding: Boolean
+): TextStyle =
+ style.copy(platformStyle = PlatformTextStyle(includeFontPadding = includeFontPadding))
diff --git a/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/datetime/picker/time/TouchExplorationStateProvider.kt b/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/datetime/picker/time/TouchExplorationStateProvider.kt
new file mode 100644
index 000000000..c7d599df2
--- /dev/null
+++ b/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/datetime/picker/time/TouchExplorationStateProvider.kt
@@ -0,0 +1,109 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ *
+ * Modifications copyright (C) 2023 Esri Inc
+ */
+
+package com.arcgismaps.toolkit.featureforms.components.datetime.picker.time
+
+import android.content.Context
+import android.view.accessibility.AccessibilityManager
+import android.view.accessibility.AccessibilityManager.AccessibilityStateChangeListener
+import android.view.accessibility.AccessibilityManager.TouchExplorationStateChangeListener
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.State
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalLifecycleOwner
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleEventObserver
+
+/**
+ * It depends on the state of accessibility services to determine the current state of touch
+ * exploration services.
+ */
+@Composable
+internal fun touchExplorationState(): State {
+ val context = LocalContext.current
+ val accessibilityManager = remember {
+ context.getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager
+ }
+
+ val listener = remember { Listener() }
+
+ LocalLifecycleOwner.current.lifecycle.ObserveState(
+ handleEvent = { event ->
+ if (event == Lifecycle.Event.ON_RESUME) {
+ listener.register(accessibilityManager)
+ }
+ },
+ onDispose = {
+ listener.unregister(accessibilityManager)
+ }
+ )
+
+ return remember { derivedStateOf { listener.isEnabled() } }
+}
+
+@Composable
+private fun Lifecycle.ObserveState(
+ handleEvent: (Lifecycle.Event) -> Unit = {},
+ onDispose: () -> Unit = {}
+) {
+ DisposableEffect(this) {
+ val observer = LifecycleEventObserver { _, event ->
+ handleEvent(event)
+ }
+ this@ObserveState.addObserver(observer)
+ onDispose {
+ onDispose()
+ this@ObserveState.removeObserver(observer)
+ }
+ }
+}
+
+private class Listener : AccessibilityStateChangeListener, TouchExplorationStateChangeListener {
+ private var accessibilityEnabled by mutableStateOf(false)
+ private var touchExplorationEnabled by mutableStateOf(false)
+
+ fun isEnabled() = accessibilityEnabled && touchExplorationEnabled
+
+ override fun onAccessibilityStateChanged(it: Boolean) {
+ accessibilityEnabled = it
+ }
+
+ override fun onTouchExplorationStateChanged(it: Boolean) {
+ touchExplorationEnabled = it
+ }
+
+ fun register(am: AccessibilityManager) {
+ accessibilityEnabled = am.isEnabled
+ touchExplorationEnabled = am.isTouchExplorationEnabled
+
+ am.addTouchExplorationStateChangeListener(this)
+ am.addAccessibilityStateChangeListener(this)
+ }
+
+ fun unregister(am: AccessibilityManager) {
+ am.removeTouchExplorationStateChangeListener(this)
+ am.removeAccessibilityStateChangeListener(this)
+ }
+}
diff --git a/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/formelement/FieldElement.kt b/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/formelement/FieldElement.kt
new file mode 100644
index 000000000..e55d3d780
--- /dev/null
+++ b/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/formelement/FieldElement.kt
@@ -0,0 +1,83 @@
+/*
+ * Copyright 2023 Esri
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.arcgismaps.toolkit.featureforms.components.formelement
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import com.arcgismaps.toolkit.featureforms.components.base.BaseFieldState
+import com.arcgismaps.toolkit.featureforms.components.codedvalue.CodedValueFieldState
+import com.arcgismaps.toolkit.featureforms.components.codedvalue.ComboBoxField
+import com.arcgismaps.toolkit.featureforms.components.codedvalue.RadioButtonField
+import com.arcgismaps.toolkit.featureforms.components.codedvalue.RadioButtonFieldState
+import com.arcgismaps.toolkit.featureforms.components.codedvalue.SwitchField
+import com.arcgismaps.toolkit.featureforms.components.codedvalue.SwitchFieldState
+import com.arcgismaps.toolkit.featureforms.components.datetime.DateTimeField
+import com.arcgismaps.toolkit.featureforms.components.datetime.DateTimeFieldState
+import com.arcgismaps.toolkit.featureforms.components.text.FormTextField
+import com.arcgismaps.toolkit.featureforms.components.text.FormTextFieldState
+
+@Composable
+internal fun FieldElement(
+ state: BaseFieldState,
+ onDialogRequest: () -> Unit = {}
+) {
+ val visible by state.isVisible.collectAsState()
+ if (visible) {
+ when (state) {
+ is FormTextFieldState -> {
+ FormTextField(state = state)
+ }
+
+ is DateTimeFieldState -> {
+ DateTimeField(
+ state = state,
+ onDialogRequest = onDialogRequest
+ )
+ }
+
+ is SwitchFieldState -> {
+ if (!state.fallback) {
+ SwitchField(state = state)
+ } else {
+ ComboBoxField(
+ state = state,
+ onDialogRequest = onDialogRequest
+ )
+ }
+ }
+
+ is RadioButtonFieldState -> {
+ if (state.shouldFallback()) {
+ ComboBoxField(state = state)
+ } else {
+ RadioButtonField(state = state)
+ }
+ }
+
+ is CodedValueFieldState -> {
+ ComboBoxField(
+ state = state,
+ onDialogRequest = onDialogRequest
+ )
+ }
+
+ else -> { /* TO-DO: add support for other input types */
+ }
+ }
+ }
+}
diff --git a/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/formelement/GroupElement.kt b/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/formelement/GroupElement.kt
new file mode 100644
index 000000000..0c48351a3
--- /dev/null
+++ b/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/formelement/GroupElement.kt
@@ -0,0 +1,163 @@
+/*
+ * Copyright 2023 Esri
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.arcgismaps.toolkit.featureforms.components.formelement
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.Crossfade
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.ExpandLess
+import androidx.compose.material.icons.rounded.ExpandMore
+import androidx.compose.material3.Card
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.arcgismaps.mapping.featureforms.GroupFormElement
+import com.arcgismaps.toolkit.featureforms.components.base.BaseFieldState
+import com.arcgismaps.toolkit.featureforms.components.base.BaseGroupState
+
+@Composable
+internal fun GroupElement(
+ groupElement: GroupFormElement,
+ state: BaseGroupState,
+ modifier: Modifier = Modifier,
+ colors: GroupElementColors = GroupElementDefaults.colors(),
+ onDialogRequest: (BaseFieldState<*>, Int) -> Unit
+) {
+ val visible by groupElement.isVisible.collectAsState()
+ if (visible) {
+ GroupElement(
+ label = state.label,
+ description = state.description,
+ expanded = state.expanded.value,
+ fieldStates = state.fieldStates,
+ modifier = modifier,
+ colors = colors,
+ onClick = {
+ state.setExpanded(!state.expanded.value)
+ },
+ onDialogRequest = onDialogRequest
+ )
+ }
+}
+
+@Composable
+private fun GroupElement(
+ label: String,
+ description: String,
+ expanded: Boolean,
+ fieldStates: Map?>,
+ modifier: Modifier = Modifier,
+ colors: GroupElementColors,
+ onClick: () -> Unit,
+ onDialogRequest: ((BaseFieldState<*>, Int) -> Unit)? = null
+) {
+ Card(
+ modifier = modifier,
+ shape = GroupElementDefaults.containerShape,
+ border = BorderStroke(GroupElementDefaults.borderThickness, colors.borderColor)
+ ) {
+ GroupElementHeader(
+ modifier = Modifier.fillMaxWidth(),
+ title = label,
+ description = description,
+ isExpanded = expanded,
+ onClick = onClick
+ )
+ AnimatedVisibility(visible = expanded) {
+ Column(
+ modifier = Modifier.background(colors.containerColor)
+ ) {
+ fieldStates.forEach { (key, state) ->
+ if (state != null) {
+ FieldElement(state = state) {
+ onDialogRequest?.invoke(state, key)
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun GroupElementHeader(
+ modifier: Modifier = Modifier,
+ title: String,
+ description: String,
+ isExpanded: Boolean,
+ onClick: () -> Unit
+) {
+ Row(modifier = modifier
+ .clickable {
+ onClick()
+ }
+ .padding(15.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.bodyMedium,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ Text(
+ text = description,
+ style = MaterialTheme.typography.bodySmall
+ )
+ }
+ Crossfade(targetState = isExpanded, label = "expanded-icon-anim") {
+ Icon(
+ imageVector = if (it) {
+ Icons.Rounded.ExpandLess
+ } else {
+ Icons.Rounded.ExpandMore
+ },
+ contentDescription = null
+ )
+ }
+ }
+}
+
+@Preview(showBackground = true, backgroundColor = 0xFFFFFF)
+@Composable
+private fun GroupElementPreview() {
+ GroupElement(
+ label = "Title",
+ description = "Description",
+ expanded = false,
+ fieldStates = mutableMapOf(),
+ modifier = Modifier.padding(start = 15.dp, end = 15.dp, top = 10.dp, bottom = 10.dp),
+ colors = GroupElementDefaults.colors(),
+ onClick = {}
+ )
+}
diff --git a/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/formelement/GroupElementDefaults.kt b/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/formelement/GroupElementDefaults.kt
new file mode 100644
index 000000000..e4e0b563c
--- /dev/null
+++ b/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/formelement/GroupElementDefaults.kt
@@ -0,0 +1,42 @@
+/*
+ * Copyright 2023 Esri
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.arcgismaps.toolkit.featureforms.components.formelement
+
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.LocalContentColor
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import com.arcgismaps.toolkit.featureforms.components.codedvalue.RadioButtonFieldColors
+
+internal object GroupElementDefaults {
+
+ val borderThickness = 1.dp
+ val containerShape = RoundedCornerShape(5.dp)
+
+ @Composable
+ fun colors() : GroupElementColors = GroupElementColors(
+ containerColor = MaterialTheme.colorScheme.background,
+ borderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.6f)
+ )
+}
+
+internal data class GroupElementColors(
+ val containerColor : Color,
+ val borderColor : Color,
+)
diff --git a/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/text/FormTextField.kt b/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/text/FormTextField.kt
new file mode 100644
index 000000000..67f301f48
--- /dev/null
+++ b/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/text/FormTextField.kt
@@ -0,0 +1,92 @@
+/*
+ * Copyright 2023 Esri
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.arcgismaps.toolkit.featureforms.components.text
+
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.input.KeyboardType
+import com.arcgismaps.toolkit.featureforms.components.base.BaseTextField
+import com.arcgismaps.toolkit.featureforms.utils.isNumeric
+
+@Composable
+internal fun FormTextField(
+ state: FormTextFieldState,
+ modifier: Modifier = Modifier,
+) {
+ val text by state.value.collectAsState()
+ val isEditable by state.isEditable.collectAsState()
+ val isRequired by state.isRequired.collectAsState()
+ val isFocused by state.isFocused.collectAsState()
+ val label = remember(isRequired) {
+ if (isRequired) {
+ "${state.label} *"
+ } else {
+ state.label
+ }
+ }
+ val supportingText by state.supportingText
+ val contentLength = if (state.minLength > 0 || state.maxLength > 0) "${text.length}" else ""
+ val supportingTextIsErrorMessage by state.supportingTextIsErrorMessage
+
+ BaseTextField(
+ text = text,
+ onValueChange = {
+ state.onValueChanged(it)
+ },
+ modifier = modifier.fillMaxWidth(),
+ isEditable = isEditable,
+ label = label,
+ placeholder = state.placeholder,
+ singleLine = state.singleLine,
+ keyboardType = if (state.fieldType.isNumeric) KeyboardType.Number else KeyboardType.Ascii,
+ supportingText = {
+ val textColor = if (supportingTextIsErrorMessage) MaterialTheme.colorScheme.error
+ else MaterialTheme.colorScheme.onSurface
+ Row {
+ if (supportingText.isNotEmpty()) {
+ Text(
+ text = supportingText,
+ modifier = Modifier
+ .semantics { contentDescription = "helper" },
+ color = textColor
+ )
+ }
+ if (isFocused && isEditable) {
+ Spacer(modifier = Modifier.weight(1f))
+ Text(
+ text = contentLength,
+ modifier = Modifier.semantics { contentDescription = "char count" },
+ color = textColor
+ )
+ }
+ }
+ },
+ onFocusChange = {
+ state.onFocusChanged(it)
+ }
+ )
+}
diff --git a/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/text/FormTextFieldState.kt b/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/text/FormTextFieldState.kt
new file mode 100644
index 000000000..4557c9f7e
--- /dev/null
+++ b/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/text/FormTextFieldState.kt
@@ -0,0 +1,497 @@
+/*
+ * Copyright 2023 Esri
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.arcgismaps.toolkit.featureforms.components.text
+
+import android.content.Context
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.State
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.Saver
+import androidx.compose.runtime.saveable.listSaver
+import androidx.compose.runtime.saveable.rememberSaveable
+import com.arcgismaps.data.Domain
+import com.arcgismaps.data.FieldType
+import com.arcgismaps.data.RangeDomain
+import com.arcgismaps.mapping.featureforms.FeatureForm
+import com.arcgismaps.mapping.featureforms.FieldFormElement
+import com.arcgismaps.mapping.featureforms.TextAreaFormInput
+import com.arcgismaps.mapping.featureforms.TextBoxFormInput
+import com.arcgismaps.toolkit.featureforms.R
+import com.arcgismaps.toolkit.featureforms.components.base.BaseFieldState
+import com.arcgismaps.toolkit.featureforms.components.base.FieldProperties
+import com.arcgismaps.toolkit.featureforms.components.formelement.FieldElement
+import com.arcgismaps.toolkit.featureforms.components.text.ValidationErrorState.ExactCharConstraint
+import com.arcgismaps.toolkit.featureforms.components.text.ValidationErrorState.MaxCharConstraint
+import com.arcgismaps.toolkit.featureforms.components.text.ValidationErrorState.MaxNumericConstraint
+import com.arcgismaps.toolkit.featureforms.components.text.ValidationErrorState.MinMaxCharConstraint
+import com.arcgismaps.toolkit.featureforms.components.text.ValidationErrorState.MinMaxNumericConstraint
+import com.arcgismaps.toolkit.featureforms.components.text.ValidationErrorState.MinNumericConstraint
+import com.arcgismaps.toolkit.featureforms.components.text.ValidationErrorState.NoError
+import com.arcgismaps.toolkit.featureforms.components.text.ValidationErrorState.NotANumber
+import com.arcgismaps.toolkit.featureforms.components.text.ValidationErrorState.NotAWholeNumber
+import com.arcgismaps.toolkit.featureforms.components.text.ValidationErrorState.Required
+import com.arcgismaps.toolkit.featureforms.utils.asDoubleTuple
+import com.arcgismaps.toolkit.featureforms.utils.asLongTuple
+import com.arcgismaps.toolkit.featureforms.utils.domain
+import com.arcgismaps.toolkit.featureforms.utils.editValue
+import com.arcgismaps.toolkit.featureforms.utils.fieldType
+import com.arcgismaps.toolkit.featureforms.utils.isFloatingPoint
+import com.arcgismaps.toolkit.featureforms.utils.isIntegerType
+import com.arcgismaps.toolkit.featureforms.utils.isNumeric
+import com.arcgismaps.toolkit.featureforms.utils.valueFlow
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.drop
+import kotlinx.coroutines.launch
+
+internal class TextFieldProperties(
+ label: String,
+ placeholder: String,
+ description: String,
+ value: StateFlow,
+ required: StateFlow,
+ editable: StateFlow,
+ visible: StateFlow,
+ val fieldType: FieldType,
+ val domain: Domain?,
+ val singleLine: Boolean,
+ val minLength: Int,
+ val maxLength: Int,
+) : FieldProperties(label, placeholder, description, value, required, editable, visible)
+
+/**
+ * A class to handle the state of a [FormTextField]. Essential properties are inherited from the
+ * [BaseFieldState].
+ *
+ * @param properties the [TextFieldProperties] associated with this state.
+ * @param initialValue optional initial value to set for this field. It is set to the value of
+ * [TextFieldProperties.value] by default.
+ * @param scope a [CoroutineScope] to start [StateFlow] collectors on.
+ * @param context a Context scoped to the lifetime of a call to the [FieldElement] composable function.
+ * @param onEditValue a callback to invoke when the user edits result in a change of value. This
+ * is called on [FormTextFieldState.onValueChanged].
+ */
+@Stable
+internal class FormTextFieldState(
+ properties: TextFieldProperties,
+ initialValue: String = properties.value.value,
+ scope: CoroutineScope,
+ private val context: Context,
+ onEditValue: (Any?) -> Unit
+) : BaseFieldState(
+ properties = properties,
+ initialValue = initialValue,
+ scope = scope,
+ onEditValue = onEditValue
+) {
+ // indicates singleLine only if TextBoxFeatureFormInput
+ val singleLine = properties.singleLine
+
+ // fetch the minLength based on the featureFormElement.inputType
+ val minLength = properties.minLength
+
+ // fetch the maxLength based on the featureFormElement.inputType
+ val maxLength = properties.maxLength
+
+ private var hasBeenFocused: Boolean = false
+
+ // supporting text will depend on multiple other states. If there is an error, it will display
+ // error message. Otherwise description is displayed, unless it is empty in which case
+ // the helper text is displayed when the field is focused.
+ private val _supportingText: MutableState = mutableStateOf(description)
+ val supportingText: State = _supportingText
+
+ private val _isFocused: MutableStateFlow = MutableStateFlow(false)
+ val isFocused: StateFlow = _isFocused.asStateFlow()
+
+ private val _hasError = mutableStateOf(false)
+ private val _supportingTextIsErrorMessage = mutableStateOf(false)
+ val supportingTextIsErrorMessage: State = _supportingTextIsErrorMessage
+
+ /**
+ * The domain of the element's field.
+ */
+ val domain: Domain? = properties.domain
+
+ /**
+ * The FieldType of the element's field.
+ */
+ val fieldType: FieldType = properties.fieldType
+
+ private val errorMessages: MutableMap by lazy {
+ val min = if (domain is RangeDomain) {
+ (domain.minValue as? Number)?.format()
+ } else {
+ ""
+ }
+
+ val max = if (domain is RangeDomain) {
+ (domain.maxValue as? Number)?.format()
+ } else {
+ ""
+ }
+
+ mutableMapOf(
+ Required to context.getString(R.string.required),
+ MaxCharConstraint to context.getString(R.string.maximum_n_chars, if (maxLength > 0) maxLength else 254),
+ ExactCharConstraint to context.getString(R.string.enter_n_chars, minLength),
+ MinMaxCharConstraint to context.getString(R.string.enter_min_to_max_chars, minLength, maxLength),
+ MinNumericConstraint to context.getString(R.string.less_than_min_value, min),
+ MaxNumericConstraint to context.getString(R.string.exceeds_max_value, max),
+ MinMaxNumericConstraint to context.getString(R.string.numeric_range_helper_text, min, max),
+ NotANumber to context.getString(R.string.value_must_be_a_number),
+ NotAWholeNumber to context.getString(R.string.value_must_be_a_whole_number)
+ )
+ }
+
+ // build helper text
+ private val helperText =
+ if (fieldType.isNumeric) {
+ if (domain != null && domain is RangeDomain) {
+ val min = domain.minValue
+ val max = domain.maxValue
+ // to format the range of either integer or floating point
+ // values without a lot of logic, they are formatted as strings.
+ if (min is Number && max is Number) {
+ context.getString(R.string.numeric_range_helper_text, min.format(), max.format())
+ } else if (min is Number) {
+ context.getString(R.string.less_than_min_value, min.format())
+ } else if (max is Number) {
+ context.getString(R.string.exceeds_max_value, max.format())
+ } else {
+ // not likely to happen.
+ ""
+ }
+ } else {
+ ""
+ }
+ } else {
+ if (minLength > 0 && maxLength > 0) {
+ if (minLength == maxLength) {
+ context.getString(R.string.enter_n_chars, minLength)
+ } else {
+ context.getString(R.string.enter_min_to_max_chars, minLength, maxLength)
+ }
+ } else if (maxLength > 0) {
+ context.getString(R.string.maximum_n_chars, maxLength)
+ } else {
+ context.getString(R.string.maximum_n_chars, 254)
+ }
+ }
+
+ init {
+ scope.launch {
+ value.drop(1).collect { newValue ->
+ updateValidation(newValue)
+ }
+ }
+ scope.launch {
+ isRequired.drop(1).collect {
+ updateValidation(value.value)
+ }
+ }
+ scope.launch {
+ isFocused.drop(1).collect {
+ if (it) {
+ hasBeenFocused = true
+ }
+ updateValidation(value.value)
+ }
+ }
+ }
+
+ private fun updateValidation(value: String) {
+ val errors = validate(value)
+ val errorToDisplay = errorMessageToDisplay(value, errors)
+ _supportingTextIsErrorMessage.value = errorToDisplay != NoError
+ _supportingText.value = if (errorToDisplay != NoError) {
+ errorMessages[errorToDisplay] ?: throw IllegalStateException("validation error must have a message")
+ } else {
+ description.ifEmpty {
+ if (_isFocused.value && isEditable.value) {
+ helperText
+ } else {
+ ""
+ }
+ }
+ }
+ _hasError.value = errors.isNotEmpty()
+ }
+
+ private fun validateTextRange(value: String): ValidationErrorState =
+ if (value.length !in minLength..maxLength) {
+ if (minLength > 0 && maxLength > 0) {
+ if (minLength == maxLength) {
+ ExactCharConstraint
+ } else {
+ MinMaxCharConstraint
+ }
+ } else {
+ MaxCharConstraint
+ }
+ } else {
+ NoError
+ }
+
+ private fun validateNumber(value: String): ValidationErrorState =
+ if (fieldType.isIntegerType) {
+ val numberVal = value.toIntOrNull()
+ if (numberVal == null) {
+ NotAWholeNumber
+ } else {
+ validateNumericRange(numberVal)
+ }
+ } else {
+ val numberVal = value.toDoubleOrNull()
+ if (numberVal == null) {
+ NotANumber
+ } else {
+ validateNumericRange(numberVal)
+ }
+ }
+
+ private fun validateNumericRange(numberVal: Int): ValidationErrorState {
+ require(fieldType.isIntegerType)
+ return if (domain != null && domain is RangeDomain) {
+ val (min, max) = domain.asLongTuple
+ if (min != null && max != null) {
+ if (numberVal in min..max) {
+ NoError
+ } else {
+ MinMaxNumericConstraint
+ }
+ } else if (min != null) {
+ if (min <= numberVal) {
+ NoError
+ } else {
+ MinNumericConstraint
+ }
+ } else if (max != null) {
+ if (numberVal <= max) {
+ NoError
+ } else {
+ MaxNumericConstraint
+ }
+ } else {
+ NoError
+ }
+ } else {
+ NoError
+ }
+ }
+
+ private fun validateNumericRange(numberVal: Double): ValidationErrorState {
+ require(fieldType.isFloatingPoint)
+ return if (domain != null && domain is RangeDomain) {
+ val (min, max) = domain.asDoubleTuple
+ if (min != null && max != null) {
+ if (numberVal in min..max) {
+ NoError
+ } else {
+ MinMaxNumericConstraint
+ }
+ } else if (min != null) {
+ if (min <= numberVal) {
+ NoError
+ } else {
+ MinNumericConstraint
+ }
+ } else if (max != null) {
+ if (numberVal <= max) {
+ NoError
+ } else {
+ MaxNumericConstraint
+ }
+ } else {
+ NoError
+ }
+ } else {
+ NoError
+ }
+ }
+
+ private fun errorMessageToDisplay(
+ value: String,
+ validationErrors: List
+ ): ValidationErrorState =
+ if (validationErrors.isEmpty() || !isEditable.value) {
+ NoError
+ } else if (isFocused.value) {
+ if (value.isEmpty()) {
+ // if focused and empty, don't show the "Required" error or numeric parse errors
+ validationErrors.firstOrNull { it != Required && it != NotANumber && it != NotAWholeNumber } ?: NoError
+ } else {
+ // if non empty, focused, show any error other than required (the Required error shouldn't be in the list)
+ check (!validationErrors.contains(Required))
+ validationErrors.first()
+ }
+ } else if (hasBeenFocused) {
+ if (value.isEmpty()) {
+ if (validationErrors.contains(Required)) {
+ // show any non required and non parse error before showing a Required error
+ validationErrors.firstOrNull { it != Required && it != NotANumber && it != NotAWholeNumber } ?: Required
+ } else {
+ // don't show parse errors when empty and not required (and when required, show required as above)
+ validationErrors.firstOrNull { it != NotANumber && it != NotAWholeNumber } ?: NoError
+ }
+ } else {
+ // if non empty, unfocused, show any error other than required (the Required error shouldn't be in the list)
+ check (!validationErrors.contains(Required))
+ validationErrors.first()
+ }
+ } else {
+ // never been focused
+ NoError
+ }
+
+ private fun validate(value: String): MutableList {
+ val ret = mutableListOf()
+ if (isRequired.value && value.isEmpty()) {
+ ret += Required
+ }
+
+ if (!fieldType.isNumeric) {
+ val rangeError = validateTextRange(value)
+ if (rangeError != NoError) {
+ ret += rangeError
+ }
+ } else {
+ val error = validateNumber(value)
+ if (error != NoError) {
+ ret += error
+ }
+ }
+
+ return ret
+ }
+
+ fun onFocusChanged(focus: Boolean) {
+ _isFocused.value = focus
+ }
+
+ companion object {
+ fun Saver(
+ formElement: FieldFormElement,
+ form: FeatureForm,
+ context: Context,
+ scope: CoroutineScope
+ ): Saver = listSaver(
+ save = {
+ listOf(
+ it.value.value,
+ it.hasBeenFocused
+ )
+ },
+ restore = { list ->
+ val minLength = (formElement.input as? TextBoxFormInput)?.minLength ?: (formElement.input as TextAreaFormInput).minLength
+ val maxLength = (formElement.input as? TextBoxFormInput)?.maxLength ?: (formElement.input as TextAreaFormInput).maxLength
+ FormTextFieldState(
+ properties = TextFieldProperties(
+ label = formElement.label,
+ placeholder = formElement.hint,
+ description = formElement.description,
+ value = formElement.valueFlow(scope),
+ required = formElement.isRequired,
+ editable = formElement.isEditable,
+ visible = formElement.isVisible,
+ domain = form.domain(formElement) as? RangeDomain,
+ fieldType = form.fieldType(formElement),
+ singleLine = formElement.input is TextBoxFormInput,
+ minLength = minLength.toInt(),
+ maxLength = maxLength.toInt()
+ ),
+ initialValue = list[0] as String,
+ scope = scope,
+ context = context,
+ onEditValue = { newValue ->
+ form.editValue(formElement, newValue)
+ scope.launch { form.evaluateExpressions() }
+ },
+ ).apply {
+ // focus is lost on rotation. https://devtopia.esri.com/runtime/apollo/issues/230
+ hasBeenFocused = list[1] as Boolean
+ updateValidation(list[0] as String)
+ }
+ }
+ )
+ }
+}
+
+@Composable
+internal fun rememberFormTextFieldState(
+ field: FieldFormElement,
+ minLength: Int,
+ maxLength: Int,
+ form: FeatureForm,
+ context: Context,
+ scope: CoroutineScope
+): FormTextFieldState = rememberSaveable(
+ saver = FormTextFieldState.Saver(field, form, context, scope)
+) {
+ FormTextFieldState(
+ properties = TextFieldProperties(
+ label = field.label,
+ placeholder = field.hint,
+ description = field.description,
+ value = field.valueFlow(scope),
+ editable = field.isEditable,
+ required = field.isRequired,
+ visible = field.isVisible,
+ singleLine = field.input is TextBoxFormInput,
+ fieldType = form.fieldType(field),
+ domain = form.domain(field) as? RangeDomain,
+ minLength = minLength,
+ maxLength = maxLength,
+ ),
+ scope = scope,
+ context = context,
+ onEditValue = {
+ form.editValue(field, it)
+ scope.launch { form.evaluateExpressions() }
+ }
+ )
+}
+
+private sealed class ValidationErrorState {
+ object NoError: ValidationErrorState()
+ object Required: ValidationErrorState()
+ object MinMaxCharConstraint: ValidationErrorState()
+ object ExactCharConstraint: ValidationErrorState()
+ object MaxCharConstraint: ValidationErrorState()
+ object MinNumericConstraint: ValidationErrorState()
+ object MaxNumericConstraint: ValidationErrorState()
+ object MinMaxNumericConstraint: ValidationErrorState()
+ object NotANumber: ValidationErrorState()
+ object NotAWholeNumber: ValidationErrorState()
+}
+
+/**
+ * Provide a format string for any numeric type.
+ *
+ * @param digits: If the number is floating point, restricts the decimal digits
+ * @return a formatted string representing the number.
+ */
+private fun Number.format(digits: Int = 2): String =
+ when (this) {
+ is Double -> "%.${digits}f".format(this)
+ is Float -> "%.${digits}f".format(this)
+ else -> "$this"
+ }
diff --git a/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/utils/CorePrototypes.kt b/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/utils/CorePrototypes.kt
new file mode 100644
index 000000000..9f7f3d1c1
--- /dev/null
+++ b/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/utils/CorePrototypes.kt
@@ -0,0 +1,221 @@
+/*
+ * COPYRIGHT 1995-2023 ESRI
+ *
+ * TRADE SECRETS: ESRI PROPRIETARY AND CONFIDENTIAL
+ * Unpublished material - all rights reserved under the
+ * Copyright Laws of the United States.
+ *
+ * For additional information, contact:
+ * Environmental Systems Research Institute, Inc.
+ * Attn: Contracts Dept
+ * 380 New York Street
+ * Redlands, California, USA 92373
+ *
+ * email: contracts@esri.com
+ */
+
+package com.arcgismaps.toolkit.featureforms.utils
+
+import android.util.Log
+import com.arcgismaps.data.Domain
+import com.arcgismaps.data.FieldType
+import com.arcgismaps.data.RangeDomain
+import com.arcgismaps.mapping.featureforms.FeatureForm
+import com.arcgismaps.mapping.featureforms.FieldFormElement
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import java.time.Instant
+import kotlin.math.roundToInt
+import kotlin.math.roundToLong
+
+/**
+ * This file contains logic which will eventually be provided by core. Do not add anything to this file that isn't
+ * scheduled for core implementation. This entire file will be removed before the 200.3.0 release.
+ */
+
+internal fun FeatureForm.fieldIsNullable(element: FieldFormElement): Boolean {
+ val isNullable = feature.featureTable?.getField(element.fieldName)?.nullable
+ require(isNullable != null) {
+ "expected feature table to have field with name ${element.fieldName}"
+ }
+ return isNullable
+}
+
+/**
+ * Set the value in the feature's attribute map. This call can only be made when a transaction is open.
+ * Catches and swallows exceptions. Remove this function when [FieldFormElement.updateValue] no longer throws.
+ *
+ * @param value the value to be set on the attribute represented by this FieldFormElement.
+ */
+internal fun FeatureForm.editValue(element: FieldFormElement, value: Any?) {
+ try {
+ element.updateValue(cast(value, fieldType(element)))
+ } catch (e: Exception) {
+ //TODO: remove before release. (and also the try catch)
+ Log.w(
+ "Form.editValue", "caught ${e.message} while updating value of field ${element.label} to $value"
+ )
+ }
+}
+
+internal fun FeatureForm.fieldType(element: FieldFormElement): FieldType {
+ val fieldType = feature.featureTable?.getField(element.fieldName)?.fieldType
+ require(fieldType != null) {
+ "expected feature table to have field with name ${element.fieldName}"
+ }
+ return fieldType
+}
+
+internal fun FeatureForm.domain(element: FieldFormElement): Domain? =
+ feature.featureTable?.getField(element.fieldName)?.domain
+
+internal inline fun FieldFormElement.valueFlow(scope: CoroutineScope): StateFlow =
+ if (value.value is T) {
+ value.map { it as T }.stateIn(scope, SharingStarted.Eagerly, value.value as T)
+ } else if (formattedValue is T) {
+ // T is String
+ value.map { formattedValue as T }.stateIn(scope, SharingStarted.Eagerly, formattedValue as T)
+ } else {
+ // usage error.
+ throw IllegalStateException("the generic parameterization of the state object must match either the value or the formattedValue.")
+ }
+
+
+internal val FieldType.isNumeric: Boolean
+ get() {
+ return isFloatingPoint || isIntegerType
+ }
+
+internal val FieldType.isFloatingPoint: Boolean
+ get() {
+ return when (this) {
+ FieldType.Float32 -> true
+ FieldType.Float64 -> true
+ else -> false
+ }
+ }
+
+internal val FieldType.isIntegerType: Boolean
+ get() {
+ return when (this) {
+ FieldType.Int16 -> true
+ FieldType.Int32 -> true
+ FieldType.Int64 -> true
+ else -> false
+ }
+ }
+
+/**
+ * cast the min and max values to the type indicated by the RangeDomain.fieldType
+ * Then return those values as a tuple of Doubles.
+ */
+
+internal val RangeDomain.asDoubleTuple: MinMax
+ get() {
+ return when (fieldType) {
+ FieldType.Int16 -> {
+ MinMax((minValue as? Int)?.toDouble(), (maxValue as? Int)?.toDouble())
+ }
+ FieldType.Int32 -> {
+ MinMax((minValue as? Int)?.toDouble(), (maxValue as? Int)?.toDouble())
+ }
+ FieldType.Int64 -> {
+ MinMax((minValue as? Long)?.toDouble(), (maxValue as? Long)?.toDouble())
+ }
+ FieldType.Float32 -> {
+ MinMax((minValue as? Float)?.toDouble(), (maxValue as? Float)?.toDouble())
+ }
+ FieldType.Float64 -> {
+ MinMax(minValue as? Double, maxValue as? Double)
+ }
+ else -> throw IllegalArgumentException("RangeDomain must have a numeric field type")
+ }
+}
+
+/**
+ * cast the min and max values to the type indicated by the RangeDomain.fieldType
+ * Then return those values as a tuple of Longs.
+ */
+internal val RangeDomain.asLongTuple: MinMax
+ get() {
+ return when (fieldType) {
+ FieldType.Int16 -> {
+ MinMax((minValue as? Int)?.toLong(), (maxValue as? Int)?.toLong())
+ }
+ FieldType.Int32 -> {
+ MinMax((minValue as? Int)?.toLong(), (maxValue as? Int)?.toLong())
+ }
+ FieldType.Int64 -> {
+ MinMax(minValue as? Long, maxValue as? Long)
+ }
+ FieldType.Float32 -> {
+ MinMax((minValue as? Float)?.toLong(), (maxValue as? Float)?.toLong())
+ }
+ FieldType.Float64 -> {
+ MinMax((minValue as? Double)?.toLong(), (maxValue as? Double)?.toLong())
+ }
+ else -> throw IllegalArgumentException("RangeDomain must have a numeric field type")
+ }
+ }
+
+internal data class MinMax(val min: T?, val max: T?)
+
+private fun cast(value: Any?, fieldType: FieldType): Any? =
+ when (fieldType) {
+ FieldType.Int16 -> {
+ when (value) {
+ is String -> value.toIntOrNull()?.toShort()
+ is Int -> value.toShort()
+ is Double -> value.roundToInt().toShort()
+ else -> null
+ }
+ }
+ FieldType.Int32 -> {
+ when (value) {
+ is String -> value.toIntOrNull()
+ is Int -> value
+ is Double -> value.roundToInt()
+ else -> null
+ }
+ }
+ FieldType.Int64 -> {
+ when (value) {
+ is String -> value.toLongOrNull()
+ is Int -> value.toLong()
+ is Double -> value.roundToLong()
+ else -> null
+ }
+ }
+ FieldType.Float32 -> {
+ when (value) {
+ is String -> value.toFloatOrNull()
+ is Int -> value.toFloat()
+ is Double -> value.toFloat()
+ else -> null
+ }
+ }
+ FieldType.Float64 -> {
+ when (value) {
+ is String -> value.toDoubleOrNull()
+ is Int -> value.toDouble()
+ is Float -> value.toDouble()
+ is Double -> value.toDouble()
+ else -> null
+ }
+ }
+ FieldType.Date -> {
+ when (value) {
+ is String -> value.toLongOrNull()?.let { Instant.ofEpochMilli(it) }
+ is Long -> Instant.ofEpochMilli(value)
+ is Instant -> value
+ else -> null
+ }
+ }
+ FieldType.Text -> {
+ value?.toString()
+ }
+ else -> throw IllegalArgumentException("casting FieldFormElement value to $fieldType is not allowed")
+ }
diff --git a/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/utils/Dialog.kt b/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/utils/Dialog.kt
new file mode 100644
index 000000000..a16f33d0b
--- /dev/null
+++ b/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/utils/Dialog.kt
@@ -0,0 +1,156 @@
+/*
+ * Copyright 2023 Esri
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.arcgismaps.toolkit.featureforms.utils
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.remember
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.KeyboardType
+import com.arcgismaps.toolkit.featureforms.R
+import com.arcgismaps.toolkit.featureforms.components.base.BaseFieldState
+import com.arcgismaps.toolkit.featureforms.components.codedvalue.CodedValueFieldState
+import com.arcgismaps.toolkit.featureforms.components.codedvalue.ComboBoxDialog
+import com.arcgismaps.toolkit.featureforms.components.datetime.DateTimeFieldState
+import com.arcgismaps.toolkit.featureforms.components.datetime.picker.DateTimePicker
+import com.arcgismaps.toolkit.featureforms.components.datetime.picker.DateTimePickerInput
+import com.arcgismaps.toolkit.featureforms.components.datetime.picker.DateTimePickerStyle
+import com.arcgismaps.toolkit.featureforms.components.datetime.picker.rememberDateTimePickerState
+import java.io.Serializable
+import java.time.Instant
+
+/**
+ * Specifies the type of dialog to use for a [FeatureFormDialog].
+ */
+internal sealed class DialogType : Serializable {
+ /**
+ * Indicates no dialog should be shown.
+ */
+ object NoDialog : DialogType()
+
+ /**
+ * Indicates a [DatePickerDialog] should be shown.
+ *
+ * @param stateKey the key for a [DateTimeFieldState].
+ */
+ data class DatePickerDialog(val stateKey: Int) : DialogType()
+
+ /**
+ * Indicates a [ComboBoxDialog]
+ *
+ * @param stateKey the key for a [CodedValueFieldState].
+ */
+ data class ComboBoxDialog(val stateKey: Int) : DialogType()
+
+ /**
+ * Returns the key for this dialog type state. Null is returned if its a [NoDialog].
+ */
+ fun getStateKey(): Int? {
+ return when (this) {
+ is DatePickerDialog -> {
+ stateKey
+ }
+
+ is ComboBoxDialog -> {
+ stateKey
+ }
+
+ is NoDialog -> {
+ null
+ }
+ }
+ }
+}
+
+/**
+ * A dialog container for different field types.
+ *
+ * @param dialogType the [DialogType] to show.
+ * @param state the [BaseFieldState] associated with the field element. This will be cast to its
+ * appropriate subtype based on the [dialogType].
+ * @param onDismissRequest a callback invoked to dismiss the dialog when the user clicks outside
+ * the dialog or on the back button.
+ */
+@Composable
+internal fun FeatureFormDialog(
+ dialogType: DialogType,
+ state: BaseFieldState<*>?,
+ onDismissRequest: () -> Unit
+) {
+ when (dialogType) {
+ is DialogType.NoDialog -> {
+ /* show nothing */
+ }
+
+ is DialogType.DatePickerDialog -> {
+ if (state is DateTimeFieldState) {
+ val shouldShowTime = remember {
+ state.shouldShowTime
+ }
+ val pickerStyle = if (shouldShowTime) {
+ DateTimePickerStyle.DateTime
+ } else {
+ DateTimePickerStyle.Date
+ }
+ val pickerState = rememberDateTimePickerState(
+ pickerStyle,
+ state.minEpochMillis,
+ state.maxEpochMillis,
+ state.value.collectAsState().value,
+ state.label,
+ state.description,
+ DateTimePickerInput.Date
+ )
+ // the picker dialog
+ DateTimePicker(
+ state = pickerState,
+ onDismissRequest = onDismissRequest,
+ onCancelled = onDismissRequest,
+ onConfirmed = {
+ state.onValueChanged(pickerState.selectedDateTimeMillis?.let {
+ Instant.ofEpochMilli(it)
+ })
+ onDismissRequest()
+ }
+ )
+ }
+ }
+
+ is DialogType.ComboBoxDialog -> {
+ if (state is CodedValueFieldState) {
+ ComboBoxDialog(
+ initialValue = state.value.collectAsState().value,
+ values = state.codedValues.associateBy({ it.code }, { it.name }),
+ label = state.label,
+ description = state.description,
+ isRequired = state.isRequired.collectAsState().value,
+ noValueOption = state.showNoValueOption,
+ keyboardType = if (state.fieldType.isNumeric) {
+ KeyboardType.Number
+ } else {
+ KeyboardType.Ascii
+ },
+ noValueLabel = state.noValueLabel.ifEmpty { stringResource(R.string.no_value) },
+ onValueChange = { nameOrEmpty ->
+ state.onValueChanged(nameOrEmpty)
+ },
+ onDismissRequest = onDismissRequest
+ )
+ }
+ }
+ }
+}
diff --git a/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/utils/Utils.kt b/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/utils/Utils.kt
new file mode 100644
index 000000000..5076f5631
--- /dev/null
+++ b/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/utils/Utils.kt
@@ -0,0 +1,46 @@
+package com.arcgismaps.toolkit.featureforms.utils
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.ui.ExperimentalComposeUiApi
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.input.OffsetMapping
+import androidx.compose.ui.text.input.TransformedText
+import androidx.compose.ui.text.input.VisualTransformation
+
+@OptIn(ExperimentalComposeUiApi::class)
+@Composable
+internal fun ClearFocus(key: Boolean, onComplete: () -> Unit = {}) {
+ val focusManager = LocalFocusManager.current
+ val keyboardController = LocalSoftwareKeyboardController.current
+ DisposableEffect(key) {
+ if (key) {
+ // hides the keyboard only if visible
+ keyboardController?.hide()
+ focusManager.clearFocus()
+ }
+ onDispose {
+ onComplete()
+ }
+ }
+}
+
+
+/**
+ * Changes the visual output of the placeholder and label properties of a TextField. Using this
+ * transformation, the placeholder is always visible even if empty and puts the label above the
+ * TextField as it's default position.
+ */
+internal class PlaceholderTransformation(private val placeholder: String) : VisualTransformation {
+
+ private val mapping = object : OffsetMapping {
+ override fun originalToTransformed(offset: Int): Int = 0
+ override fun transformedToOriginal(offset: Int): Int = 0
+ }
+
+ override fun filter(text: AnnotatedString): TransformedText {
+ return TransformedText(AnnotatedString(placeholder), mapping)
+ }
+}
diff --git a/toolkit/featureforms/src/main/res/values/strings.xml b/toolkit/featureforms/src/main/res/values/strings.xml
new file mode 100644
index 000000000..2a6da9497
--- /dev/null
+++ b/toolkit/featureforms/src/main/res/values/strings.xml
@@ -0,0 +1,37 @@
+
+
+
+ Enter %1$d characters
+ Enter %1$d to %2$d characters
+ Maximum %1$d characters
+ No value
+ Enter Value
+ Required
+ Today
+ Now
+ Cancel
+ OK
+ Done
+ Filter %1$s
+ Enter value from %1$s to %2$s
+ Less than minimum value %1$s
+ Exceeds maximum value %1$s
+ Value must be a whole number
+ Value must be a number
+
diff --git a/toolkit/featureforms/src/test/java/com/arcgismaps/toolkit/featureforms/ExampleUnitTest.kt b/toolkit/featureforms/src/test/java/com/arcgismaps/toolkit/featureforms/ExampleUnitTest.kt
new file mode 100644
index 000000000..782ca45c0
--- /dev/null
+++ b/toolkit/featureforms/src/test/java/com/arcgismaps/toolkit/featureforms/ExampleUnitTest.kt
@@ -0,0 +1,17 @@
+package com.arcgismaps.toolkit.featureforms
+
+import org.junit.Test
+
+import org.junit.Assert.*
+
+/**
+ * Example local unit test, which will execute on the development machine (host).
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+class ExampleUnitTest {
+ @Test
+ fun addition_isCorrect() {
+ assertEquals(4, 2 + 2)
+ }
+}