diff --git a/.gitignore b/.gitignore index 3800acc3f3..5dd29578f6 100644 --- a/.gitignore +++ b/.gitignore @@ -45,6 +45,7 @@ captures/ .classpath .project .settings +.vscode # Keystore files # Uncomment the following lines if you do not want to check your keystore files in. @@ -101,4 +102,6 @@ docs/use/api/*/** # Synthea synthea -.vscode + ### Kotlin/JS + kotlin-js-store/ + node_modules/ diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 28d4ad0b9f..ff2b065c1f 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -1,3 +1,7 @@ +import org.gradle.api.artifacts.dsl.DependencyHandler +import org.gradle.plugin.use.PluginDependency +import org.gradle.api.provider.Provider + plugins { `kotlin-dsl` } @@ -9,15 +13,21 @@ repositories { } dependencies { - implementation("com.diffplug.spotless:spotless-plugin-gradle:6.22.0") - - implementation("com.android.tools.build:gradle:8.9.2") - - implementation("app.cash.licensee:licensee-gradle-plugin:1.8.0") - implementation("com.osacky.flank.gradle:fladle:0.17.4") - - implementation("com.spotify.ruler:ruler-gradle-plugin:1.4.0") - - implementation("ca.uhn.hapi.fhir:hapi-fhir-structures-r4:6.10.0") - implementation("com.squareup:kotlinpoet:1.17.0") + implementation(libs.spotless.plugin.gradle) + implementation(libs.gradle) + implementation(libs.licensee.gradle.plugin) + implementation(libs.fladle) + implementation(libs.ruler.gradle.plugin) + implementation(libs.hapi.fhir.structures.r4.v6100) + implementation(libs.kotlinpoet) + implementation(plugin(libs.plugins.android.kotlin.multiplatform.library)) + implementation(plugin(libs.plugins.compose.compiler)) + implementation(plugin(libs.plugins.compose.hotreload)) + implementation(plugin(libs.plugins.compose.multiplatform)) + implementation(plugin(libs.plugins.kotlin.multiplatform)) + implementation(plugin(libs.plugins.android.application.build.src)) + implementation(plugin(libs.plugins.kotlin.serialization.build.src)) } + +fun DependencyHandler.plugin(plugin: Provider) = + plugin.map { "${it.pluginId}:${it.pluginId}.gradle.plugin:${it.version}" } diff --git a/buildSrc/settings.gradle.kts b/buildSrc/settings.gradle.kts new file mode 100644 index 0000000000..215a5d58e7 --- /dev/null +++ b/buildSrc/settings.gradle.kts @@ -0,0 +1,7 @@ +dependencyResolutionManagement { + versionCatalogs { + create("libs") { + from(files("../gradle/libs.versions.toml")) + } + } +} diff --git a/buildSrc/src/main/kotlin/Plugins.kt b/buildSrc/src/main/kotlin/Plugins.kt index 37fa94553e..f1ab29f39a 100644 --- a/buildSrc/src/main/kotlin/Plugins.kt +++ b/buildSrc/src/main/kotlin/Plugins.kt @@ -40,7 +40,7 @@ object Plugins { object Versions { const val androidGradlePlugin = "8.9.2" const val benchmarkPlugin = "1.4.0-rc01" - const val kspPlugin = "2.1.20-2.0.1" - const val kotlin = "2.1.20" + const val kspPlugin = "2.2.20-2.0.4" + const val kotlin = "2.2.20" } } diff --git a/buildSrc/src/main/kotlin/codegen/SearchParameterRepositoryGenerator.kt b/buildSrc/src/main/kotlin/codegen/SearchParameterRepositoryGenerator.kt index 93bb408661..4fe2b6b47a 100644 --- a/buildSrc/src/main/kotlin/codegen/SearchParameterRepositoryGenerator.kt +++ b/buildSrc/src/main/kotlin/codegen/SearchParameterRepositoryGenerator.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023 Google LLC + * Copyright 2023-2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -81,7 +81,7 @@ internal object SearchParameterRepositoryGenerator { SearchParamDefinition( className = searchParamDefinitionClass, name = searchParameter.name, - paramTypeCode = searchParameter.type.toCode().toUpperCase(Locale.US), + paramTypeCode = searchParameter.type.toCode().uppercase(Locale.US), path = path.value, ), ) @@ -90,7 +90,7 @@ internal object SearchParameterRepositoryGenerator { SearchParamDefinition( className = searchParamDefinitionClass, name = searchParameter.name, - paramTypeCode = searchParameter.type.toCode().toUpperCase(Locale.US), + paramTypeCode = searchParameter.type.toCode().uppercase(Locale.US), path = path.value, ), ) diff --git a/datacapture-kmp/.gitignore b/datacapture-kmp/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/datacapture-kmp/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/datacapture-kmp/build.gradle.kts b/datacapture-kmp/build.gradle.kts new file mode 100644 index 0000000000..32d5a71632 --- /dev/null +++ b/datacapture-kmp/build.gradle.kts @@ -0,0 +1,187 @@ +@file:OptIn(ExperimentalWasmDsl::class) + +import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi +import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl + +plugins { + id("org.jetbrains.kotlin.multiplatform") + id("com.android.kotlin.multiplatform.library") + id("org.jetbrains.kotlin.plugin.compose") + id("org.jetbrains.compose.hot-reload") + id("org.jetbrains.compose") + alias(libs.plugins.ksp) +} + +kotlin { + jvmToolchain(11) + + androidLibrary { + namespace = "com.google.android.fhir.datacapture" + compileSdk = 36 + minSdk = 24 + withJava() + withHostTestBuilder {} + withDeviceTestBuilder { sourceSetTreeName = "test" } + .configure { instrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } + + experimentalProperties["android.experimental.kmp.enableAndroidResources"] = true + + compilations.configureEach { + compilerOptions.configure { + jvmTarget.set( + org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11, + ) + } + } + packaging { + resources.excludes.addAll( + listOf("META-INF/ASL2.0", "META-INF/ASL-2.0.txt", "META-INF/LGPL-3.0.txt"), + ) + } + } + + // For iOS targets, this is also where you should + // configure native binary output. For more information, see: + // https://kotlinlang.org/docs/multiplatform-build-native-binaries.html#build-xcframeworks + + // A step-by-step guide on how to include this library in an XCode + // project can be found here: + // https://developer.android.com/kotlin/multiplatform/migrate + val xcfName = "sharedKit" + + iosX64 { binaries.framework { baseName = xcfName } } + + iosArm64 { binaries.framework { baseName = xcfName } } + + iosSimulatorArm64 { binaries.framework { baseName = xcfName } } + + wasmJs { + browser() + binaries.library() + } + + jvm("desktop") + + js { + browser() + binaries.library() + } + + // Source set declarations. + // Declaring a target automatically creates a source set with the same name. By default, the + // Kotlin Gradle Plugin creates additional source sets that depend on each other, since it is + // common to share sources between related targets. + // See: https://kotlinlang.org/docs/multiplatform-hierarchy.html + sourceSets { + all { + languageSettings { + @OptIn(ExperimentalKotlinGradlePluginApi::class) + compilerOptions { freeCompilerArgs.add("-Xexpect-actual-classes") } + } + } + + commonMain { + dependencies { + implementation(libs.material.icons.extended) + implementation(compose.runtime) + implementation(compose.foundation) + implementation(compose.material3) + implementation(compose.ui) + implementation(compose.components.resources) + implementation(compose.components.uiToolingPreview) + implementation(libs.navigation.compose) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.kermit) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlin.fhir) + implementation(libs.kotlinx.io.core) + implementation(libs.kotlinx.serialization.json) + } + } + + commonTest { dependencies { implementation(libs.kotlin.test) } } + + androidMain { + resources.srcDir("res") + dependencies { + + // Add Android-specific dependencies here. Note that this source set depends on + // commonMain by default and will correctly pull the Android artifacts of any KMP + // dependencies declared in commonMain. + // api(libs.hapi.fhir.structures.r4) + implementation(libs.accompanist.themeadapter.material3) + // implementation(libs.android.fhir.common) + implementation(libs.androidx.appcompat) + implementation(libs.androidx.constraintlayout) + implementation(libs.androidx.core) + implementation(libs.androidx.fragment) + implementation(libs.material) + // implementation(libs.hapi.fhir.caching.guava) + /* implementation(libs.hapi.fhir.validation) { + exclude(module = "commons-logging") + exclude(module = "httpclient") + }*/ + implementation(libs.kotlinx.coroutines.core) + implementation(libs.timber) + implementation(libs.glide) + /* + constraints { + Dependencies.hapiFhirConstraints().forEach { (libName, constraints) -> + api(libName, constraints) + implementation(libName, constraints) + } + }*/ + } + } + + getByName("androidDeviceTest") { + dependencies { + implementation(libs.androidx.test.runner) + implementation(libs.androidx.test.core) + implementation(libs.androidx.test.ext.junit) + implementation(libs.androidx.compose.ui.test.junit4) + implementation(libs.androidx.test.core) + /* implementation(libs.androidx.test.espresso.contrib) { + // build fails with error "Duplicate class found" (org.checkerframework.checker.*) + exclude(group = "org.checkerframework", module = "checker") + }*/ + implementation(libs.androidx.test.espresso.core) + implementation(libs.androidx.test.ext.junit) + implementation(libs.androidx.test.ext.junit.ktx) + implementation(libs.androidx.test.rules) + implementation(libs.androidx.test.runner) + implementation(libs.junit) + implementation(libs.kotlinx.coroutines.test) + implementation(libs.truth) + } + } + + getByName("androidHostTest") { + dependencies { + implementation(libs.androidx.fragment.testing) + implementation(libs.androidx.test.core) + implementation(libs.junit) + implementation(libs.kotlin.test.junit) + implementation(libs.kotlinx.coroutines.test) + implementation(libs.mockito.inline) + implementation(libs.mockito.kotlin) + implementation(libs.robolectric) + implementation(libs.truth) + /* implementation(project(":knowledge")) { + exclude(group = "com.google.android.fhir", module = "engine") + }*/ + } + } + + val desktopMain by getting { + dependencies { + implementation(compose.desktop.currentOs) + // Provide Main Dispatcher for JVM target + implementation(libs.kotlinx.coroutines.swing) + } + } + + iosMain { dependencies {} } + } +} diff --git a/datacapture-kmp/src/androidDeviceTest/kotlin/com/google/android/fhir/datacapture/ExampleInstrumentedTest.kt b/datacapture-kmp/src/androidDeviceTest/kotlin/com/google/android/fhir/datacapture/ExampleInstrumentedTest.kt new file mode 100644 index 0000000000..6cba606762 --- /dev/null +++ b/datacapture-kmp/src/androidDeviceTest/kotlin/com/google/android/fhir/datacapture/ExampleInstrumentedTest.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2025 Google LLC + * + * 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.google.android.fhir.datacapture + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + Assert.assertEquals("com.google.android.fhir.datacapture.test", appContext.packageName) + } +} diff --git a/datacapture-kmp/src/androidHostTest/kotlin/com/google/android/fhir/datacapture/ExampleUnitTest.kt b/datacapture-kmp/src/androidHostTest/kotlin/com/google/android/fhir/datacapture/ExampleUnitTest.kt new file mode 100644 index 0000000000..d1f961b2b5 --- /dev/null +++ b/datacapture-kmp/src/androidHostTest/kotlin/com/google/android/fhir/datacapture/ExampleUnitTest.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2025 Google LLC + * + * 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.google.android.fhir.datacapture + +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * 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) + } +} diff --git a/datacapture-kmp/src/androidMain/kotlin/com/google/android/fhir/datacapture/DataCapture.android.kt b/datacapture-kmp/src/androidMain/kotlin/com/google/android/fhir/datacapture/DataCapture.android.kt new file mode 100644 index 0000000000..6d7d884e43 --- /dev/null +++ b/datacapture-kmp/src/androidMain/kotlin/com/google/android/fhir/datacapture/DataCapture.android.kt @@ -0,0 +1,46 @@ +/* + * Copyright 2025 Google LLC + * + * 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.google.android.fhir.datacapture + +import android.content.Context + +actual object DataCapture { + + private lateinit var configuration: DataCaptureConfig + + fun initialize(context: Context) { + if (!::configuration.isInitialized) { + configuration = + if (context.applicationContext is DataCaptureConfig.Provider) { + (context.applicationContext as DataCaptureConfig.Provider).getDataCaptureConfig() + } else { + DataCaptureConfig() + } + } + return + } + + actual fun getConfiguration(): DataCaptureConfig { + if (this::configuration.isInitialized) { + return configuration + } else { + throw Exception( + "DataCapture not initialized. Initialize the library with DataCapture.initialize(context) ", + ) + } + } +} diff --git a/datacapture-kmp/src/androidMain/kotlin/com/google/android/fhir/datacapture/NumberFormatter.android.kt b/datacapture-kmp/src/androidMain/kotlin/com/google/android/fhir/datacapture/NumberFormatter.android.kt new file mode 100644 index 0000000000..cb765c1afb --- /dev/null +++ b/datacapture-kmp/src/androidMain/kotlin/com/google/android/fhir/datacapture/NumberFormatter.android.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2025 Google LLC + * + * 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.google.android.fhir.datacapture + +import android.icu.number.NumberFormatter +import android.icu.text.DecimalFormat +import android.os.Build +import java.util.Locale + +actual object NumberFormatter { + actual fun formatInteger(value: Int): String { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + NumberFormatter.withLocale(Locale.getDefault()).format(value).toString() + } else { + DecimalFormat.getInstance(Locale.getDefault()).format(value) + } + } +} diff --git a/datacapture-kmp/src/androidMain/kotlin/com/google/android/fhir/datacapture/QuestionnaireCancelDialogFragment.kt b/datacapture-kmp/src/androidMain/kotlin/com/google/android/fhir/datacapture/QuestionnaireCancelDialogFragment.kt new file mode 100644 index 0000000000..03f4e3a8c0 --- /dev/null +++ b/datacapture-kmp/src/androidMain/kotlin/com/google/android/fhir/datacapture/QuestionnaireCancelDialogFragment.kt @@ -0,0 +1,49 @@ +/* + * Copyright 2022-2025 Google LLC + * + * 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.google.android.fhir.datacapture + +import android.app.Dialog +import android.os.Bundle +import androidx.core.os.bundleOf +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.setFragmentResult +import com.google.android.material.dialog.MaterialAlertDialogBuilder + +internal class QuestionnaireCancelDialogFragment : DialogFragment() { + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + return MaterialAlertDialogBuilder(requireContext()) + .setMessage(R.string.questionnaire_cancel_text) + .setPositiveButton(android.R.string.ok) { dialog, _ -> + setFragmentResult(REQUEST_KEY, bundleOf(RESULT_KEY to RESULT_YES)) + dialog?.dismiss() + } + .setNegativeButton(android.R.string.cancel) { dialog, _ -> + setFragmentResult(REQUEST_KEY, bundleOf(RESULT_KEY to RESULT_NO)) + dialog?.dismiss() + } + .create() + } + + companion object { + const val TAG = "QuestionnaireCancelDialogFragment" + const val REQUEST_KEY = "request-key" + const val RESULT_KEY = "result-key" + const val RESULT_NO = "no" + const val RESULT_YES = "yes" + } +} diff --git a/datacapture-kmp/src/androidMain/kotlin/com/google/android/fhir/datacapture/QuestionnaireFragment.kt b/datacapture-kmp/src/androidMain/kotlin/com/google/android/fhir/datacapture/QuestionnaireFragment.kt new file mode 100644 index 0000000000..d2ee7070dd --- /dev/null +++ b/datacapture-kmp/src/androidMain/kotlin/com/google/android/fhir/datacapture/QuestionnaireFragment.kt @@ -0,0 +1,356 @@ +/* + * Copyright 2023-2025 Google LLC + * + * 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.google.android.fhir.datacapture + +import android.content.Context +import android.net.Uri +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.annotation.VisibleForTesting +import androidx.appcompat.view.ContextThemeWrapper +import androidx.compose.ui.platform.ComposeView +import androidx.core.content.res.use +import androidx.core.os.bundleOf +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.fragment.app.setFragmentResult +import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope +import com.google.android.fhir.datacapture.validation.Invalid +import com.google.android.fhir.datacapture.validation.ValidationResult +import com.google.android.fhir.datacapture.views.factories.QuestionnaireItemComposeViewHolderFactory +import com.google.fhir.model.r4.Questionnaire +import kotlinx.coroutines.launch +import timber.log.Timber + +/** + * A [Fragment] for displaying FHIR Questionnaires and getting user responses as FHIR + * QuestionnaireResponses. + * + * For more information, see the + * [QuestionnaireFragment](https://github.com/google/android-fhir/wiki/SDCL%3A-Use-QuestionnaireFragment) + * developer guide. + */ +class QuestionnaireFragment : Fragment() { + private val viewModel: QuestionnaireViewModel by viewModels() + + /** + * Provides a [QuestionnaireItemViewHolderFactoryMatcher]s which are used to evaluate whether a + * custom [QuestionnaireItemComposeViewHolderFactory] should be used to render a given + * questionnaire item. The provider may be provided by the application developer via + * [DataCaptureConfig], otherwise default no-op implementation is used. + */ + @VisibleForTesting + val questionnaireItemViewHolderFactoryMatchersProvider: + QuestionnaireItemViewHolderFactoryMatchersProvider by lazy { + requireArguments().getString(EXTRA_MATCHERS_FACTORY)?.let { factoryKey -> + val provider = + DataCapture.getConfiguration() + .questionnaireItemViewHolderFactoryMatchersProviderFactory + ?.get(factoryKey) + + provider?.let { + object : QuestionnaireItemViewHolderFactoryMatchersProvider() { + override fun get(): List { + return it.get().map { matcher -> + QuestionnaireItemViewHolderFactoryMatcher( + factory = matcher.factory, + matches = matcher.matches, + ) + } + } + } + } + } + ?: EmptyQuestionnaireItemViewHolderFactoryMatchersProviderImpl + } + + /** @suppress */ + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + val themeId = getQuestionnaireThemeId(inflater.context) + val themedContext = ContextThemeWrapper(inflater.context, themeId) + + return ComposeView(themedContext).apply { + setContent { + QuestionnaireScreen( + viewModel = viewModel, + matchersProvider = questionnaireItemViewHolderFactoryMatchersProvider, + ) + } + } + } + + /** @suppress */ + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + DataCapture.initialize(requireContext()) + setupViewModelCallbacks() + setupFragmentResultListeners() + } + + private fun setupViewModelCallbacks() { + viewModel.setOnCancelButtonClickListener { + QuestionnaireCancelDialogFragment() + .show(requireActivity().supportFragmentManager, QuestionnaireCancelDialogFragment.TAG) + } + + viewModel.setOnSubmitButtonClickListener { + lifecycleScope.launch { + viewModel.validateQuestionnaireAndUpdateUI().let { validationMap -> + if (validationMap.values.flatten().filterIsInstance().isEmpty()) { + setFragmentResult(SUBMIT_REQUEST_KEY, Bundle.EMPTY) + } else { + showValidationErrorDialog(validationMap) + } + } + } + } + } + + private fun showValidationErrorDialog(validationMap: Map>) { + val errorViewModel: QuestionnaireValidationErrorViewModel by activityViewModels() + errorViewModel.setQuestionnaireAndValidation(viewModel.questionnaire, validationMap) + val validationErrorMessageDialog = QuestionnaireValidationErrorMessageDialogFragment() + if (requireArguments().containsKey(EXTRA_SHOW_SUBMIT_ANYWAY_BUTTON)) { + validationErrorMessageDialog.arguments = + Bundle().apply { + putBoolean( + EXTRA_SHOW_SUBMIT_ANYWAY_BUTTON, + requireArguments().getBoolean(EXTRA_SHOW_SUBMIT_ANYWAY_BUTTON), + ) + } + } + validationErrorMessageDialog.show( + requireActivity().supportFragmentManager, + QuestionnaireValidationErrorMessageDialogFragment.TAG, + ) + } + + private fun setupFragmentResultListeners() { + requireActivity().supportFragmentManager.setFragmentResultListener( + QuestionnaireValidationErrorMessageDialogFragment.RESULT_CALLBACK, + viewLifecycleOwner, + ) { _, bundle -> + when ( + val result = bundle.getString(QuestionnaireValidationErrorMessageDialogFragment.RESULT_KEY) + ) { + QuestionnaireValidationErrorMessageDialogFragment.RESULT_VALUE_FIX -> { + // Go back to the Edit mode if currently in the Review mode. + viewModel.setReviewMode(false) + } + QuestionnaireValidationErrorMessageDialogFragment.RESULT_VALUE_SUBMIT -> { + setFragmentResult(SUBMIT_REQUEST_KEY, Bundle.EMPTY) + } + else -> Timber.e("Unknown fragment result $result") + } + } + + /** Listen to Button Clicks from the Cancel Dialog */ + requireActivity().supportFragmentManager.setFragmentResultListener( + QuestionnaireCancelDialogFragment.REQUEST_KEY, + viewLifecycleOwner, + ) { _, bundle -> + when (val result = bundle.getString(QuestionnaireCancelDialogFragment.RESULT_KEY)) { + QuestionnaireCancelDialogFragment.RESULT_NO -> { + // Allow the user to continue with the questionnaire + } + QuestionnaireCancelDialogFragment.RESULT_YES -> { + setFragmentResult(CANCEL_REQUEST_KEY, Bundle.EMPTY) + } + else -> Timber.e("Unknown fragment result $result") + } + } + } + + private fun getQuestionnaireThemeId(context: Context): Int { + return context.obtainStyledAttributes(R.styleable.QuestionnaireTheme).use { + it.getResourceId( + R.styleable.QuestionnaireTheme_questionnaire_theme, + R.style.Theme_Questionnaire, + ) + } + } + + /** + * Returns a [QuestionnaireResponse][QuestionnaireResponse] populated with any answers that are + * present on the rendered [QuestionnaireFragment] when it is called. + */ + suspend fun getQuestionnaireResponse() = viewModel.getQuestionnaireResponse() + + fun clearAllAnswers() = viewModel.clearAllAnswers() + + /** Helper to create [QuestionnaireFragment] with appropriate [Bundle] arguments. */ + class Builder { + private val args = mutableListOf>() + + /** + * A JSON encoded string extra for a questionnaire. This should only be used for questionnaires + * with size at most 512KB. For large questionnaires, use `setQuestionnaire(questionnaireUri: + * Uri)`. + * + * This is required unless `setQuestionnaire(questionnaireUri: Uri)` is provided. + * + * If this and `setQuestionnaire(questionnaireUri: Uri)` are provided, + * [setQuestionnaire(questionnaireUri: Uri)] takes precedence. + */ + fun setQuestionnaire(questionnaireJson: String) = apply { + args.add(EXTRA_QUESTIONNAIRE_JSON_STRING to questionnaireJson) + } + + /** + * A [URI][android.net.Uri] extra for streaming a JSON encoded questionnaire. + * + * This is required unless `setQuestionnaire(questionnaireJson: String)` is provided. + * + * If this and `setQuestionnaire(questionnaireJson: String)` are provided, this extra takes + * precedence. + */ + fun setQuestionnaire(questionnaireUri: Uri) = apply { + args.add(EXTRA_QUESTIONNAIRE_JSON_URI to questionnaireUri) + } + + /** + * A JSON encoded string extra for a prefilled questionnaire response. This should only be used + * for questionnaire response with size at most 512KB. For large questionnaire response, use + * `setQuestionnaireResponse(questionnaireResponseUri: Uri)`. + * + * If this and `setQuestionnaireResponse(questionnaireResponseUri: Uri)` are provided, + * `setQuestionnaireResponse(questionnaireResponseUri: Uri)` takes precedence. + */ + fun setQuestionnaireResponse(questionnaireResponseJson: String) = apply { + args.add(EXTRA_QUESTIONNAIRE_RESPONSE_JSON_STRING to questionnaireResponseJson) + } + + /** + * A [URI][android.net.Uri] extra for streaming a JSON encoded questionnaire response. + * + * If this and `setQuestionnaireResponse(questionnaireResponseJson: String)` are provided, this + * extra takes precedence. + */ + fun setQuestionnaireResponse(questionnaireResponseUri: Uri) = apply { + args.add(EXTRA_QUESTIONNAIRE_RESPONSE_JSON_URI to questionnaireResponseUri) + } + + /** + * The launch context allows information to be passed into questionnaire based on the context in + * which the questionnaire is being evaluated. For example, what patient, what encounter, what + * user, etc. is "in context" at the time the questionnaire response is being completed: + * https://build.fhir.org/ig/HL7/sdc/StructureDefinition-sdc-questionnaire-launchContext.html + * + * @param launchContextMap map of launchContext name and serialized resources + */ + fun setQuestionnaireLaunchContextMap(launchContextMap: Map) = apply { + args.add(EXTRA_QUESTIONNAIRE_LAUNCH_CONTEXT_MAP to launchContextMap) + } + + /** + * An [Boolean] extra to control if the questionnaire is read-only. If review page and read-only + * are both enabled, read-only will take precedence. + */ + fun setIsReadOnly(value: Boolean) = apply { args.add(EXTRA_READ_ONLY to value) } + + /** + * A [Boolean] extra to control if a review page is shown. By default it will be shown at the + * end of the questionnaire. + */ + fun showReviewPageBeforeSubmit(value: Boolean) = apply { + args.add(EXTRA_ENABLE_REVIEW_PAGE to value) + } + + /** + * A [Boolean] extra to control if the review page is to be opened first. This has no effect if + * review page is not enabled. + */ + fun showReviewPageFirst(value: Boolean) = apply { + args.add(EXTRA_SHOW_REVIEW_PAGE_FIRST to value) + } + + /** A [Boolean] extra to control whether the asterisk text is shown. */ + fun showAsterisk(value: Boolean) = apply { args.add(EXTRA_SHOW_ASTERISK_TEXT to value) } + + /** A [Boolean] extra to control whether the required text is shown. */ + fun showRequiredText(value: Boolean) = apply { args.add(EXTRA_SHOW_REQUIRED_TEXT to value) } + + /** A [Boolean] extra to control whether the optional text is shown. */ + fun showOptionalText(value: Boolean) = apply { args.add(EXTRA_SHOW_OPTIONAL_TEXT to value) } + + /** + * A matcher to provide [QuestionnaireItemViewHolderFactoryMatcher]s for custom + * [Questionnaire.QuestionnaireItemType]. The application needs to provide a + * [QuestionnaireItemViewHolderFactoryMatchersProviderFactory] in the [DataCaptureConfig] so + * that the [QuestionnaireFragment] can get instance of + * [QuestionnaireItemViewHolderFactoryMatchersProvider]. + */ + fun setCustomQuestionnaireItemViewHolderFactoryMatchersProvider( + matchersProviderFactory: String, + ) = apply { args.add(EXTRA_MATCHERS_FACTORY to matchersProviderFactory) } + + /** + * A [Boolean] extra to show or hide the Submit button in the questionnaire. Default is true. + */ + fun setShowSubmitButton(value: Boolean) = apply { args.add(EXTRA_SHOW_SUBMIT_BUTTON to value) } + + /** To accept a configurable text for the submit button */ + fun setSubmitButtonText(text: String) = apply { args.add(EXTRA_SUBMIT_BUTTON_TEXT to text) } + + /** + * A [Boolean] extra to show or hide the Cancel button in the questionnaire. Default is true. + */ + fun setShowCancelButton(value: Boolean) = apply { args.add(EXTRA_SHOW_CANCEL_BUTTON to value) } + + /** + * A [Boolean] extra to show questionnaire page as a default/long scroll with the + * previous/next/submit buttons anchored to bottom/end of page. Default is false. + */ + fun setShowNavigationInDefaultLongScroll(value: Boolean) = apply { + args.add(EXTRA_SHOW_NAVIGATION_IN_DEFAULT_LONG_SCROLL to value) + } + + /** Setter to show/hide the Submit anyway button. This button is visible by default. */ + fun setShowSubmitAnywayButton(value: Boolean) = apply { + args.add(EXTRA_SHOW_SUBMIT_ANYWAY_BUTTON to value) + } + + @VisibleForTesting fun buildArgs() = bundleOf(*args.toTypedArray()) + + /** @return A [QuestionnaireFragment] with provided [Bundle] arguments. */ + fun build(): QuestionnaireFragment { + return QuestionnaireFragment().apply { arguments = buildArgs() } + } + } + + /** + * Extras that can be passed to [QuestionnaireFragment] to define its behavior. When you create a + * QuestionnaireFragment, one of [EXTRA_QUESTIONNAIRE_JSON_URI] or + * [EXTRA_QUESTIONNAIRE_JSON_STRING] is required. + */ + companion object { + fun builder() = Builder() + } + + /** No-op implementation that provides no custom [QuestionnaireItemViewHolderFactoryMatcher]s . */ + private object EmptyQuestionnaireItemViewHolderFactoryMatchersProviderImpl : + QuestionnaireItemViewHolderFactoryMatchersProvider() { + override fun get() = emptyList() + } +} diff --git a/datacapture-kmp/src/androidMain/kotlin/com/google/android/fhir/datacapture/QuestionnaireValidationErrorMessageDialogFragment.kt b/datacapture-kmp/src/androidMain/kotlin/com/google/android/fhir/datacapture/QuestionnaireValidationErrorMessageDialogFragment.kt new file mode 100644 index 0000000000..8944aba986 --- /dev/null +++ b/datacapture-kmp/src/androidMain/kotlin/com/google/android/fhir/datacapture/QuestionnaireValidationErrorMessageDialogFragment.kt @@ -0,0 +1,154 @@ +/* + * Copyright 2023-2025 Google LLC + * + * 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.google.android.fhir.datacapture + +import android.app.Dialog +import android.os.Bundle +import android.text.Spanned +import android.view.LayoutInflater +import android.view.View +import android.widget.TextView +import androidx.annotation.VisibleForTesting +import androidx.appcompat.view.ContextThemeWrapper +import androidx.compose.ui.text.AnnotatedString +import androidx.core.os.bundleOf +import androidx.core.text.HtmlCompat +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.activityViewModels +import androidx.fragment.app.setFragmentResult +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.google.android.fhir.datacapture.extensions.flattened +import com.google.android.fhir.datacapture.extensions.localizedPrefixAnnotatedString +import com.google.android.fhir.datacapture.extensions.localizedTextAnnotatedString +import com.google.android.fhir.datacapture.validation.Invalid +import com.google.android.fhir.datacapture.validation.ValidationResult +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.fhir.model.r4.Questionnaire + +/** + * [android.app.Dialog] to highlight the required fields that need to be filled by the user before + * submitting the [Questionnaire]. This is shown when user has pressed internal submit button in the + * [com.google.android.fhir.datacapture.QuestionnaireFragment] and there are some validation errors. + */ +internal class QuestionnaireValidationErrorMessageDialogFragment( + /** + * Factory helps with testing and should not be set to anything in the regular production flow. + */ + private val factoryProducer: (() -> ViewModelProvider.Factory)? = null, +) : DialogFragment() { + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + isCancelable = false + val currentDialog = + MaterialAlertDialogBuilder(requireContext()).setView(onCreateCustomView()).setPositiveButton( + R.string.questionnaire_validation_error_fix_button_text, + ) { dialog, _ -> + setFragmentResult(RESULT_CALLBACK, bundleOf(RESULT_KEY to RESULT_VALUE_FIX)) + dialog?.dismiss() + } + if (arguments == null || requireArguments().getBoolean(EXTRA_SHOW_SUBMIT_ANYWAY_BUTTON, true)) { + currentDialog.setNegativeButton(R.string.questionnaire_validation_error_submit_button_text) { + dialog, + _, + -> + setFragmentResult(RESULT_CALLBACK, bundleOf(RESULT_KEY to RESULT_VALUE_SUBMIT)) + dialog?.dismiss() + } + } + return currentDialog.create() + } + + @VisibleForTesting + fun onCreateCustomView(layoutInflater: LayoutInflater = getLayoutInflater()): View { + val themeId = + layoutInflater.context.obtainStyledAttributes(R.styleable.QuestionnaireTheme).use { + it.getResourceId( + // Use the custom questionnaire theme if it is specified + R.styleable.QuestionnaireTheme_questionnaire_theme, + // Otherwise, use the default questionnaire theme + R.style.Theme_Questionnaire, + ) + } + + return layoutInflater + .cloneInContext(ContextThemeWrapper(layoutInflater.context, themeId)) + .inflate(R.layout.questionnaire_validation_dialog, null) + .apply { + findViewById(R.id.body).apply { + val viewModel: QuestionnaireValidationErrorViewModel by + activityViewModels(factoryProducer = factoryProducer) + text = + viewModel + .getItemsTextWithValidationErrors() + .joinToString(separator = "
") { + context.getString(R.string.questionnaire_validation_error_item_text_with_bullet, it) + } + .toSpanned() + } + } + } + + /** Converts Text with HTML Tag to formatted text. */ + internal fun String.toSpanned(): Spanned { + return HtmlCompat.fromHtml(this, HtmlCompat.FROM_HTML_MODE_COMPACT) + } + + companion object { + const val TAG = "QuestionnaireValidationErrorMessageDialogFragment" + const val RESULT_CALLBACK = "QuestionnaireValidationResultCallback" + const val RESULT_KEY = "result" + const val RESULT_VALUE_FIX = "result_fix" + const val RESULT_VALUE_SUBMIT = "result_submit" + + /** + * A [Boolean] extra to show or hide the Submit anyway button in the questionnaire. Default is + * true. + */ + internal const val EXTRA_SHOW_SUBMIT_ANYWAY_BUTTON = "show-submit-anyway-button" + } +} + +internal class QuestionnaireValidationErrorViewModel : ViewModel() { + private var questionnaire: Questionnaire? = null + private var validation: Map>? = null + + fun setQuestionnaireAndValidation( + questionnaire: Questionnaire, + validation: Map>, + ) { + this.questionnaire = questionnaire + this.validation = validation + } + + /** @return Texts associated with the failing [Questionnaire.Item]s. */ + fun getItemsTextWithValidationErrors(): List { + val invalidFields = + validation?.filterValues { it.filterIsInstance().isNotEmpty() } ?: emptyMap() + return questionnaire + ?.item + ?.flattened() + ?.filter { invalidFields.contains(it.linkId.value) } + ?.mapNotNull { + // Use the question text if available, otherwise fall back to the fly-over and then the + // prefix. + it.localizedTextAnnotatedString + ?: it.localizedTextAnnotatedString ?: it.localizedPrefixAnnotatedString + } + ?: emptyList() + } +} diff --git a/datacapture-kmp/src/androidMain/res/animator/bottom_prompt_chip_enter.xml b/datacapture-kmp/src/androidMain/res/animator/bottom_prompt_chip_enter.xml new file mode 100644 index 0000000000..83b2298b69 --- /dev/null +++ b/datacapture-kmp/src/androidMain/res/animator/bottom_prompt_chip_enter.xml @@ -0,0 +1,26 @@ + + + + + + diff --git a/datacapture-kmp/src/androidMain/res/color/box_filled_color.xml b/datacapture-kmp/src/androidMain/res/color/box_filled_color.xml new file mode 100644 index 0000000000..df357107f8 --- /dev/null +++ b/datacapture-kmp/src/androidMain/res/color/box_filled_color.xml @@ -0,0 +1,4 @@ + + + + diff --git a/datacapture-kmp/src/androidMain/res/color/box_stroke_color.xml b/datacapture-kmp/src/androidMain/res/color/box_stroke_color.xml new file mode 100644 index 0000000000..0c1f0e1138 --- /dev/null +++ b/datacapture-kmp/src/androidMain/res/color/box_stroke_color.xml @@ -0,0 +1,16 @@ + + + + + + + + diff --git a/datacapture-kmp/src/androidMain/res/color/checked_background_color.xml b/datacapture-kmp/src/androidMain/res/color/checked_background_color.xml new file mode 100644 index 0000000000..9385d238af --- /dev/null +++ b/datacapture-kmp/src/androidMain/res/color/checked_background_color.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/datacapture-kmp/src/androidMain/res/color/checked_text_color.xml b/datacapture-kmp/src/androidMain/res/color/checked_text_color.xml new file mode 100644 index 0000000000..89ccd743d1 --- /dev/null +++ b/datacapture-kmp/src/androidMain/res/color/checked_text_color.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/datacapture-kmp/src/androidMain/res/color/item_border_color.xml b/datacapture-kmp/src/androidMain/res/color/item_border_color.xml new file mode 100644 index 0000000000..a3decba7b7 --- /dev/null +++ b/datacapture-kmp/src/androidMain/res/color/item_border_color.xml @@ -0,0 +1,4 @@ + + + + diff --git a/datacapture-kmp/src/androidMain/res/color/questionnaire_chip_text_color.xml b/datacapture-kmp/src/androidMain/res/color/questionnaire_chip_text_color.xml new file mode 100644 index 0000000000..a6cde51545 --- /dev/null +++ b/datacapture-kmp/src/androidMain/res/color/questionnaire_chip_text_color.xml @@ -0,0 +1,15 @@ + + + + + + + diff --git a/datacapture-kmp/src/androidMain/res/color/text_color.xml b/datacapture-kmp/src/androidMain/res/color/text_color.xml new file mode 100644 index 0000000000..668824448b --- /dev/null +++ b/datacapture-kmp/src/androidMain/res/color/text_color.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/datacapture-kmp/src/androidMain/res/drawable-ldrtl/checkbox_inset.xml b/datacapture-kmp/src/androidMain/res/drawable-ldrtl/checkbox_inset.xml new file mode 100644 index 0000000000..64a23ce8d3 --- /dev/null +++ b/datacapture-kmp/src/androidMain/res/drawable-ldrtl/checkbox_inset.xml @@ -0,0 +1,6 @@ + + diff --git a/datacapture-kmp/src/androidMain/res/drawable-ldrtl/radio_button_inset.xml b/datacapture-kmp/src/androidMain/res/drawable-ldrtl/radio_button_inset.xml new file mode 100644 index 0000000000..f656d35797 --- /dev/null +++ b/datacapture-kmp/src/androidMain/res/drawable-ldrtl/radio_button_inset.xml @@ -0,0 +1,6 @@ + + diff --git a/datacapture-kmp/src/androidMain/res/drawable/add_24px.xml b/datacapture-kmp/src/androidMain/res/drawable/add_24px.xml new file mode 100644 index 0000000000..697ab3fcc0 --- /dev/null +++ b/datacapture-kmp/src/androidMain/res/drawable/add_24px.xml @@ -0,0 +1,13 @@ + + + diff --git a/datacapture-kmp/src/androidMain/res/drawable/checkbox_inset.xml b/datacapture-kmp/src/androidMain/res/drawable/checkbox_inset.xml new file mode 100644 index 0000000000..39294cc56d --- /dev/null +++ b/datacapture-kmp/src/androidMain/res/drawable/checkbox_inset.xml @@ -0,0 +1,6 @@ + + diff --git a/datacapture-kmp/src/androidMain/res/drawable/delete_24px.xml b/datacapture-kmp/src/androidMain/res/drawable/delete_24px.xml new file mode 100644 index 0000000000..287ae418fb --- /dev/null +++ b/datacapture-kmp/src/androidMain/res/drawable/delete_24px.xml @@ -0,0 +1,13 @@ + + + diff --git a/datacapture-kmp/src/androidMain/res/drawable/error_24px.xml b/datacapture-kmp/src/androidMain/res/drawable/error_24px.xml new file mode 100644 index 0000000000..353db2e7b2 --- /dev/null +++ b/datacapture-kmp/src/androidMain/res/drawable/error_24px.xml @@ -0,0 +1,13 @@ + + + diff --git a/datacapture-kmp/src/androidMain/res/drawable/gm_calendar_today_24.xml b/datacapture-kmp/src/androidMain/res/drawable/gm_calendar_today_24.xml new file mode 100644 index 0000000000..7914164cb6 --- /dev/null +++ b/datacapture-kmp/src/androidMain/res/drawable/gm_calendar_today_24.xml @@ -0,0 +1,28 @@ + + + + diff --git a/datacapture-kmp/src/androidMain/res/drawable/gm_remove_circle_outline_24.xml b/datacapture-kmp/src/androidMain/res/drawable/gm_remove_circle_outline_24.xml new file mode 100644 index 0000000000..58bafbe0ab --- /dev/null +++ b/datacapture-kmp/src/androidMain/res/drawable/gm_remove_circle_outline_24.xml @@ -0,0 +1,28 @@ + + + + diff --git a/datacapture-kmp/src/androidMain/res/drawable/gm_schedule_24.xml b/datacapture-kmp/src/androidMain/res/drawable/gm_schedule_24.xml new file mode 100644 index 0000000000..d0d43aa248 --- /dev/null +++ b/datacapture-kmp/src/androidMain/res/drawable/gm_schedule_24.xml @@ -0,0 +1,28 @@ + + + + diff --git a/datacapture-kmp/src/androidMain/res/drawable/ic_audio_file.xml b/datacapture-kmp/src/androidMain/res/drawable/ic_audio_file.xml new file mode 100644 index 0000000000..814cf59f13 --- /dev/null +++ b/datacapture-kmp/src/androidMain/res/drawable/ic_audio_file.xml @@ -0,0 +1,13 @@ + + + diff --git a/datacapture-kmp/src/androidMain/res/drawable/ic_camera.xml b/datacapture-kmp/src/androidMain/res/drawable/ic_camera.xml new file mode 100644 index 0000000000..458d23130c --- /dev/null +++ b/datacapture-kmp/src/androidMain/res/drawable/ic_camera.xml @@ -0,0 +1,13 @@ + + + diff --git a/datacapture-kmp/src/androidMain/res/drawable/ic_clear.xml b/datacapture-kmp/src/androidMain/res/drawable/ic_clear.xml new file mode 100644 index 0000000000..584a2d08cd --- /dev/null +++ b/datacapture-kmp/src/androidMain/res/drawable/ic_clear.xml @@ -0,0 +1,13 @@ + + + diff --git a/datacapture-kmp/src/androidMain/res/drawable/ic_delete.xml b/datacapture-kmp/src/androidMain/res/drawable/ic_delete.xml new file mode 100644 index 0000000000..0c9971ff48 --- /dev/null +++ b/datacapture-kmp/src/androidMain/res/drawable/ic_delete.xml @@ -0,0 +1,13 @@ + + + diff --git a/datacapture-kmp/src/androidMain/res/drawable/ic_document_file.xml b/datacapture-kmp/src/androidMain/res/drawable/ic_document_file.xml new file mode 100644 index 0000000000..2820f921b7 --- /dev/null +++ b/datacapture-kmp/src/androidMain/res/drawable/ic_document_file.xml @@ -0,0 +1,13 @@ + + + diff --git a/datacapture-kmp/src/androidMain/res/drawable/ic_error_48px.xml b/datacapture-kmp/src/androidMain/res/drawable/ic_error_48px.xml new file mode 100644 index 0000000000..56e092bf4d --- /dev/null +++ b/datacapture-kmp/src/androidMain/res/drawable/ic_error_48px.xml @@ -0,0 +1,14 @@ + + + diff --git a/datacapture-kmp/src/androidMain/res/drawable/ic_file.xml b/datacapture-kmp/src/androidMain/res/drawable/ic_file.xml new file mode 100644 index 0000000000..cf969e23f0 --- /dev/null +++ b/datacapture-kmp/src/androidMain/res/drawable/ic_file.xml @@ -0,0 +1,13 @@ + + + diff --git a/datacapture-kmp/src/androidMain/res/drawable/ic_help_48px.xml b/datacapture-kmp/src/androidMain/res/drawable/ic_help_48px.xml new file mode 100644 index 0000000000..218defa8f6 --- /dev/null +++ b/datacapture-kmp/src/androidMain/res/drawable/ic_help_48px.xml @@ -0,0 +1,13 @@ + + + diff --git a/datacapture-kmp/src/androidMain/res/drawable/ic_image_file.xml b/datacapture-kmp/src/androidMain/res/drawable/ic_image_file.xml new file mode 100644 index 0000000000..00b2eb75a3 --- /dev/null +++ b/datacapture-kmp/src/androidMain/res/drawable/ic_image_file.xml @@ -0,0 +1,13 @@ + + + diff --git a/datacapture-kmp/src/androidMain/res/drawable/ic_outline_edit_24.xml b/datacapture-kmp/src/androidMain/res/drawable/ic_outline_edit_24.xml new file mode 100644 index 0000000000..f478653b16 --- /dev/null +++ b/datacapture-kmp/src/androidMain/res/drawable/ic_outline_edit_24.xml @@ -0,0 +1,13 @@ + + + diff --git a/datacapture-kmp/src/androidMain/res/drawable/ic_video_file.xml b/datacapture-kmp/src/androidMain/res/drawable/ic_video_file.xml new file mode 100644 index 0000000000..335499f1e9 --- /dev/null +++ b/datacapture-kmp/src/androidMain/res/drawable/ic_video_file.xml @@ -0,0 +1,13 @@ + + + diff --git a/datacapture-kmp/src/androidMain/res/drawable/questionnaire_item_checkbox_background.xml b/datacapture-kmp/src/androidMain/res/drawable/questionnaire_item_checkbox_background.xml new file mode 100644 index 0000000000..aee0934a70 --- /dev/null +++ b/datacapture-kmp/src/androidMain/res/drawable/questionnaire_item_checkbox_background.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/datacapture-kmp/src/androidMain/res/drawable/radio_button_inset.xml b/datacapture-kmp/src/androidMain/res/drawable/radio_button_inset.xml new file mode 100644 index 0000000000..7e12d705b5 --- /dev/null +++ b/datacapture-kmp/src/androidMain/res/drawable/radio_button_inset.xml @@ -0,0 +1,6 @@ + + diff --git a/datacapture-kmp/src/androidMain/res/drawable/rounded_corner_preview_icon_bg_filled.xml b/datacapture-kmp/src/androidMain/res/drawable/rounded_corner_preview_icon_bg_filled.xml new file mode 100644 index 0000000000..e4ab7b542f --- /dev/null +++ b/datacapture-kmp/src/androidMain/res/drawable/rounded_corner_preview_icon_bg_filled.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/datacapture-kmp/src/androidMain/res/drawable/rounded_corner_rect_border.xml b/datacapture-kmp/src/androidMain/res/drawable/rounded_corner_rect_border.xml new file mode 100644 index 0000000000..ae015fccb3 --- /dev/null +++ b/datacapture-kmp/src/androidMain/res/drawable/rounded_corner_rect_border.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/datacapture-kmp/src/androidMain/res/drawable/rounded_corner_rect_filled.xml b/datacapture-kmp/src/androidMain/res/drawable/rounded_corner_rect_filled.xml new file mode 100644 index 0000000000..53ddac327e --- /dev/null +++ b/datacapture-kmp/src/androidMain/res/drawable/rounded_corner_rect_filled.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/datacapture-kmp/src/androidMain/res/layout/add_repeated_item.xml b/datacapture-kmp/src/androidMain/res/layout/add_repeated_item.xml new file mode 100644 index 0000000000..85bba8edc5 --- /dev/null +++ b/datacapture-kmp/src/androidMain/res/layout/add_repeated_item.xml @@ -0,0 +1,17 @@ + + + + + + diff --git a/datacapture-kmp/src/androidMain/res/layout/attachment_file_preview.xml b/datacapture-kmp/src/androidMain/res/layout/attachment_file_preview.xml new file mode 100644 index 0000000000..a83b20d08c --- /dev/null +++ b/datacapture-kmp/src/androidMain/res/layout/attachment_file_preview.xml @@ -0,0 +1,86 @@ + + + + + + + + + + + + +