diff --git a/.gitignore b/.gitignore index e102095b4..b67000e7b 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,9 @@ .externalNativeBuild build/ +# test artifacts +connectedTestReports/ + # Local configuration file (sdk path, etc) /local.properties .gradle diff --git a/PULL_REQUEST_TEMPLATE.md b/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000..c1d5af086 --- /dev/null +++ b/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,23 @@ + + +Related to issue: # + + + +### Description: + + + +### Summary of changes: + +- + +### Pre-merge Checklist + + +- a [vTest](https://runtime-kotlin.esri.com/view/all/job/vtest/job/toolkit/) Job for this PR has been run + - [ ] link: +- Unit and/or integration tests have been added to exercise this PR's logic, and the tests are passing: + - [ ] Yes + - [ ] No + \ No newline at end of file diff --git a/README.md b/README.md index b3c493250..86ea230e3 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,10 @@ The ArcGIS Maps SDK for Kotlin Toolkit contains components that will simplify yo * **[Authenticator](toolkit/authentication)** - Displays a user interface when network and ArcGIS authentication challenges occur. * **[Compass](toolkit/compass)** - Shows a compass direction when the map is rotated. Auto-hides when the map points north. * **[FeatureForms](toolkit/featureforms)** - Provides a UI for editing feature attributes. -* **[GeoView-Compose](toolkit/geoview-compose)** - Compose wrappers for the MapView and SceneView. * **[FloorFilter](toolkit/indoors)** - Allows filtering of floor plan data in a geo view by a site, a facility in the site, or a floor in the facility. +* **[GeoView-Compose](toolkit/geoview-compose)** - Compose wrappers for the MapView and SceneView. + * **[Callout](toolkit/geoview-compose#display-a-callout)** - Draws a callout on the GeoView to display Composable content. +* **[Popup](toolkit/popup)** - View field values of features in a layer using the Popup API. ## API Reference @@ -41,10 +43,12 @@ repositories { The *ArcGIS Maps SDK for Kotlin Toolkit* is released with a "bill of materials" (`BOM`). The releasable BOM is versioned and represents a set of versions of the toolkit components which are compatible with one another. You may specify dependencies as follows ``` -implementation(platform("com.esri:arcgis-maps-kotlin-toolkit-bom:200.4.0")) +implementation(platform("com.esri:arcgis-maps-kotlin-toolkit-bom:200.5.0")) implementation("com.esri:arcgis-maps-kotlin-toolkit-authentication") implementation("com.esri:arcgis-maps-kotlin-toolkit-compass") +implementation("com.esri:arcgis-maps-kotlin-toolkit-geoview-compose") implementation("com.esri:arcgis-maps-kotlin-toolkit-indoors") +implementation("com.esri:arcgis-maps-kotlin-toolkit-popup") ``` The template and TemplateApp modules are for bootstrapping new modules. @@ -62,11 +66,14 @@ Esri welcomes contributions from anyone and everyone. Please see our [guidelines ### Creating a New Toolkit Component A new toolkit component can be added to this project by running the bash script at the top level -`./new-component-starter.sh -n NameOfNewComponent"` +`./new-component-starter.sh` -This will create a new library module for the new component, and a new app module which depends on the library. +This script will prompt for you to input a name for the new component and ask if you want to create a new app module for it. Capitalization isn't necessary, but it won't capitalize anything but the first letter if none is specified. +A new microapp can be added by running the bash script `./new-microapp-starter.sh`. +This script will prompt for you to input a name for the new microapp. + This script requires bash 4.0 or higher. ### About this Repo @@ -94,6 +101,24 @@ The template and TemplateApp modules are for bootstrapping new modules. Please see the [package structure](doc/general/developer_setup.md#package-structure) documentation for more details. +### Testing + +#### Running Local Tests + +In order to run *local* (non-instrumented) tests of all modules and get an aggregated test report, run the following at the root folder of the project: +``` +./gradlew testAggregatedReport --continue +``` +The test report for local tests can be located under `arcgis-maps-sdk-kotlin-toolkit/build/reports`. + +#### Running Connected Tests + +In order to run *connected* (instrumented) tests of all modules and get the test reports in a centralized folder, run the following at the root folder of the project: +``` +./gradlew connectedDebugAndroidTest --continue +``` +The test reports for connected tests can be located under `arcgis-maps-sdk-kotlin-toolkit/connectedTestReports`. + ## Licensing Copyright 2019-2022 Esri diff --git a/build.gradle.kts b/build.gradle.kts index 6f45bd4ef..adc31058d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -27,6 +27,7 @@ plugins { alias(libs.plugins.hilt) apply false alias(libs.plugins.kotlin.serialization) apply false alias(libs.plugins.dokka) apply false + alias(libs.plugins.gmazzo.test.aggregation) } buildscript { @@ -38,3 +39,41 @@ buildscript { classpath(libs.dokka.versioning) } } + +// Path to the centralized folder in root directory where test reports for connected tests end up +val connectedTestReportsPath by extra("${rootDir}/connectedTestReports") + +/** + * Configures the [gmazzo test aggregation plugin](https://github.com/gmazzo/gradle-android-test-aggregation-plugin) + * with all local tests to be aggregated into a single test report. + * Note: This works only for local tests, not for connected tests. + * To run aggregated local tests, run the following at the root folder of the project: + * ``` + * ./gradlew testAggregatedReport --continue + * ``` + * Test report to be found under `arcgis-maps-sdk-kotlin-toolkit/build/reports`. + */ +testAggregation { + getModulesExcept( + "bom", + "kdoc", + "microapps-lib", + "template", + "template-app", + "composable-map").forEach { + this.modules.include(project(":$it")) + } +} + +/** + * Returns all modules in this project, except the ones specified by [modulesToExclude]. + */ +fun getModulesExcept(vararg modulesToExclude: String): List = + with(File("$rootDir/settings.gradle.kts")) { + readLines() + .filter { it.startsWith("include") } + .map { + it.removePrefix("include(\":").removeSuffix("\")") + } + .filter { !modulesToExclude.contains(it) } // exclude specified modules + } diff --git a/doc/general/developer_setup.md b/doc/general/developer_setup.md index 499b02e74..f6ed7782c 100644 --- a/doc/general/developer_setup.md +++ b/doc/general/developer_setup.md @@ -8,8 +8,7 @@ Requirements: and an internet connection to download dependencies expressed in gradle. -For production use, an API Key or valid ArcGIS Identity is required to use the ArcGIS Maps SDK for Kotlin, which is a dependency of all toolkit components. -Sign up for an ArcGIS developer account [here](https://developers.arcgis.com/sign-up). +For production use, [API Key authentication](https://developers.arcgis.com/documentation/security-and-authentication/api-key-authentication/) or [user authentication](https://developers.arcgis.com/documentation/security-and-authentication/user-authentication/) is required to access the ArcGIS location services provided by the ArcGIS Maps SDK for Kotlin toolkit components. Sign up for an ArcGIS account [here](https://developers.arcgis.com/kotlin/get-started/#1-sign-up-for-an-account). ### Get Started diff --git a/gradle.properties b/gradle.properties index b1389d7e3..7561d7b9c 100644 --- a/gradle.properties +++ b/gradle.properties @@ -46,12 +46,12 @@ artifactoryUsername="" artifactoryPassword="" # these numbers will define the artifact version on artifactory # and are overridden by the jenkins command line in the daily build -versionNumber=200.4.0 +versionNumber=200.5.0 buildNumber=0000-snapshot #set this flag to `true` to ignore the build number when publishing. This # will publish an artifact with a build number like "..:200.2.0" as opposed to "...:200.2.0-3963 ignoreBuildNumber=true # 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.4.0 +sdkVersionNumber=200.5.0 sdkBuildNumber= diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5976d6e04..e5c383274 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,33 +1,37 @@ [versions] -androidGradlePlugin = "8.2.0" -androidXBrowser = "1.7.0" +activityCompose = "1.8.2" +androidGradlePlugin = "8.3.2" +androidXBrowser = "1.8.0" coilBOM = "2.5.0" -composeBOM = "2023.10.01" -androidxComposeCompiler = "1.5.6" -androidxCore = "1.12.0" +composeBOM = "2024.04.01" +androidxComposeCompiler = "1.5.12" +androidxCore = "1.13.0" androidxEspresso = "3.5.1" -androidxHiltNavigationCompose = "1.0.0" -androidxLifecycle = "2.6.2" -androidxMaterialIcons = "1.4.3" +androidxHiltNavigationCompose = "1.2.0" +androidxLifecycle = "2.7.0" +androidxMaterialIcons = "1.6.6" androidxTestExt = "1.1.5" androidxWindow = "1.2.0" binaryCompatibilityValidator = "0.14.0" compileSdk = "34" -compose-navigation = "2.7.5" -dokka = "1.9.10" -hilt = "2.48" -hiltExt = "1.0.0" +compose-navigation = "2.7.7" +dokka = "1.9.20" +hilt = "2.49" +hiltExt = "1.2.0" junit = "4.13.2" -kotlin = "1.9.21" -ksp = "1.9.21-1.0.16" +kotlin = "1.9.23" +ksp = "1.9.23-1.0.20" +media3Exoplayer = "1.3.1" minSdk = "26" -kotlinxCoroutinesTest = "1.7.3" -kotlinxSerializationJson = "1.5.1" -room = "2.6.0" -truth = "1.1.4" +kotlinxCoroutinesTest = "1.8.0" +kotlinxSerializationJson = "1.6.3" +mockkAndroid = "1.13.10" +room = "2.6.1" +truth = "1.4.2" +uiautomator = "2.3.0" [libraries] -androidx-activity-compose = { group = "androidx.activity", name = "activity-compose"} +androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose"} androidx-browser = { group = "androidx.browser", name = "browser", version.ref = "androidXBrowser"} androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidxCore" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBOM" } @@ -47,8 +51,12 @@ androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecyc androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose"} androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose"} androidx-material-icons = { group = "androidx.compose.material", name = "material-icons-extended", version.ref = "androidxMaterialIcons"} +androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3Exoplayer" } +androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "media3Exoplayer" } +androidx-media3-exoplayer-dash = { module = "androidx.media3:media3-exoplayer-dash", version.ref = "media3Exoplayer" } 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-uiautomator = { module = "androidx.test.uiautomator:uiautomator", version.ref = "uiautomator" } androidx-window = { group = "androidx.window", name = "window", version.ref = "androidxWindow" } androidx-window-core = { group = "androidx.window", name = "window-core", version.ref = "androidxWindow" } coil-bom = { group = "io.coil-kt", name = "coil-bom", version.ref = "coilBOM" } @@ -64,6 +72,7 @@ kotlin-reflect = { group = "org.jetbrains.kotlin", name = "kotlin-reflect", vers 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" } +mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockkAndroid" } 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" } @@ -80,6 +89,7 @@ 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" } +gmazzo-test-aggregation = { id = "io.github.gmazzo.test.aggregation.results", version = "2.2.0" } [bundles] core = [ diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 033e24c4c..7f93135c4 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 62f495dfe..3fa8f862f 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew index fcb6fca14..1aa94a426 100755 --- a/gradlew +++ b/gradlew @@ -83,7 +83,8 @@ done # This is normally unused # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} -APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -144,7 +145,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC3045 + # shellcheck disable=SC2039,SC3045 MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac @@ -152,7 +153,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then '' | soft) :;; #( *) # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC3045 + # shellcheck disable=SC2039,SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac @@ -201,11 +202,11 @@ fi # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' -# Collect all arguments for the java command; -# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of -# shell script including quotes and variable substitutions, so put them in -# double quotes to make sure that they get re-expanded; and -# * put everything else in single quotes, so that it's not re-expanded. +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ diff --git a/kdoc/build.gradle.kts b/kdoc/build.gradle.kts index af135609d..64c554d13 100644 --- a/kdoc/build.gradle.kts +++ b/kdoc/build.gradle.kts @@ -19,7 +19,7 @@ plugins { id("com.android.library") id("org.jetbrains.kotlin.android") - alias(libs.plugins.dokka) + alias(libs.plugins.dokka) apply true } val versionNumber: String by project @@ -40,26 +40,35 @@ rootProject.subprojects.filter { evaluationDependsOn(":${it.name}") } +// only run kdoc on components which are released. Only modules that apply +// the `artifact-deploy` plugin are released. +// TODO: flag released modules directly. val releasedModules = project.rootProject.subprojects.filter { it.plugins.findPlugin("artifact-deploy") != null } // determine the released toolkit components -val releasedSourceSets = releasedModules.map { subproject -> +val releasedSourceSetPaths = releasedModules.map { subproject -> // add all the intended library projects as sourceSets below File(rootDir, "toolkit/${subproject.name}/src/main/java").canonicalPath } tasks { - //./gradlew :documentation:dokkaHtml + //./gradlew :kdoc:dokkaHtml // doc output will be under `documentation/build/dokka/html`. dokkaHtml { pluginConfiguration { version = versionNumber } + moduleName.set("arcgis-maps-kotlin-toolkit") dokkaSourceSets { + named("main") { + sourceRoots.from(releasedSourceSetPaths) + } + configureEach { + platform.set(org.jetbrains.dokka.Platform.jvm) perPackageOption { matchingRegex.set(".*internal.*") suppress.set(true) @@ -68,7 +77,6 @@ tasks { perPackageOption { reportUndocumented.set(true) } - } } } @@ -80,34 +88,16 @@ android { defaultConfig { minSdk = libs.versions.minSdk.get().toInt() - consumerProguardFiles("consumer-rules.pro") } - - sourceSets { - named("main") { - java { - releasedSourceSets.forEach { - srcDir(it) - } - } - } - } } dependencies { // Puts the version in the KDoc dokkaPlugin(libs.dokka.versioning) - - project.afterEvaluate { - releasedModules.forEach { proj -> - proj.configurations.forEach { config -> - config.allDependencies.forEach { - //add all dependencies as implementation dependencies, no need for api. - project.dependencies.add("implementation", it) - } - } - } - } + // put exposed dependencies in dokka's classpath + implementation(arcgis.mapsSdk) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.bundles.composeCore) } diff --git a/microapps/AuthenticationApp/app/build.gradle.kts b/microapps/AuthenticationApp/app/build.gradle.kts index ad1858422..2af6e7a60 100644 --- a/microapps/AuthenticationApp/app/build.gradle.kts +++ b/microapps/AuthenticationApp/app/build.gradle.kts @@ -72,10 +72,20 @@ android { excludes += "/META-INF/{AL2.0,LGPL2.1}" } } + + /** + * Configures the test report for connected (instrumented) tests to be copied to a central + * folder in the project's root directory. + */ + testOptions { + val connectedTestReportsPath: String by project + reportDir = "$connectedTestReportsPath/${project.name}" + } } dependencies { implementation(project(":authentication")) + implementation(project(":microapps-lib")) implementation(arcgis.mapsSdk) implementation(platform(libs.androidx.compose.bom)) implementation(libs.bundles.composeCore) diff --git a/microapps/AuthenticationApp/app/src/main/java/com/arcgismaps/toolkit/authenticationapp/MainActivity.kt b/microapps/AuthenticationApp/app/src/main/java/com/arcgismaps/toolkit/authenticationapp/MainActivity.kt index ea74b7ebf..04b4c2e75 100644 --- a/microapps/AuthenticationApp/app/src/main/java/com/arcgismaps/toolkit/authenticationapp/MainActivity.kt +++ b/microapps/AuthenticationApp/app/src/main/java/com/arcgismaps/toolkit/authenticationapp/MainActivity.kt @@ -36,9 +36,12 @@ import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Checkbox import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField +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.ui.Alignment @@ -51,7 +54,7 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import com.arcgismaps.ArcGISEnvironment import com.arcgismaps.toolkit.authentication.DialogAuthenticator -import com.arcgismaps.toolkit.authenticationapp.ui.theme.AuthenticationAppTheme +import com.esri.microappslib.theme.MicroAppTheme class MainActivity : ComponentActivity() { private val authenticationAppViewModel: AuthenticationAppViewModel by viewModels() @@ -60,7 +63,7 @@ class MainActivity : ComponentActivity() { // Application context must be set for client certificate authentication. ArcGISEnvironment.applicationContext = applicationContext setContent { - AuthenticationAppTheme { + MicroAppTheme { AuthenticationApp(authenticationAppViewModel) DialogAuthenticator(authenticatorState = authenticationAppViewModel.authenticatorState) } @@ -68,20 +71,25 @@ class MainActivity : ComponentActivity() { } } +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun AuthenticationApp(authenticationAppViewModel: AuthenticationAppViewModel) { - Column { - val infoText = authenticationAppViewModel.infoText.collectAsState().value - val isLoading = authenticationAppViewModel.isLoading.collectAsState().value - PortalDetails( - url = authenticationAppViewModel.url.collectAsState().value, - onSetUrl = authenticationAppViewModel::setUrl, - useOAuth = authenticationAppViewModel.useOAuth.collectAsState().value, - onSetUseOAuth = authenticationAppViewModel::setUseOAuth, - onSignOut = authenticationAppViewModel::signOut, - onLoadPortal = authenticationAppViewModel::loadPortal - ) - InfoScreen(text = infoText, isLoading = isLoading) + Scaffold( + topBar = { TopAppBar(title = { Text("Authentication App") }) } + ) { + Column(Modifier.padding(it)) { + val infoText = authenticationAppViewModel.infoText.collectAsState().value + val isLoading = authenticationAppViewModel.isLoading.collectAsState().value + PortalDetails( + url = authenticationAppViewModel.url.collectAsState().value, + onSetUrl = authenticationAppViewModel::setUrl, + useOAuth = authenticationAppViewModel.useOAuth.collectAsState().value, + onSetUseOAuth = authenticationAppViewModel::setUseOAuth, + onSignOut = authenticationAppViewModel::signOut, + onLoadPortal = authenticationAppViewModel::loadPortal + ) + InfoScreen(text = infoText, isLoading = isLoading) + } } } diff --git a/microapps/AuthenticationApp/app/src/main/java/com/arcgismaps/toolkit/authenticationapp/ui/theme/Color.kt b/microapps/AuthenticationApp/app/src/main/java/com/arcgismaps/toolkit/authenticationapp/ui/theme/Color.kt deleted file mode 100644 index f38160645..000000000 --- a/microapps/AuthenticationApp/app/src/main/java/com/arcgismaps/toolkit/authenticationapp/ui/theme/Color.kt +++ /dev/null @@ -1,29 +0,0 @@ -/* - * - * 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.authenticationapp.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/AuthenticationApp/app/src/main/java/com/arcgismaps/toolkit/authenticationapp/ui/theme/Theme.kt b/microapps/AuthenticationApp/app/src/main/java/com/arcgismaps/toolkit/authenticationapp/ui/theme/Theme.kt deleted file mode 100644 index 379a13f5f..000000000 --- a/microapps/AuthenticationApp/app/src/main/java/com/arcgismaps/toolkit/authenticationapp/ui/theme/Theme.kt +++ /dev/null @@ -1,88 +0,0 @@ -/* - * - * 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.authenticationapp.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 AuthenticationAppTheme( - 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/AuthenticationApp/app/src/main/java/com/arcgismaps/toolkit/authenticationapp/ui/theme/Type.kt b/microapps/AuthenticationApp/app/src/main/java/com/arcgismaps/toolkit/authenticationapp/ui/theme/Type.kt deleted file mode 100644 index e382e0f5e..000000000 --- a/microapps/AuthenticationApp/app/src/main/java/com/arcgismaps/toolkit/authenticationapp/ui/theme/Type.kt +++ /dev/null @@ -1,52 +0,0 @@ -/* - * - * 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.authenticationapp.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/CompassApp/app/build.gradle.kts b/microapps/CompassApp/app/build.gradle.kts index 68c1fcf5c..04a72c5f3 100644 --- a/microapps/CompassApp/app/build.gradle.kts +++ b/microapps/CompassApp/app/build.gradle.kts @@ -70,6 +70,15 @@ android { excludes += "/META-INF/{AL2.0,LGPL2.1}" } } + + /** + * Configures the test report for connected (instrumented) tests to be copied to a central + * folder in the project's root directory. + */ + testOptions { + val connectedTestReportsPath: String by project + reportDir = "$connectedTestReportsPath/${project.name}" + } } //https://youtrack.jetbrains.com/issue/KTIJ-21063 @@ -80,6 +89,7 @@ tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile::class).all { dependencies { implementation(project(":compass")) implementation(project(":geoview-compose")) + implementation(project(":microapps-lib")) implementation(arcgis.mapsSdk) implementation(platform(libs.androidx.compose.bom)) implementation(libs.bundles.composeCore) diff --git a/microapps/CompassApp/app/src/main/java/com/arcgismaps/toolkit/compassapp/MainActivity.kt b/microapps/CompassApp/app/src/main/java/com/arcgismaps/toolkit/compassapp/MainActivity.kt index 3f8560d75..40a05f48b 100644 --- a/microapps/CompassApp/app/src/main/java/com/arcgismaps/toolkit/compassapp/MainActivity.kt +++ b/microapps/CompassApp/app/src/main/java/com/arcgismaps/toolkit/compassapp/MainActivity.kt @@ -21,12 +21,18 @@ package com.arcgismaps.toolkit.compassapp import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +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 androidx.compose.ui.tooling.preview.Preview import com.arcgismaps.ApiKey import com.arcgismaps.ArcGISEnvironment import com.arcgismaps.toolkit.compassapp.screens.MainScreen -import com.arcgismaps.toolkit.compassapp.ui.theme.CompassAppTheme +import com.esri.microappslib.theme.MicroAppTheme class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -36,22 +42,25 @@ class MainActivity : ComponentActivity() { ArcGISEnvironment.apiKey = ApiKey.create(BuildConfig.API_KEY) setContent { - CompassAppTheme { + MicroAppTheme { CompassApp() } } } } +@OptIn(ExperimentalMaterial3Api::class) @Composable fun CompassApp() { - MainScreen() + Scaffold(topBar = { TopAppBar(title = { Text("Compass App") }) }) { + MainScreen(Modifier.padding(it)) + } } @Preview(showBackground = true) @Composable fun AppPreview() { - CompassAppTheme { + MicroAppTheme { CompassApp() } } 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 6bc806d51..7174e4eb3 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 @@ -43,7 +43,7 @@ import com.arcgismaps.toolkit.geoviewcompose.MapViewProxy import kotlinx.coroutines.launch @Composable -fun MainScreen() { +fun MainScreen(modifier: Modifier) { // create an ArcGISMap with a Topographic basemap style val arcGISMap by remember { mutableStateOf( @@ -57,7 +57,7 @@ fun MainScreen() { val mapViewProxy = remember { MapViewProxy() } // show composable MapView with compass Box( - modifier = Modifier.fillMaxSize() + modifier = modifier.fillMaxSize() ) { MapView( arcGISMap, diff --git a/microapps/CompassApp/app/src/main/java/com/arcgismaps/toolkit/compassapp/ui/theme/Color.kt b/microapps/CompassApp/app/src/main/java/com/arcgismaps/toolkit/compassapp/ui/theme/Color.kt deleted file mode 100644 index 5de135268..000000000 --- a/microapps/CompassApp/app/src/main/java/com/arcgismaps/toolkit/compassapp/ui/theme/Color.kt +++ /dev/null @@ -1,29 +0,0 @@ -/* - * - * 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.compassapp.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/CompassApp/app/src/main/java/com/arcgismaps/toolkit/compassapp/ui/theme/Theme.kt b/microapps/CompassApp/app/src/main/java/com/arcgismaps/toolkit/compassapp/ui/theme/Theme.kt deleted file mode 100644 index fdc1607e7..000000000 --- a/microapps/CompassApp/app/src/main/java/com/arcgismaps/toolkit/compassapp/ui/theme/Theme.kt +++ /dev/null @@ -1,88 +0,0 @@ -/* - * - * 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.compassapp.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 CompassAppTheme( - 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/CompassApp/app/src/main/java/com/arcgismaps/toolkit/compassapp/ui/theme/Type.kt b/microapps/CompassApp/app/src/main/java/com/arcgismaps/toolkit/compassapp/ui/theme/Type.kt deleted file mode 100644 index c5005d733..000000000 --- a/microapps/CompassApp/app/src/main/java/com/arcgismaps/toolkit/compassapp/ui/theme/Type.kt +++ /dev/null @@ -1,52 +0,0 @@ -/* - * - * 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.compassapp.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/README.md b/microapps/FeatureFormsApp/README.md index 5ca63269c..8d9ed8bef 100644 --- a/microapps/FeatureFormsApp/README.md +++ b/microapps/FeatureFormsApp/README.md @@ -10,11 +10,9 @@ for editing Feature attributes. The application provides a web map gallery and a map viewer which invokes the form when Features are tapped. Authentication is optional but limited to specific public web maps. -To authenticate ArcGIS Online with username and password, please provide values in `local.properties` as follows +To login to your ArcGIS Online account, click the `Sign in with ArcGIS Online` button in the app. This will open the OAuth page in a new browser window where you can enter the account credentials. +Once you have signed in, you can access the web maps that are shared with your account. -``` -webMapUser=XXX -webMapPassword=YYY -``` +If you'd rather use an ArcGIS Enterprise account, click the `Sign in with ArcGIS Enterprise` button in the app. This will open a dialog prompting for an Enterprise URL followed by another dialog to enter your credentials. For more information on the `FeatureForm` component and how it works, see it's [Readme](../../toolkit/featureforms/README.md). diff --git a/microapps/FeatureFormsApp/app/build.gradle.kts b/microapps/FeatureFormsApp/app/build.gradle.kts index 8753f238c..d9caf2566 100644 --- a/microapps/FeatureFormsApp/app/build.gradle.kts +++ b/microapps/FeatureFormsApp/app/build.gradle.kts @@ -57,7 +57,6 @@ android { kotlinOptions { jvmTarget = "1.8" } - @Suppress("UnstableApiUsage") buildFeatures { compose = true buildConfig = true @@ -71,6 +70,15 @@ android { excludes += "/META-INF/{AL2.0,LGPL2.1}" } } + + /** + * Configures the test report for connected (instrumented) tests to be copied to a central + * folder in the project's root directory. + */ + testOptions { + val connectedTestReportsPath: String by project + reportDir = "$connectedTestReportsPath/${project.name}" + } } //https://youtrack.jetbrains.com/issue/KTIJ-21063 @@ -97,6 +105,7 @@ dependencies { implementation(libs.androidx.window) implementation(libs.androidx.window.core) // compose + implementation(platform(libs.androidx.compose.bom)) implementation(libs.bundles.composeCore) implementation(libs.bundles.core) implementation(libs.androidx.lifecycle.runtime.ktx) @@ -104,6 +113,8 @@ dependencies { implementation(libs.androidx.compose.navigation) implementation(libs.androidx.lifecycle.viewmodel.compose) testImplementation(libs.bundles.unitTest) + testImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.bundles.composeTest) + androidTestImplementation(platform(libs.androidx.compose.bom)) debugImplementation(libs.bundles.debug) } diff --git a/microapps/FeatureFormsApp/app/src/main/AndroidManifest.xml b/microapps/FeatureFormsApp/app/src/main/AndroidManifest.xml index a022d20c9..298a63415 100644 --- a/microapps/FeatureFormsApp/app/src/main/AndroidManifest.xml +++ b/microapps/FeatureFormsApp/app/src/main/AndroidManifest.xml @@ -41,6 +41,22 @@ + + + + + + + + + + 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 index aa4d401c6..87b3b8521 100644 --- 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 @@ -18,24 +18,38 @@ package com.arcgismaps.toolkit.featureformsapp +import android.Manifest.permission.CAMERA +import android.content.pm.PackageManager import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts 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.material.icons.Icons +import androidx.compose.material.icons.rounded.Warning +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon 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.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.res.stringResource import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat import androidx.lifecycle.lifecycleScope import androidx.navigation.compose.rememberNavController import com.arcgismaps.ArcGISEnvironment @@ -73,12 +87,26 @@ class MainActivity : ComponentActivity() { private val appState: MutableStateFlow = MutableStateFlow(AppState.Loading) + private val hasPermissions = mutableStateOf(null) + + // Register the permissions callback, which handles the user's response to the + // system permissions dialog. + private val requestPermissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestPermission() + ) { isGranted: Boolean -> + hasPermissions.value = isGranted + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) ArcGISEnvironment.applicationContext = this setContent { FeatureFormsAppTheme { - FeatureFormApp(appState.collectAsState().value, navigator) + FeatureFormApp( + appState.collectAsState().value, + navigator, + hasPermissions.value + ) } } lifecycleScope.launch { @@ -89,6 +117,14 @@ class MainActivity : ComponentActivity() { ) loadCredentials(factory.getPortalSettings()) } + // check for permissions + when (ContextCompat.checkSelfPermission(this, CAMERA)) { + PackageManager.PERMISSION_GRANTED -> { + hasPermissions.value = true + } + + else -> requestPermissionLauncher.launch(CAMERA) + } } private suspend fun loadCredentials(portalSettings: PortalSettings) = @@ -118,7 +154,14 @@ class MainActivity : ComponentActivity() { } @Composable -fun FeatureFormApp(appState: AppState, navigator: Navigator) { +fun FeatureFormApp( + appState: AppState, + navigator: Navigator, + hasPermissions: Boolean? +) { + var showPermissionsDialog by remember(hasPermissions) { + mutableStateOf(hasPermissions != null && hasPermissions == false) + } if (appState is AppState.Loading) { AnimatedLoading({ true }, modifier = Modifier.fillMaxSize()) } else { @@ -138,6 +181,24 @@ fun FeatureFormApp(appState: AppState, navigator: Navigator) { startDestination = startDestination ) } + if (showPermissionsDialog) { + AlertDialog( + onDismissRequest = { + showPermissionsDialog = false + }, + text = { + Text(text = stringResource(R.string.camera_permission_required)) + }, + icon = { + Icon(imageVector = Icons.Rounded.Warning, contentDescription = "Warning") + }, + confirmButton = { + Button(onClick = { showPermissionsDialog = false }) { + Text(text = stringResource(id = R.string.okay)) + } + } + ) + } } @Composable 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 index 687f10aab..f6a619b54 100644 --- 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 @@ -44,8 +44,8 @@ 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.automirrored.filled.ExitToApp import androidx.compose.material.icons.filled.AccountCircle -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 @@ -376,7 +376,7 @@ fun AppSearchBar( }, leadingIcon = { Icon( - imageVector = Icons.Default.ExitToApp, + imageVector = Icons.AutoMirrored.Filled.ExitToApp, contentDescription = null ) } 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 index abb9ee2a1..a8db8723c 100644 --- 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 @@ -25,14 +25,18 @@ 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.animation.togetherWith +import androidx.compose.foundation.Image +import androidx.compose.foundation.border 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.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width @@ -49,13 +53,12 @@ import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material3.Button import androidx.compose.material3.Card -import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider 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.material3.TextField import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable @@ -69,6 +72,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.composed +import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.geometry.CornerRadius @@ -121,10 +125,10 @@ fun LoginScreen( AnimatedContent( targetState = loginState is LoginState.Loading || loginState is LoginState.Success, transitionSpec = { - slideInVertically { h -> h } with - slideOutVertically( - animationSpec = tween() - ) { h -> h } + fadeOut() + slideInVertically { h -> h } togetherWith + slideOutVertically( + animationSpec = tween() + ) { h -> h } + fadeOut() }, label = "evaluation loading animation" ) { @@ -137,10 +141,10 @@ fun LoginScreen( statusText = stringResource(R.string.signing_in) ) } else { - Spacer(modifier = Modifier.weight(1f)) + Spacer(modifier = Modifier.height(50.dp)) LoginOptions( - onDefaultLoginTapped = { - viewModel.loginWithDefaultCredentials() + onAgolLoginTapped = { + viewModel.login(useOAuth = true) }, onEnterpriseLoginTapped = { showEnterpriseLogin = true @@ -161,6 +165,7 @@ fun LoginScreen( showEnterpriseLogin = false } ) + Authenticator(authenticatorState = viewModel.authenticatorState) LaunchedEffect(Unit) { viewModel.loginState.collect { if (it is LoginState.Success) { @@ -182,13 +187,13 @@ fun EnterpriseLogin( val visible = visibilityProvider() if (visible) { var showPortalUrlForm by remember { mutableStateOf(true) } - Authenticator(authenticatorState = loginViewModel.authenticatorState) if (showPortalUrlForm) { PortalURLForm( recents = loginViewModel.urlHistory.collectAsState().value, - onSubmit = { + onSubmit = { url -> showPortalUrlForm = false - loginViewModel.loginWithArcGISEnterprise(it) + loginViewModel.addUrlToHistory(url) + loginViewModel.login(url, useOAuth = false) }, onCancel = onCancel ) @@ -296,7 +301,7 @@ fun TextFieldWithHistory( imeAction = ImeAction.Done ), keyboardActions = KeyboardActions( - onDone = { focusManager.clearFocus() } + onDone = { focusManager.clearFocus() } ), shape = RoundedCornerShape(10.dp), colors = TextFieldDefaults.colors( @@ -346,7 +351,7 @@ fun TextFieldWithHistory( ) } if (index < recents.lastIndex) - Divider() + HorizontalDivider() } } } @@ -358,23 +363,50 @@ fun TextFieldWithHistory( @Composable fun LoginOptions( modifier: Modifier = Modifier, - onDefaultLoginTapped: () -> Unit, + onAgolLoginTapped: () -> Unit, onEnterpriseLoginTapped: () -> Unit, skipSignInTapped: () -> Unit ) { Column( - modifier = modifier, + modifier = modifier.padding(vertical = 50.dp), verticalArrangement = Arrangement.spacedBy(20.dp), horizontalAlignment = Alignment.CenterHorizontally ) { + // browse demo maps card + Box( + modifier = Modifier + .wrapContentHeight() + .fillMaxWidth(0.8f) + .clip(RoundedCornerShape(15.dp)) + .border( + width = 5.dp, + color = MaterialTheme.colorScheme.secondary, + shape = RoundedCornerShape(15.dp) + ) + .clickable { skipSignInTapped() } + ) { + Image( + painter = painterResource(id = R.drawable.ic_topographic_map), + contentDescription = null + ) + Text( + text = stringResource(R.string.browse_demo_maps), + style = MaterialTheme.typography.titleLarge.copy( + color = Color.White, + fontWeight = FontWeight.Bold + ), + modifier = Modifier.align(Alignment.Center) + ) + } + Spacer(modifier = Modifier.weight(1f)) Button( - onClick = onDefaultLoginTapped, + onClick = onAgolLoginTapped, modifier = Modifier .fillMaxWidth() .padding(horizontal = 40.dp) ) { Text( - text = stringResource(R.string.sign_in_with_default_credentials), + text = stringResource(R.string.sign_in_with_agol), modifier = Modifier.padding(5.dp), ) } @@ -389,9 +421,6 @@ fun LoginOptions( modifier = Modifier.padding(5.dp) ) } - TextButton(onClick = skipSignInTapped) { - Text(text = stringResource(R.string.skin_sign_in)) - } } } @@ -441,7 +470,7 @@ fun EnterpriseLoginPreview() { "https://url2.com/portal", "https://url3.com/portal" ), - onSubmit = { a -> + onSubmit = { _ -> } ) { 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 index 465d8de36..ac4c7a521 100644 --- 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 @@ -18,6 +18,7 @@ package com.arcgismaps.toolkit.featureformsapp.screens.login import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.arcgismaps.httpcore.authentication.OAuthUserConfiguration import com.arcgismaps.portal.Portal import com.arcgismaps.toolkit.authentication.AuthenticatorState import com.arcgismaps.toolkit.featureformsapp.BuildConfig @@ -33,7 +34,6 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import kotlinx.coroutines.withTimeoutOrNull import javax.inject.Inject @HiltViewModel @@ -41,9 +41,6 @@ class LoginViewModel @Inject constructor( private val portalSettings: PortalSettings, private val urlHistoryDao: UrlHistoryDao ) : ViewModel() { - - private data class Credentials(val username: String = "", val password: String = "") - val authenticatorState = AuthenticatorState() val urlHistory: StateFlow> = urlHistoryDao.observeAll().map { urlEntries -> @@ -59,51 +56,34 @@ class LoginViewModel @Inject constructor( private val _loginState: MutableStateFlow = MutableStateFlow(LoginState.NotLoggedIn) val loginState = _loginState.asStateFlow() - private var credentials: Credentials? = Credentials() + private val oAuthRedirectUri = "featureformsapp://auth" + private val clientId = "iFmvhJGQEKGK1Ahf" - init { + /** + * Save this url to the search history. + */ + fun addUrlToHistory(url: String) { 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") + if (url.isNotEmpty()) { + urlHistoryDao.insert(UrlEntry(url)) } } } - fun loginWithArcGISEnterprise(url: String) { - credentials = null + /** + * Authenticate the user with the given portal [url]. Default [url] is ArcGIS Online. + */ + fun login(url: String = portalSettings.defaultPortalUrl, useOAuth: Boolean) { _loginState.value = LoginState.Loading viewModelScope.launch(Dispatchers.IO) { - if (url.isNotEmpty()) { - urlHistoryDao.insert(UrlEntry(url)) - } - authenticatorState.oAuthUserConfiguration = null + authenticatorState.oAuthUserConfiguration = + if (useOAuth) + OAuthUserConfiguration( + portalUrl = url, + clientId = clientId, + redirectUrl = oAuthRedirectUri, + ) + else null portalSettings.setPortalUrl(url) portalSettings.setPortalConnection(Portal.Connection.Authenticated) val portal = Portal(url, Portal.Connection.Authenticated) @@ -115,6 +95,9 @@ class LoginViewModel @Inject constructor( } } + /** + * Skip authentication and use the portal as an anonymous user to load any public content. + */ fun skipSignIn() { viewModelScope.launch { portalSettings.setPortalUrl(portalSettings.defaultPortalUrl) 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 index 69271b46a..977571e23 100644 --- 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 @@ -24,7 +24,9 @@ import android.widget.Toast import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.Image 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 @@ -38,16 +40,19 @@ import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.rounded.Warning import androidx.compose.material3.AlertDialog +import androidx.compose.material3.BasicAlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text @@ -81,7 +86,9 @@ import com.arcgismaps.toolkit.featureformsapp.screens.bottomsheet.SheetValue import com.arcgismaps.toolkit.featureformsapp.screens.bottomsheet.StandardBottomSheet import com.arcgismaps.toolkit.featureformsapp.screens.bottomsheet.rememberStandardBottomSheetState import com.arcgismaps.toolkit.geoviewcompose.MapView +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -121,27 +128,36 @@ fun MapScreen(mapViewModel: MapViewModel = hiltViewModel(), onBackPressed: () -> 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 = uiState !is UIState.NotEditing, - onClose = { - showDiscardEditsDialog = true - }, - onSave = { - scope.launch { - mapViewModel.commitEdits().onFailure { - Log.w("Forms", "Applying edits failed : ${it.message}") - Toast.makeText( - context, - "Applying edits failed : ${it.message}", - Toast.LENGTH_LONG - ).show() + Box { + // 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 = uiState is UIState.Editing, + onClose = { + showDiscardEditsDialog = true + }, + onSave = { + scope.launch { + mapViewModel.commitEdits().onFailure { + Log.w("Forms", "Applying edits failed : ${it.message}") + withContext(Dispatchers.Main) { + Toast.makeText( + context, + "Applying edits failed : ${it.message}", + Toast.LENGTH_LONG + ).show() + } + } } - } - }) { - onBackPressed() + }) { + onBackPressed() + } + if (uiState is UIState.Loading) { + LinearProgressIndicator(modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter)) + } } } ) { padding -> @@ -195,11 +211,35 @@ fun MapScreen(mapViewModel: MapViewModel = hiltViewModel(), onBackPressed: () -> } } } - if (uiState is UIState.Committing) { - SubmitForm(errors = (uiState as UIState.Committing).errors) { - mapViewModel.cancelCommit() + when (uiState) { + is UIState.Committing -> { + SubmitForm(errors = (uiState as UIState.Committing).errors) { + mapViewModel.cancelCommit() + } } + + is UIState.Switching -> { + DiscardEditsDialog( + onConfirm = { mapViewModel.selectNewFeature() }, + onCancel = { mapViewModel.continueEditing() } + ) + } + + is UIState.NoFeatureFormDefinition -> { + NoFormDefinitionDialog( + onConfirm = { + mapViewModel.setDefaultState() + }, + onCancel = { + mapViewModel.setDefaultState() + onBackPressed() + } + ) + } + + else -> {} } + if (showDiscardEditsDialog) { DiscardEditsDialog( onConfirm = { @@ -211,12 +251,7 @@ fun MapScreen(mapViewModel: MapViewModel = hiltViewModel(), onBackPressed: () -> } ) } - if (uiState is UIState.Switching) { - DiscardEditsDialog( - onConfirm = { mapViewModel.selectNewFeature() }, - onCancel = { mapViewModel.continueEditing() } - ) - } + } @Composable @@ -242,6 +277,37 @@ fun DiscardEditsDialog(onConfirm: () -> Unit, onCancel: () -> Unit) { ) } +@Composable +fun NoFormDefinitionDialog( + onConfirm: () -> Unit, + onCancel: () -> Unit, +) { + AlertDialog( + onDismissRequest = {}, + title = { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = stringResource(R.string.no_featureform_found), modifier = Modifier.weight(1f)) + Image(imageVector = Icons.Rounded.Warning, contentDescription = null) + } + }, + confirmButton = { + Button(onClick = onConfirm) { + Text(text = stringResource(R.string.okay)) + } + }, + dismissButton = { + Button(onClick = onCancel) { + Text(text = stringResource(R.string.exit)) + } + }, + text = { + Text(text = stringResource(R.string.no_featureform_description)) + } + ) +} + @OptIn(ExperimentalMaterial3Api::class) @Composable fun TopFormBar( @@ -270,7 +336,7 @@ fun TopFormBar( } } else { IconButton(onClick = onBackPressed) { - Icon(imageVector = Icons.Default.ArrowBack, contentDescription = "Back") + Icon(imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") } } }, @@ -289,9 +355,7 @@ fun TopFormBar( private fun SubmitForm(errors: List, onDismissRequest: () -> Unit) { if (errors.isEmpty()) { // show a progress dialog if no errors are present - AlertDialog( - onDismissRequest = { /* cannot be dismissed */ }, - ) { + BasicAlertDialog(onDismissRequest = { /* cannot be dismissed */ }) { Card(modifier = Modifier.wrapContentSize()) { Column( modifier = Modifier.padding(15.dp), 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 index eae47b1b1..e322f42f7 100644 --- 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 @@ -36,6 +36,8 @@ import com.arcgismaps.mapping.featureforms.FieldFormElement import com.arcgismaps.mapping.featureforms.FormElement import com.arcgismaps.mapping.featureforms.GroupFormElement import com.arcgismaps.mapping.layers.FeatureLayer +import com.arcgismaps.mapping.layers.GroupLayer +import com.arcgismaps.mapping.layers.Layer import com.arcgismaps.mapping.view.SingleTapConfirmedEvent import com.arcgismaps.toolkit.featureforms.ValidationErrorVisibility import com.arcgismaps.toolkit.featureformsapp.data.PortalItemRepository @@ -55,7 +57,17 @@ sealed class UIState { /** * Currently not editing. */ - object NotEditing : UIState() + data object NotEditing : UIState() + + /** + * Loading state that indicates the map is being loaded. + */ + data object Loading : UIState() + + /** + * No feature form definition available. + */ + data object NoFeatureFormDefinition : UIState() /** * Currently selecting a new Feature @@ -63,7 +75,7 @@ sealed class UIState { data class Switching( val oldState: Editing, val newFeature: ArcGISFeature - ): UIState() + ) : UIState() /** * In editing state with the [featureForm] with the validation error visibility given by @@ -93,7 +105,7 @@ data class ErrorInfo(val fieldName: String, val error: FeatureFormValidationExce * Base class for context aware AndroidViewModel. This class must have only a single application * parameter. */ -open class BaseMapViewModel(application: Application): AndroidViewModel(application) +open class BaseMapViewModel(application: Application) : AndroidViewModel(application) /** * A view model for the FeatureForms MapView UI @@ -115,10 +127,29 @@ class MapViewModel @Inject constructor( val map: ArcGISMap = ArcGISMap(portalItem) - private val _uiState: MutableState = mutableStateOf(UIState.NotEditing) + private val _uiState: MutableState = mutableStateOf(UIState.Loading) val uiState: State get() = _uiState + init { + scope.launch { + // check if this map has a FeatureFormDefinition on any of its layers + checkFeatureFormDefinition() + } + } + + private suspend fun checkFeatureFormDefinition() { + map.load() + val layer = map.operationalLayers.firstOrNull { + it.hasFeatureFormDefinition() + } + _uiState.value = if (layer == null) { + UIState.NoFeatureFormDefinition + } else { + UIState.NotEditing + } + } + /** * Apply attribute edits to the Geodatabase backing * the ServiceFeatureTable and refresh the local feature. @@ -136,7 +167,7 @@ class MapViewModel @Inject constructor( featureForm.validationErrors.value.forEach { entry -> entry.value.forEach { error -> featureForm.elements.getFormElement(entry.key)?.let { formElement -> - if (formElement.isEditable.value) { + if (formElement.isEditable.value || formElement.hasValueExpression) { errors.add( ErrorInfo( formElement.label, @@ -149,22 +180,33 @@ class MapViewModel @Inject constructor( } // set the state to committing with the errors if any _uiState.value = UIState.Committing( - featureForm = state.featureForm, + featureForm = featureForm, errors = errors ) // if there are no errors then update the feature return if (errors.isEmpty()) { - val feature = state.featureForm.feature val serviceFeatureTable = - feature.featureTable as? ServiceFeatureTable ?: return Result.failure( + featureForm.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() + var result = Result.success(Unit) + featureForm.finishEditing().onSuccess { + serviceFeatureTable.serviceGeodatabase?.let { database -> + if (database.serviceInfo?.canUseServiceGeodatabaseApplyEdits == true) { + database.applyEdits().onFailure { + result = Result.failure(it) + } + } else { + serviceFeatureTable.applyEdits().onFailure { + result = Result.failure(it) + } + } + } + featureForm.feature.refresh() // unselect the feature after the edits have been saved - (feature.featureTable?.layer as FeatureLayer).clearSelection() + (featureForm.feature.featureTable?.layer as FeatureLayer).clearSelection() + }.onFailure { + result = Result.failure(it) } // set the state to not editing since the feature was updated successfully _uiState.value = UIState.NotEditing @@ -268,6 +310,9 @@ class MapViewModel @Inject constructor( } } + fun setDefaultState() { + _uiState.value = UIState.NotEditing + } } /** @@ -295,3 +340,19 @@ fun List.getFormElement(fieldName: String): FieldFormElement? { } } } + +/** + * Returns true if the layer has a feature form definition. If the layer is a [GroupLayer] then + * this function will return true if any of the layers in the group have a feature form definition. + */ +private suspend fun Layer.hasFeatureFormDefinition(): Boolean = when(this) { + is FeatureLayer -> { + load() + featureFormDefinition != null + } + is GroupLayer -> { + load() + layers.any { it.hasFeatureFormDefinition() } + } + else -> false +} diff --git a/microapps/FeatureFormsApp/app/src/main/res/drawable/ic_topographic_map.jpeg b/microapps/FeatureFormsApp/app/src/main/res/drawable/ic_topographic_map.jpeg new file mode 100644 index 000000000..6145fa4a8 Binary files /dev/null and b/microapps/FeatureFormsApp/app/src/main/res/drawable/ic_topographic_map.jpeg differ diff --git a/microapps/FeatureFormsApp/app/src/main/res/values/strings.xml b/microapps/FeatureFormsApp/app/src/main/res/values/strings.xml index 756b79a41..dd15d8f08 100644 --- a/microapps/FeatureFormsApp/app/src/main/res/values/strings.xml +++ b/microapps/FeatureFormsApp/app/src/main/res/values/strings.xml @@ -21,7 +21,7 @@ Cancel "Enter an URL for ArcGIS Enterprise" Signing in.. - Sign in using built-in Credentials + Sign in with ArcGIS Online Sign in with ArcGIS Enterprise Skip sign in Maximum character length exceeded @@ -42,4 +42,10 @@ Discard Discard Edits? All changes made within the form will be lost. + Browse Demo Maps + No FeatureForm Found! + Okay + Exit + FeatureLayers in this map do not contain a FeatureFormDefinition. Feature editing will be disabled. + Camera permission is required for Attachments. This will result in limited functionality. diff --git a/microapps/FloorFilterApp/app/build.gradle.kts b/microapps/FloorFilterApp/app/build.gradle.kts index 660810839..b5d1348ad 100644 --- a/microapps/FloorFilterApp/app/build.gradle.kts +++ b/microapps/FloorFilterApp/app/build.gradle.kts @@ -53,6 +53,15 @@ android { excludes += "/META-INF/{AL2.0,LGPL2.1}" } } + + /** + * Configures the test report for connected (instrumented) tests to be copied to a central + * folder in the project's root directory. + */ + testOptions { + val connectedTestReportsPath: String by project + reportDir = "$connectedTestReportsPath/${project.name}" + } } // context receivers are not experimental anymore, but AS thinks they are. @@ -64,6 +73,7 @@ tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile::class).all { dependencies { implementation(project(":indoors")) implementation(project(":geoview-compose")) + implementation(project(":microapps-lib")) implementation(arcgis.mapsSdk) implementation(platform(libs.androidx.compose.bom)) implementation(libs.bundles.composeCore) diff --git a/microapps/FloorFilterApp/app/src/main/java/com/arcgismaps/toolkit/floorfilterapp/MainActivity.kt b/microapps/FloorFilterApp/app/src/main/java/com/arcgismaps/toolkit/floorfilterapp/MainActivity.kt index 51c67db66..f91e33e37 100644 --- a/microapps/FloorFilterApp/app/src/main/java/com/arcgismaps/toolkit/floorfilterapp/MainActivity.kt +++ b/microapps/FloorFilterApp/app/src/main/java/com/arcgismaps/toolkit/floorfilterapp/MainActivity.kt @@ -20,12 +20,18 @@ package com.arcgismaps.toolkit.floorfilterapp import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +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 androidx.compose.ui.tooling.preview.Preview import com.arcgismaps.ApiKey import com.arcgismaps.ArcGISEnvironment import com.arcgismaps.toolkit.floorfilterapp.screens.MainScreen -import com.arcgismaps.toolkit.floorfilterapp.ui.theme.FloorFilterAppTheme +import com.esri.microappslib.theme.MicroAppTheme class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -33,22 +39,25 @@ class MainActivity : ComponentActivity() { ArcGISEnvironment.apiKey = ApiKey.create(BuildConfig.API_KEY) setContent { - FloorFilterAppTheme { + MicroAppTheme { FloorFilterApp() } } } } +@OptIn(ExperimentalMaterial3Api::class) @Composable fun FloorFilterApp() { - MainScreen() + Scaffold(topBar = { TopAppBar(title = { Text("FloorFilter App") }) }) { + MainScreen(Modifier.padding(it)) + } } @Preview(showBackground = true) @Composable fun AppPreview() { - FloorFilterAppTheme { + MicroAppTheme { FloorFilterApp() } } diff --git a/microapps/FloorFilterApp/app/src/main/java/com/arcgismaps/toolkit/floorfilterapp/screens/MainScreen.kt b/microapps/FloorFilterApp/app/src/main/java/com/arcgismaps/toolkit/floorfilterapp/screens/MainScreen.kt index 1ad6f9569..2263ac1b0 100644 --- a/microapps/FloorFilterApp/app/src/main/java/com/arcgismaps/toolkit/floorfilterapp/screens/MainScreen.kt +++ b/microapps/FloorFilterApp/app/src/main/java/com/arcgismaps/toolkit/floorfilterapp/screens/MainScreen.kt @@ -41,7 +41,7 @@ import com.arcgismaps.toolkit.indoors.FloorFilterSelection import com.arcgismaps.toolkit.indoors.FloorFilterState @Composable -fun MainScreen() { +fun MainScreen(modifier: Modifier = Modifier) { val floorAwareWebMap by remember { mutableStateOf( ArcGISMap( @@ -87,11 +87,11 @@ fun MainScreen() { MapView( floorAwareWebMap, - modifier = Modifier.fillMaxSize(), + modifier = modifier.fillMaxSize(), mapViewProxy = mapViewProxy ) Box( - modifier = Modifier.fillMaxSize().padding(horizontal = 20.dp, vertical = 40.dp), + modifier = modifier.fillMaxSize().padding(horizontal = 20.dp, vertical = 40.dp), contentAlignment = Alignment.BottomStart ) { FloorFilter(floorFilterState = floorFilterState) diff --git a/microapps/FloorFilterApp/app/src/main/java/com/arcgismaps/toolkit/floorfilterapp/ui/theme/Color.kt b/microapps/FloorFilterApp/app/src/main/java/com/arcgismaps/toolkit/floorfilterapp/ui/theme/Color.kt deleted file mode 100644 index 790bd3c97..000000000 --- a/microapps/FloorFilterApp/app/src/main/java/com/arcgismaps/toolkit/floorfilterapp/ui/theme/Color.kt +++ /dev/null @@ -1,28 +0,0 @@ -/* - * 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.floorfilterapp.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/FloorFilterApp/app/src/main/java/com/arcgismaps/toolkit/floorfilterapp/ui/theme/Theme.kt b/microapps/FloorFilterApp/app/src/main/java/com/arcgismaps/toolkit/floorfilterapp/ui/theme/Theme.kt deleted file mode 100644 index 127a87e5f..000000000 --- a/microapps/FloorFilterApp/app/src/main/java/com/arcgismaps/toolkit/floorfilterapp/ui/theme/Theme.kt +++ /dev/null @@ -1,87 +0,0 @@ -/* - * 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.floorfilterapp.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 FloorFilterAppTheme( - 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/FloorFilterApp/app/src/main/java/com/arcgismaps/toolkit/floorfilterapp/ui/theme/Type.kt b/microapps/FloorFilterApp/app/src/main/java/com/arcgismaps/toolkit/floorfilterapp/ui/theme/Type.kt deleted file mode 100644 index 057523ae9..000000000 --- a/microapps/FloorFilterApp/app/src/main/java/com/arcgismaps/toolkit/floorfilterapp/ui/theme/Type.kt +++ /dev/null @@ -1,51 +0,0 @@ -/* - * 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.floorfilterapp.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/MapViewCalloutApp/.gitignore b/microapps/MapViewCalloutApp/.gitignore new file mode 100644 index 000000000..aa724b770 --- /dev/null +++ b/microapps/MapViewCalloutApp/.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/MapViewCalloutApp/README.md b/microapps/MapViewCalloutApp/README.md new file mode 100644 index 000000000..6a34078f1 --- /dev/null +++ b/microapps/MapViewCalloutApp/README.md @@ -0,0 +1,13 @@ +# MapView Callout Micro-app + +This micro-app demonstrates the use of `Callout` with a composable `MapView`. `Callout` is a composable function which renders an empty composable Box placed at a given point or GeoElement on a Map. The content of the composable Box is customizable, while the container of the Box is a stylable rectangular shape with a leader line positioned at the point of the tap or GeoElement passed into the `Callout` composable functions. + +## Usage + +The application starts with a choice of two screens +* A `MapView` that displays a `Callout` at the location where the user taps on the screen. +* A `MapView` with `Callout`s placed on GeoElements tapped by the user. +* For more information on the composable `Callout` component and how it works, see the relevant section in the composable GeoView [Readme](../../toolkit/geoview-compose#display-a-callout). + +![Callout-geoelement](https://github.com/user-attachments/assets/d6fd278a-c773-45f3-9ecd-a76852b71192) + diff --git a/microapps/MapViewCalloutApp/app/.gitignore b/microapps/MapViewCalloutApp/app/.gitignore new file mode 100644 index 000000000..796b96d1c --- /dev/null +++ b/microapps/MapViewCalloutApp/app/.gitignore @@ -0,0 +1 @@ +/build diff --git a/microapps/MapViewCalloutApp/app/build.gradle.kts b/microapps/MapViewCalloutApp/app/build.gradle.kts new file mode 100644 index 000000000..9d6891831 --- /dev/null +++ b/microapps/MapViewCalloutApp/app/build.gradle.kts @@ -0,0 +1,91 @@ +/* + * + * Copyright 2024 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 { + // this file doesn't contain secrets, it just provides defaults which can be committed into git. + defaultPropertiesFileName = "secrets.defaults.properties" +} + +android { + namespace = "com.arcgismaps.toolkit.mapviewcalloutapp" + compileSdk = libs.versions.compileSdk.get().toInt() + + defaultConfig { + applicationId ="com.arcgismaps.toolkit.mapviewcalloutapp" + 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}" + } + } +} + +dependencies { + implementation(project(":geoview-compose")) + implementation(project(":microapps-lib")) + implementation(arcgis.mapsSdk) + implementation(platform(libs.androidx.compose.bom)) + 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) + implementation(libs.androidx.compose.navigation) + testImplementation(libs.bundles.unitTest) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.bundles.composeTest) + debugImplementation(libs.bundles.debug) +} diff --git a/microapps/MapViewCalloutApp/app/proguard-rules.pro b/microapps/MapViewCalloutApp/app/proguard-rules.pro new file mode 100644 index 000000000..f1b424510 --- /dev/null +++ b/microapps/MapViewCalloutApp/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/toolkit/authentication/src/androidTest/java/com/arcgismaps/toolkit/authentication/ExampleInstrumentedTest.kt b/microapps/MapViewCalloutApp/app/src/androidTest/java/com/arcgismaps/toolkit/mapviewcalloutapp/ExampleInstrumentedTest.kt similarity index 87% rename from toolkit/authentication/src/androidTest/java/com/arcgismaps/toolkit/authentication/ExampleInstrumentedTest.kt rename to microapps/MapViewCalloutApp/app/src/androidTest/java/com/arcgismaps/toolkit/mapviewcalloutapp/ExampleInstrumentedTest.kt index dd4516f42..8334ce930 100644 --- a/toolkit/authentication/src/androidTest/java/com/arcgismaps/toolkit/authentication/ExampleInstrumentedTest.kt +++ b/microapps/MapViewCalloutApp/app/src/androidTest/java/com/arcgismaps/toolkit/mapviewcalloutapp/ExampleInstrumentedTest.kt @@ -16,11 +16,11 @@ * */ -package com.arcgismaps.toolkit.authentication +package com.arcgismaps.toolkit.mapviewcalloutapp import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry -import org.junit.Assert +import org.junit.Assert.* import org.junit.Test import org.junit.runner.RunWith @@ -35,6 +35,6 @@ class ExampleInstrumentedTest { fun useAppContext() { // Context of the app under test. val appContext = InstrumentationRegistry.getInstrumentation().targetContext - Assert.assertEquals("com.arcgismaps.toolkit.authentication.test", appContext.packageName) + assertEquals("com.arcgismaps.toolkit.mapviewcalloutapp", appContext.packageName) } } diff --git a/microapps/MapViewCalloutApp/app/src/main/AndroidManifest.xml b/microapps/MapViewCalloutApp/app/src/main/AndroidManifest.xml new file mode 100644 index 000000000..940864f19 --- /dev/null +++ b/microapps/MapViewCalloutApp/app/src/main/AndroidManifest.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + diff --git a/microapps/MapViewCalloutApp/app/src/main/java/com/arcgismaps/toolkit/mapviewcalloutapp/MainActivity.kt b/microapps/MapViewCalloutApp/app/src/main/java/com/arcgismaps/toolkit/mapviewcalloutapp/MainActivity.kt new file mode 100644 index 000000000..95d6196e3 --- /dev/null +++ b/microapps/MapViewCalloutApp/app/src/main/java/com/arcgismaps/toolkit/mapviewcalloutapp/MainActivity.kt @@ -0,0 +1,45 @@ +/* + * + * Copyright 2024 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.mapviewcalloutapp + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.runtime.Composable +import com.arcgismaps.ApiKey +import com.arcgismaps.ArcGISEnvironment +import com.arcgismaps.toolkit.mapviewcalloutapp.screens.MainScreen +import com.esri.microappslib.theme.MicroAppTheme + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + ArcGISEnvironment.apiKey = ApiKey.create(BuildConfig.API_KEY) + setContent { + MicroAppTheme { + MapViewCalloutApp() + } + } + } +} + +@Composable +fun MapViewCalloutApp() { + MainScreen() +} diff --git a/microapps/MapViewCalloutApp/app/src/main/java/com/arcgismaps/toolkit/mapviewcalloutapp/screens/FeatureScreen.kt b/microapps/MapViewCalloutApp/app/src/main/java/com/arcgismaps/toolkit/mapviewcalloutapp/screens/FeatureScreen.kt new file mode 100644 index 000000000..f36b2c28e --- /dev/null +++ b/microapps/MapViewCalloutApp/app/src/main/java/com/arcgismaps/toolkit/mapviewcalloutapp/screens/FeatureScreen.kt @@ -0,0 +1,254 @@ +/* + * + * Copyright 2024 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.mapviewcalloutapp.screens + +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.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +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.material.icons.Icons +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material3.Checkbox +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +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.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.arcgismaps.toolkit.geoviewcompose.MapView + +/** + * Displays a composable [MapView] to show a Callout on the tapped Feature. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FeatureScreen(viewModel: MapViewModel) { + val selectedGeoElement = viewModel.selectedGeoElement.collectAsState().value + val selectedLayerName = viewModel.selectedLayerName.collectAsState().value + var calloutVisibility by rememberSaveable { mutableStateOf(true) } + var nullTapLocation by rememberSaveable { mutableStateOf(false) } + val modalBottomSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + var showBottomSheet by remember { mutableStateOf(false) } + + Scaffold( + floatingActionButton = { + Box( + Modifier + .fillMaxWidth() + .padding(bottom = 12.dp), + contentAlignment = Alignment.BottomEnd + ) { + ExtendedFloatingActionButton( + text = { Text("Callout options") }, + icon = { Icon(Icons.Filled.Settings, contentDescription = "SettingsIcon") }, + onClick = { showBottomSheet = true } + ) + } + } + ) { contentPadding -> + MapView( + modifier = Modifier + .fillMaxSize() + .padding(contentPadding), + arcGISMap = viewModel.arcGISMapWithFeatureLayer, + mapViewProxy = viewModel.mapViewProxy, + insets = PaddingValues(horizontal = 12.dp), + graphicsOverlays = remember { listOf(viewModel.tapLocationGraphicsOverlay) }, + onSingleTapConfirmed = { singleTapConfirmedEvent -> + viewModel.apply { + // clears the tapped location and graphic + clearTapLocationAndGraphic() + // sets the new tapped location and adds a graphic + setTapLocation(singleTapConfirmedEvent.mapPoint, nullTapLocation) + // identify the tapped layer and the features attributes + identify(singleTapConfirmedEvent) + } + }, + content = if (selectedGeoElement != null && calloutVisibility) { + { + Callout( + modifier = Modifier + .wrapContentSize() + .height(200.dp) + .widthIn(max = 300.dp), + geoElement = selectedGeoElement, + tapLocation = viewModel.tapLocation.value, + ) { + CalloutContent( + onCloseIconClick = { + viewModel.clearSelectedGeoElement() + viewModel.clearTapLocationAndGraphic() + }, + selectedElementAttributes = filterAttributes(selectedGeoElement.attributes), + layerName = selectedLayerName + ) + } + } + } else { + null + } + ) + + if (showBottomSheet) { + ModalBottomSheet( + modifier = Modifier.padding(contentPadding), + onDismissRequest = { showBottomSheet = false }, + sheetState = modalBottomSheetState + ) { + Box(Modifier.navigationBarsPadding()) { + CalloutOptions( + calloutVisibility = calloutVisibility, + onVisibilityToggled = { calloutVisibility = !calloutVisibility }, + passNullTapLocation = nullTapLocation, + onNullTapLocationToggled = { nullTapLocation = !nullTapLocation } + ) + } + } + } + } +} + +/** + * Content for the Callout to display information on the tapped Layer and it's [selectedElementAttributes] + */ +@Composable +fun CalloutContent( + onCloseIconClick: () -> Unit, + selectedElementAttributes: Map, + layerName: String +) { + LazyColumn(contentPadding = PaddingValues(8.dp)) { + item { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = "Layer: $layerName", + style = MaterialTheme.typography.titleMedium + ) + IconButton(onClick = onCloseIconClick) { + Icon( + imageVector = Icons.Outlined.Close, + contentDescription = null + ) + } + } + } + item { + HorizontalDivider(Modifier.padding(vertical = 8.dp)) + } + selectedElementAttributes.forEach { attribute -> + item { + Row( + modifier = Modifier.fillMaxSize(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top + ) { + Text( + text = "${attribute.key}:", + fontStyle = FontStyle.Italic, + style = MaterialTheme.typography.labelSmall + ) + Spacer(modifier = Modifier.width(20.dp)) + Text( + text = "${attribute.value}", + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.End + ) + } + } + } + } +} + +/** + * Callout visibility and tap location options, displayed in a BottomSheet. + */ +@Composable +fun CalloutOptions( + calloutVisibility: Boolean, + onVisibilityToggled: () -> Unit, + passNullTapLocation: Boolean, + onNullTapLocationToggled: () -> Unit, +) { + Column(Modifier.padding(vertical = 8.dp, horizontal = 12.dp)) { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = "Enable Callout:", style = MaterialTheme.typography.labelMedium) + Checkbox( + checked = calloutVisibility, + onCheckedChange = { onVisibilityToggled() } + ) + } + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = "Use tapped location:", style = MaterialTheme.typography.labelMedium) + Checkbox( + checked = !passNullTapLocation, + onCheckedChange = { onNullTapLocationToggled() } + ) + } + } +} + +/** + * Filter undesired feature attributes like, empty or null values and GlobalIDs. + */ +private fun filterAttributes(attributes: Map): Map { + return attributes + .filter { attribute -> attribute.value != null } + .filter { attribute -> attribute.value.toString().trim().isNotEmpty() } + .filter { attribute -> !attribute.key.contains("GlobalID") } +} diff --git a/microapps/MapViewCalloutApp/app/src/main/java/com/arcgismaps/toolkit/mapviewcalloutapp/screens/MainScreen.kt b/microapps/MapViewCalloutApp/app/src/main/java/com/arcgismaps/toolkit/mapviewcalloutapp/screens/MainScreen.kt new file mode 100644 index 000000000..cc38a84d7 --- /dev/null +++ b/microapps/MapViewCalloutApp/app/src/main/java/com/arcgismaps/toolkit/mapviewcalloutapp/screens/MainScreen.kt @@ -0,0 +1,205 @@ +/* + * + * Copyright 2024 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.mapviewcalloutapp.screens + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +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.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import com.arcgismaps.toolkit.geoviewcompose.MapView + +private val calloutAppScreens = mutableListOf( + "Show Callout on a tap location", + "Show Callout on a Feature" +) + +/** + * Displays a list of screens to launch. Each of which demonstrates different ways to show + * a Callout on a [MapView]. + */ +@Composable +fun MainScreen() { + var currentScreen by remember { mutableStateOf("") } + val navController = rememberNavController() + + navController.addOnDestinationChangedListener(listener = { _, destination, _ -> + currentScreen = destination.route.toString() + }) + + CalloutAppNavHost( + navController = navController, + calloutScreenNames = calloutAppScreens, + currentScreen = currentScreen + ) { + composable(route = calloutAppScreens[0]) { + val tapLocationViewModel: MapViewModel = viewModel() + TapLocationScreen(tapLocationViewModel) + } + composable(route = calloutAppScreens[1]) { + val featureViewModel: MapViewModel = viewModel() + FeatureScreen(featureViewModel) + } + } +} + + +@Composable +fun CalloutAppNavHost( + navController: NavHostController, + calloutScreenNames: MutableList, + currentScreen: String, + builder: NavGraphBuilder.() -> Unit +) { + Scaffold( + topBar = { + CalloutAppBar( + currentScreen = currentScreen, + canNavigateBack = navController.previousBackStackEntry != null, + navigateUp = { navController.navigateUp() } + ) + } + ) { innerPadding -> + NavHost( + modifier = Modifier.padding(innerPadding), + navController = navController, + startDestination = "Callout App", + ) { + composable(route = "Callout App") { + NavScreenSwitcher( + calloutScreenNames = calloutScreenNames, + onScreenSelected = { selectedScreen -> + navController.navigate(selectedScreen) + } + ) + } + builder.invoke(this) + } + } +} + +@Composable +fun NavScreenSwitcher( + calloutScreenNames: List, + onScreenSelected: (String) -> Unit +) { + Scaffold( + modifier = Modifier + .fillMaxSize(), + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + modifier = Modifier.padding(24.dp), + text = "Select screen to launch" + ) + + val (selectedOption, onOptionSelected) = remember { mutableStateOf(calloutScreenNames[0]) } + Column( + Modifier + .selectableGroup() + .padding(24.dp) + ) { + calloutScreenNames.forEach { calloutScreen -> + Row( + modifier = Modifier + .fillMaxWidth() + .selectable( + selected = (calloutScreen == selectedOption), + onClick = { onOptionSelected(calloutScreen) }, + role = Role.RadioButton + ) + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = (calloutScreen == selectedOption), + onClick = null + ) + Text( + text = calloutScreen, + modifier = Modifier.padding(start = 16.dp) + ) + } + } + } + + Button(onClick = { onScreenSelected(selectedOption) }) { + Text(text = "Launch screen") + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CalloutAppBar( + currentScreen: String, + canNavigateBack: Boolean, + navigateUp: () -> Unit, + modifier: Modifier = Modifier +) { + TopAppBar( + title = { Text(currentScreen) }, + modifier = modifier, + navigationIcon = { + if (canNavigateBack) { + IconButton(onClick = navigateUp) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back button" + ) + } + } + } + ) +} diff --git a/microapps/MapViewCalloutApp/app/src/main/java/com/arcgismaps/toolkit/mapviewcalloutapp/screens/MapViewModel.kt b/microapps/MapViewCalloutApp/app/src/main/java/com/arcgismaps/toolkit/mapviewcalloutapp/screens/MapViewModel.kt new file mode 100644 index 000000000..d1b8d8e58 --- /dev/null +++ b/microapps/MapViewCalloutApp/app/src/main/java/com/arcgismaps/toolkit/mapviewcalloutapp/screens/MapViewModel.kt @@ -0,0 +1,148 @@ +/* + * + * Copyright 2024 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.mapviewcalloutapp.screens + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.unit.dp +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.arcgismaps.Color +import com.arcgismaps.geometry.Point +import com.arcgismaps.mapping.ArcGISMap +import com.arcgismaps.mapping.BasemapStyle +import com.arcgismaps.mapping.GeoElement +import com.arcgismaps.mapping.Viewpoint +import com.arcgismaps.mapping.symbology.SimpleMarkerSymbol +import com.arcgismaps.mapping.symbology.SimpleMarkerSymbolStyle +import com.arcgismaps.mapping.view.Graphic +import com.arcgismaps.mapping.view.GraphicsOverlay +import com.arcgismaps.mapping.view.SingleTapConfirmedEvent +import com.arcgismaps.toolkit.geoviewcompose.MapViewProxy +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +class MapViewModel : ViewModel() { + + val mapViewProxy = MapViewProxy() + + val arcGISMap = ArcGISMap(BasemapStyle.ArcGISTopographic).apply { + initialViewpoint = Viewpoint( + latitude = 39.8, + longitude = -98.6, + scale = 10e7 + ) + } + + val arcGISMapWithFeatureLayer = ArcGISMap( + uri = "https://www.arcgis.com/home/item.html?id=16f1b8ba37b44dc3884afc8d5f454dd2" + ).apply { + initialViewpoint = Viewpoint( + Point(x = -1.3659e7, y = 5.6917e6), + scale = 50000.0, + ) + } + + private val _mapPoint = MutableStateFlow(null) + val mapPoint: StateFlow = _mapPoint.asStateFlow() + + private val _selectedGeoElement = MutableStateFlow(null) + val selectedGeoElement: StateFlow = _selectedGeoElement.asStateFlow() + + private val _selectedLayerName = MutableStateFlow("") + val selectedLayerName: StateFlow = _selectedLayerName.asStateFlow() + + private val _tapLocation = MutableStateFlow(null) + val tapLocation: StateFlow = _tapLocation.asStateFlow() + + private val _offset = MutableStateFlow(Offset.Zero) + val offset: StateFlow = _offset + + val tapLocationGraphicsOverlay: GraphicsOverlay = GraphicsOverlay() + + private var currentIdentifyJob: Job? = null + + fun clearMapPoint() { + _mapPoint.value = null + tapLocationGraphicsOverlay.graphics.clear() + } + + fun setOffset(offset: Offset) { + _offset.value = offset + } + + fun setMapPoint(singleTapConfirmedEvent: SingleTapConfirmedEvent) { + _mapPoint.value = singleTapConfirmedEvent.mapPoint + + tapLocationGraphicsOverlay.graphics.clear() + tapLocationGraphicsOverlay.graphics.add( + Graphic( + geometry = _mapPoint.value, + symbol = SimpleMarkerSymbol(SimpleMarkerSymbolStyle.Cross, Color.red, 12.0f) + ) + ) + } + + fun clearTapLocationAndGraphic() { + _tapLocation.value = null + tapLocationGraphicsOverlay.graphics.clear() + } + + fun clearSelectedGeoElement() { + _selectedGeoElement.value = null + } + + fun setTapLocation(tapLocation: Point?, nullTapLocation: Boolean) { + _tapLocation.value = if (nullTapLocation) null else tapLocation + + tapLocationGraphicsOverlay.graphics.clear() + tapLocationGraphicsOverlay.graphics.add( + Graphic( + geometry = tapLocation, + symbol = SimpleMarkerSymbol(SimpleMarkerSymbolStyle.Cross, Color.red, 12.0f) + ) + ) + } + + /** + * Identifies the tapped screen coordinate in the provided [singleTapConfirmedEvent]. The + * identified geoelement is set to [_selectedGeoElement]. + * + * @since 200.5.0 + */ + fun identify(singleTapConfirmedEvent: SingleTapConfirmedEvent) { + currentIdentifyJob?.cancel() + currentIdentifyJob = viewModelScope.launch { + val result = mapViewProxy.identifyLayers( + screenCoordinate = singleTapConfirmedEvent.screenCoordinate, + tolerance = 1.dp + ) + result.onSuccess { identifyLayerResultList -> + if (identifyLayerResultList.isNotEmpty()) { + _selectedGeoElement.value = identifyLayerResultList[0].geoElements.firstOrNull() + _selectedLayerName.value = identifyLayerResultList[0].layerContent.name + } else { + _selectedGeoElement.value = null + } + } + } + } +} diff --git a/microapps/MapViewCalloutApp/app/src/main/java/com/arcgismaps/toolkit/mapviewcalloutapp/screens/TapLocationScreen.kt b/microapps/MapViewCalloutApp/app/src/main/java/com/arcgismaps/toolkit/mapviewcalloutapp/screens/TapLocationScreen.kt new file mode 100644 index 000000000..2f19b3283 --- /dev/null +++ b/microapps/MapViewCalloutApp/app/src/main/java/com/arcgismaps/toolkit/mapviewcalloutapp/screens/TapLocationScreen.kt @@ -0,0 +1,250 @@ +/* + * + * Copyright 2024 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.mapviewcalloutapp.screens + +import android.widget.TextView +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +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.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.node.Ref +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.text.HtmlCompat +import com.arcgismaps.geometry.Point +import com.arcgismaps.toolkit.geoviewcompose.MapView +import kotlin.math.roundToInt + +/** + * Displays a composable [MapView] to show a Callout at the tapped location. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TapLocationScreen(viewModel: MapViewModel) { + + val mapPoint = viewModel.mapPoint.collectAsState().value + val offset = viewModel.offset.collectAsState().value + + var rotateOffsetWithGeoView by rememberSaveable { mutableStateOf(false) } + var calloutVisibility by rememberSaveable { mutableStateOf(true) } + var showBottomSheet by remember { mutableStateOf(false) } + val modalBottomSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + // animate to a visible transition state + val calloutVisibleState = remember { MutableTransitionState(false) }.apply { + targetState = mapPoint != null && calloutVisibility + } + + Scaffold( + floatingActionButton = { + Box( + Modifier + .fillMaxWidth() + .padding(bottom = 12.dp), + contentAlignment = Alignment.BottomEnd + ) { + ExtendedFloatingActionButton( + text = { Text("Callout options") }, + icon = { Icon(Icons.Filled.Settings, contentDescription = "SettingsIcon") }, + onClick = { showBottomSheet = true } + ) + } + } + ) { contentPadding -> + MapView( + modifier = Modifier + .fillMaxSize() + .padding(contentPadding), + arcGISMap = viewModel.arcGISMap, + graphicsOverlays = remember { listOf(viewModel.tapLocationGraphicsOverlay) }, + onSingleTapConfirmed = viewModel::setMapPoint, + content = { + val lastMapPoint = remember { Ref() } + lastMapPoint.value = mapPoint ?: lastMapPoint.value + + AnimatedVisibility( + calloutVisibleState, + enter = fadeIn(), + exit = fadeOut() + ) { + lastMapPoint.value?.let { + Callout( + modifier = Modifier.wrapContentSize(), + location = it, + rotateOffsetWithGeoView = rotateOffsetWithGeoView, + offset = offset + ) { + Column(Modifier.padding(4.dp)) { + HtmlText( + html = "Tapped location:
" + + "x = ${it.x.roundToInt()}
" + + "y = ${it.y.roundToInt()}
" + + "wkid = ${it.spatialReference?.wkid}", + htmlFlag = HtmlCompat.FROM_HTML_MODE_COMPACT, + textColor = MaterialTheme.colorScheme.onBackground, + ) + } + } + } + } + } + ) + + if (showBottomSheet) { + ModalBottomSheet( + modifier = Modifier.padding(contentPadding), + onDismissRequest = { showBottomSheet = false }, + sheetState = modalBottomSheetState + ) { + Box(Modifier.navigationBarsPadding()) { + CalloutOptions( + calloutVisibility = calloutVisibility, + isCalloutRotationEnabled = rotateOffsetWithGeoView, + offset = offset, + mapPoint = mapPoint, + onOffsetChange = { viewModel.setOffset(it) }, + onVisibilityToggled = { calloutVisibility = !calloutVisibility }, + onClearMapPointRequest = { viewModel.clearMapPoint() }, + onCalloutOffsetRotationToggled = { + rotateOffsetWithGeoView = !rotateOffsetWithGeoView + } + ) + } + } + } + } +} + +/** + * AndroidView wrapper for the view-based [TextView] which is able to display a styled spannable [html]. + * Currently, Compose does not provide a tool to buildAnnotatedString for HTML styled spannable text. + */ +@Composable +fun HtmlText(modifier: Modifier = Modifier, html: String, htmlFlag: Int, textColor: Color) { + AndroidView( + modifier = modifier, + factory = { context -> TextView(context) }, + update = { + it.text = HtmlCompat.fromHtml(html, htmlFlag) + it.setTextColor(textColor.hashCode()) + } + ) +} + +/** + * Callout visibility and offset options displayed in a BottomSheet. + */ +@Composable +fun CalloutOptions( + calloutVisibility: Boolean, + isCalloutRotationEnabled: Boolean, + offset: Offset, + mapPoint: Point?, + onVisibilityToggled: () -> Unit, + onOffsetChange: (Offset) -> Unit, + onCalloutOffsetRotationToggled: () -> Unit, + onClearMapPointRequest: () -> Unit, +) { + Column(Modifier.padding(8.dp), horizontalAlignment = Alignment.CenterHorizontally) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text(text = "Show Callout") + Checkbox( + checked = calloutVisibility, + onCheckedChange = { onVisibilityToggled() } + ) + } + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text(text = "Rotate offset") + Checkbox( + checked = isCalloutRotationEnabled, + onCheckedChange = { onCalloutOffsetRotationToggled() } + ) + } + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceAround) { + OutlinedTextField( + modifier = Modifier.weight(1f), + value = offset.x.toString(), + onValueChange = { onOffsetChange(Offset(it.toFloat(), offset.y)) }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Decimal, + imeAction = ImeAction.Done + ), + label = { Text("X-Axis offset (px)") }, + textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.End) + ) + Spacer(modifier = Modifier.size(10.dp)) + OutlinedTextField( + modifier = Modifier.weight(1f), + value = offset.y.toString(), + onValueChange = { onOffsetChange(Offset(offset.x, it.toFloat())) }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Decimal, + imeAction = ImeAction.Done + ), + label = { Text("Y-Axis offset (px)") }, + textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.End) + ) + } + Spacer(modifier = Modifier.size(10.dp)) + Button( + enabled = mapPoint != null, + onClick = { onClearMapPointRequest() }) { + Text(text = "Clear Callout map point") + } + } +} diff --git a/microapps/MapViewCalloutApp/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/microapps/MapViewCalloutApp/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 000000000..92971e871 --- /dev/null +++ b/microapps/MapViewCalloutApp/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + diff --git a/microapps/MapViewCalloutApp/app/src/main/res/drawable/ic_launcher_background.xml b/microapps/MapViewCalloutApp/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 000000000..b51b347d8 --- /dev/null +++ b/microapps/MapViewCalloutApp/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,188 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/microapps/MapViewCalloutApp/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/microapps/MapViewCalloutApp/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 000000000..6b4a339aa --- /dev/null +++ b/microapps/MapViewCalloutApp/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/microapps/MapViewCalloutApp/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/microapps/MapViewCalloutApp/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 000000000..6b4a339aa --- /dev/null +++ b/microapps/MapViewCalloutApp/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/microapps/MapViewCalloutApp/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/microapps/MapViewCalloutApp/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 000000000..c209e78ec Binary files /dev/null and b/microapps/MapViewCalloutApp/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/microapps/MapViewCalloutApp/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/microapps/MapViewCalloutApp/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 000000000..b2dfe3d1b Binary files /dev/null and b/microapps/MapViewCalloutApp/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/microapps/MapViewCalloutApp/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/microapps/MapViewCalloutApp/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 000000000..4f0f1d64e Binary files /dev/null and b/microapps/MapViewCalloutApp/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/microapps/MapViewCalloutApp/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/microapps/MapViewCalloutApp/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 000000000..62b611da0 Binary files /dev/null and b/microapps/MapViewCalloutApp/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/microapps/MapViewCalloutApp/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/microapps/MapViewCalloutApp/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 000000000..948a3070f Binary files /dev/null and b/microapps/MapViewCalloutApp/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/microapps/MapViewCalloutApp/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/microapps/MapViewCalloutApp/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 000000000..1b9a6956b Binary files /dev/null and b/microapps/MapViewCalloutApp/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/microapps/MapViewCalloutApp/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/microapps/MapViewCalloutApp/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 000000000..28d4b77f9 Binary files /dev/null and b/microapps/MapViewCalloutApp/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/microapps/MapViewCalloutApp/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/microapps/MapViewCalloutApp/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 000000000..9287f5083 Binary files /dev/null and b/microapps/MapViewCalloutApp/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/microapps/MapViewCalloutApp/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/microapps/MapViewCalloutApp/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 000000000..aa7d6427e Binary files /dev/null and b/microapps/MapViewCalloutApp/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/microapps/MapViewCalloutApp/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/microapps/MapViewCalloutApp/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 000000000..9126ae37c Binary files /dev/null and b/microapps/MapViewCalloutApp/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/microapps/MapViewCalloutApp/app/src/main/res/values/colors.xml b/microapps/MapViewCalloutApp/app/src/main/res/values/colors.xml new file mode 100644 index 000000000..6c58071d0 --- /dev/null +++ b/microapps/MapViewCalloutApp/app/src/main/res/values/colors.xml @@ -0,0 +1,28 @@ + + + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + diff --git a/microapps/MapViewCalloutApp/app/src/main/res/values/strings.xml b/microapps/MapViewCalloutApp/app/src/main/res/values/strings.xml new file mode 100644 index 000000000..70a05bfd1 --- /dev/null +++ b/microapps/MapViewCalloutApp/app/src/main/res/values/strings.xml @@ -0,0 +1,21 @@ + + + + MapViewCalloutApp + diff --git a/microapps/MapViewCalloutApp/app/src/main/res/values/themes.xml b/microapps/MapViewCalloutApp/app/src/main/res/values/themes.xml new file mode 100644 index 000000000..456bece1e --- /dev/null +++ b/microapps/MapViewCalloutApp/app/src/main/res/values/themes.xml @@ -0,0 +1,23 @@ + + + + + + + """.trimIndent() + AndroidView(factory = { context -> + WebView(context).apply { + webViewClient = object : WebViewClient() { + + // This view might have changed size, so request a layout to ensure it is displayed correctly. + override fun onPageFinished(view: WebView?, url: String?) { + super.onPageFinished(view, url) + parent?.requestLayout() + } + + // Override the WebView's default behavior to open links. Instead of loading the URL in the WebView, + // launch the device's default browser to handle the URL. + override fun shouldOverrideUrlLoading(view: WebView, webResourceRequest: WebResourceRequest): Boolean { + val intent = Intent(Intent.ACTION_VIEW, webResourceRequest.url).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK + } + + runCatching { + context.startActivity(intent) + }.onFailure { + Log.e("ArcGISMapsSDK", "Failed to open link: ${it.message}") + } + return true + } + } + val completeHtml = "$header$headStyle${content.trim()}" + loadDataWithBaseURL(null, completeHtml, "text/html", "UTF-8", null) + } + }, + // By default, AndroidViews aren't reused in a lazy list. This means that the `HTML` composable instance will + // get discarded and recreated every time. By defining an `onReset` lambda, we can ensure tha the AndroidView will + // be reused when the composition hierarchy changes. + onReset = {} + ) +} + +/** + * A composable that displays a TextPopupElement. + * + * @since 200.5.0 + */ +@Composable +internal fun TextPopupElement(state: TextElementState) { + ExpandableCard(toggleable = false) { + HTML(content = state.value) + } +} + +@Preview +@Composable +private fun TextPopupElementPreview() { + val tempText = + "

{NAME} is a peak in California's {RANGE} range. It ranks #{RANK} among the California Fourteeners.

The summit is {ELEV_FEET} feet high ({ELEV_METERS} meters) and has a prominence of {PROM_FEET} feet ({PROM_METERS} meters).

More info

" + TextPopupElement( + TextElementState( + value = tempText, + id = 42 + ) + ) +} + diff --git a/toolkit/popup/src/main/java/com/arcgismaps/toolkit/popup/internal/ui/ExpandableCard.kt b/toolkit/popup/src/main/java/com/arcgismaps/toolkit/popup/internal/ui/ExpandableCard.kt new file mode 100644 index 000000000..d0410b68f --- /dev/null +++ b/toolkit/popup/src/main/java/com/arcgismaps/toolkit/popup/internal/ui/ExpandableCard.kt @@ -0,0 +1,186 @@ +/* + * Copyright 2024 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.popup.internal.ui + +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.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +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.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.arcgismaps.toolkit.popup.R + +/** + * Composable Card that has the ability to expand and collapse its [content]. + * + * @since 200.5.0 + */ +@Composable +internal fun ExpandableCard( + modifier: Modifier = Modifier, + title: String = "", + description: String = "", + toggleable: Boolean = true, + content: @Composable () -> Unit = {} +) { + // TODO: promote to public theme. + val shapes = ExpandableCardDefaults.shapes() + val colors = ExpandableCardDefaults.colors() + var expanded by rememberSaveable { mutableStateOf(true) } + + Card( + colors = CardDefaults.cardColors( + containerColor = colors.containerColor + ), + border = BorderStroke(shapes.borderThickness, colors.borderColor), + shape = shapes.containerShape, + modifier = modifier + .fillMaxWidth() + .padding(shapes.padding) + ) { + Column { + ExpandableHeader( + title = title, + description = description, + expandable = toggleable, + colors = colors, + isExpanded = expanded + ) { + if (toggleable) { + expanded = !expanded + } + } + + AnimatedVisibility(visible = expanded) { + content() + } + + } + } +} + +@Composable +private fun ExpandableHeader( + title: String = "", + description: String = "", + expandable: Boolean, + colors: ExpandableCardColors, + isExpanded: Boolean, + onClick: () -> Unit +) { + if (title.isEmpty() && description.isEmpty() && !expandable) return + val shapes = ExpandableCardDefaults.shapes() + Row( + Modifier + .fillMaxWidth() + .applyIf(expandable) { + clickable { + onClick() + } + } + .background(MaterialTheme.colorScheme.surface), + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(shapes.padding) + .weight(0.5f) + ) { + Text( + text = title, + color = colors.headerTextColor, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (description.isNotEmpty() && isExpanded) { + Text( + text = description, + color = colors.headerTextColor, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Normal, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + } + + if (expandable) { + Crossfade(targetState = isExpanded, label = "expandPopupElement") { + Icon( + modifier = Modifier + .padding(16.dp), + imageVector = if (it) Icons.Rounded.ExpandLess else Icons.Rounded.ExpandMore, + contentDescription = stringResource(R.string.show_or_hide_popup_element_content) + ) + } + } + } +} + +@Preview +@Composable +internal fun ExpandableHeaderPreview() { + ExpandableHeader( + title = "The Title", + colors = ExpandableCardDefaults.colors(), + description = "the description", + expandable = true, + isExpanded = true + ) {} +} + +@Preview +@Composable +private fun ExpandableCardPreview() { + ExpandableCard( + description = "Foo", + title = "Title" + ) { + Text( + "Hello World", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(16.dp) + ) + } +} + diff --git a/toolkit/popup/src/main/java/com/arcgismaps/toolkit/popup/internal/ui/ExpandableCardDefaults.kt b/toolkit/popup/src/main/java/com/arcgismaps/toolkit/popup/internal/ui/ExpandableCardDefaults.kt new file mode 100644 index 000000000..0e8cca011 --- /dev/null +++ b/toolkit/popup/src/main/java/com/arcgismaps/toolkit/popup/internal/ui/ExpandableCardDefaults.kt @@ -0,0 +1,54 @@ +/* + * Copyright 2024 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.popup.internal.ui + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + + +internal object ExpandableCardDefaults { + @Composable fun shapes(): ExpandableCardShapes = ExpandableCardShapes( + padding = 16.dp, + containerShape = RoundedCornerShape(5.dp), + borderThickness = 1.dp + ) + @Composable + fun colors() : ExpandableCardColors = ExpandableCardColors( + headerTextColor = MaterialTheme.colorScheme.onBackground, + containerColor = MaterialTheme.colorScheme.background, + galleryContainerColor = MaterialTheme.colorScheme.onBackground, + borderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.6f) + ) +} + +internal data class ExpandableCardShapes( + val padding: Dp, + val containerShape: RoundedCornerShape, + val borderThickness: Dp +) + +internal data class ExpandableCardColors( + val headerTextColor: Color, + val containerColor : Color, + val galleryContainerColor: Color, + val borderColor : Color, +) + diff --git a/toolkit/popup/src/main/java/com/arcgismaps/toolkit/popup/internal/ui/Modifier.kt b/toolkit/popup/src/main/java/com/arcgismaps/toolkit/popup/internal/ui/Modifier.kt new file mode 100644 index 000000000..83c349d3b --- /dev/null +++ b/toolkit/popup/src/main/java/com/arcgismaps/toolkit/popup/internal/ui/Modifier.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2024 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.popup.internal.ui + +import androidx.compose.ui.Modifier + +internal fun Modifier.applyIf(condition: Boolean, then: Modifier.() -> Modifier): Modifier = + if (condition) { + then() + } else { + this + } diff --git a/toolkit/popup/src/main/java/com/arcgismaps/toolkit/popup/internal/ui/fileviewer/FileViewer.kt b/toolkit/popup/src/main/java/com/arcgismaps/toolkit/popup/internal/ui/fileviewer/FileViewer.kt new file mode 100644 index 000000000..ee88fbab8 --- /dev/null +++ b/toolkit/popup/src/main/java/com/arcgismaps/toolkit/popup/internal/ui/fileviewer/FileViewer.kt @@ -0,0 +1,262 @@ +/* + * Copyright 2024 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.popup.internal.ui.fileviewer + +import android.content.Intent +import android.util.Log +import android.widget.Toast +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +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.material.icons.Icons +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.MoreVert +import androidx.compose.material.icons.rounded.Save +import androidx.compose.material.icons.rounded.Share +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +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.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +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.compose.ui.viewinterop.AndroidView +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.core.content.FileProvider +import androidx.media3.common.MediaItem +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.ui.PlayerView +import coil.compose.AsyncImage +import com.arcgismaps.toolkit.popup.R +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import java.io.File + +/** + * A file viewer that can display different type of files. + * + * @since 200.5.0 + */ +@Composable +internal fun FileViewer(scope: CoroutineScope, fileState: ViewableFile, onDismissRequest: () -> Unit) { + if (fileState.type !is ViewableFileType.Other) { + Dialog( + onDismissRequest = onDismissRequest, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + TopAppBar(fileState, scope, onDismissRequest) + } + ) { + FileViewerContent(Modifier.padding(it), fileState) + } + } + } else { + val uri = FileProvider.getUriForFile( + LocalContext.current.applicationContext, + "${LocalContext.current.applicationContext.applicationInfo.packageName}.arcgis.popup.fileprovider", + File(fileState.path) + ) + + val intent = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, fileState.contentType) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + LocalContext.current.startActivity(intent) + onDismissRequest() + } +} + +@Composable +private fun FileViewerContent( + modifier: Modifier, + fileState: ViewableFile +) { + Box( + modifier = modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background), + contentAlignment = Alignment.Center + ) { + when (fileState.type) { + is ViewableFileType.Image -> + AsyncImage( + modifier = Modifier.fillMaxSize(), + model = fileState.path, + contentDescription = stringResource(id = R.string.image), + ) + + is ViewableFileType.Video, ViewableFileType.Audio -> VideoViewer(fileState.path) + else -> { + throw UnsupportedOperationException("Cannot view this file type") + } + } + } +} + +@Composable +private fun TopAppBar(fileState: ViewableFile, scope: CoroutineScope, onDismissRequest: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(64.dp) + .background(MaterialTheme.colorScheme.surface), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = { onDismissRequest() }) { + Icon( + Icons.Rounded.Close, + contentDescription = stringResource(id = R.string.close), + tint = MaterialTheme.colorScheme.onSurface + ) + } + Text( + modifier = Modifier.weight(1f), + text = fileState.name, + color = MaterialTheme.colorScheme.onSurface, + fontSize = MaterialTheme.typography.headlineSmall.fontSize, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + ViewerActions( + coroutineScope = scope, + viewableFile = fileState, + ) + } +} + +@Composable +private fun ViewerActions( + coroutineScope: CoroutineScope, + modifier: Modifier = Modifier, + viewableFile: ViewableFile, +) { + val context = LocalContext.current + val expanded = remember { mutableStateOf(false) } + Box(modifier = modifier) { + IconButton(onClick = { expanded.value = true }) { + Icon( + Icons.Rounded.MoreVert, + contentDescription = stringResource(id = R.string.more), + tint = MaterialTheme.colorScheme.onSurface + ) + } + + DropdownMenu(expanded = expanded.value, onDismissRequest = { expanded.value = false }) { + DropdownMenuItem( + text = { Text(stringResource(id = R.string.share), color = MaterialTheme.colorScheme.onSurface) }, + onClick = { + expanded.value = false + coroutineScope.launch { viewableFile.share(context) } + }, + leadingIcon = { + Icon( + Icons.Rounded.Share, + contentDescription = stringResource(id = R.string.share), + tint = MaterialTheme.colorScheme.onSurface + ) + } + ) + + DropdownMenuItem( + text = { + Text(text = stringResource(id = R.string.save), color = MaterialTheme.colorScheme.onSurface) + }, + onClick = { + expanded.value = false + coroutineScope.launch { + val saveResult = viewableFile.saveToDevice(context) + saveResult.onSuccess { + Toast.makeText(context, context.getString(R.string.save_successful), Toast.LENGTH_SHORT) + .show() + }.onFailure { + Toast.makeText(context, context.getString(R.string.save_failed), Toast.LENGTH_SHORT).show() + Log.e("ArcGISMapsSDK", "Failed to save file: $it") + } + } + }, + leadingIcon = { + Icon( + Icons.Rounded.Save, + contentDescription = stringResource(id = R.string.save), + tint = MaterialTheme.colorScheme.onSurface + ) + } + ) + } + } +} + +@Composable +internal fun VideoViewer(path: String) { + val context = LocalContext.current + val exoPlayer = remember { + ExoPlayer.Builder(context).build().apply { + val mediaItem = MediaItem.Builder() + .setUri(path) + .build() + setMediaItem(mediaItem) + prepare() + } + } + + AndroidView( + factory = { + PlayerView(context).apply { + player = exoPlayer + } + } + ) + DisposableEffect(Unit) { + onDispose { + exoPlayer.release() + } + } +} + +@Preview +@Composable +private fun FileViewerPreview() { + FileViewer( + scope = rememberCoroutineScope(), + fileState = ViewableFile( + path = "path", + name = "ArcGIS Pro", + size = 0, + type = ViewableFileType.Image, + contentType = "image/jpeg", + ), onDismissRequest = {} + ) +} diff --git a/toolkit/popup/src/main/java/com/arcgismaps/toolkit/popup/internal/ui/fileviewer/ViewableFile.kt b/toolkit/popup/src/main/java/com/arcgismaps/toolkit/popup/internal/ui/fileviewer/ViewableFile.kt new file mode 100644 index 000000000..ad66758c8 --- /dev/null +++ b/toolkit/popup/src/main/java/com/arcgismaps/toolkit/popup/internal/ui/fileviewer/ViewableFile.kt @@ -0,0 +1,153 @@ +/* + * Copyright 2024 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.popup.internal.ui.fileviewer + +import android.annotation.SuppressLint +import android.content.ContentValues +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Parcel +import android.os.Parcelable +import android.provider.MediaStore +import androidx.core.content.FileProvider +import com.arcgismaps.mapping.popup.PopupAttachmentType +import com.arcgismaps.toolkit.popup.R +import com.arcgismaps.toolkit.popup.internal.element.attachment.PopupAttachmentState +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.parcelize.Parceler +import kotlinx.parcelize.Parcelize +import kotlinx.parcelize.TypeParceler +import java.io.File +import java.io.FileNotFoundException +import java.io.IOException + +/** + * A file that can be viewed in the [FileViewer]. + */ +@Parcelize +internal data class ViewableFile( + val name: String, + val size: Long = 0, + val path: String, + @TypeParceler() val type: ViewableFileType, + val contentType: String = "image/jpeg" +) : Parcelable + +private object ViewableFileTypeParceler : Parceler { + override fun create(parcel: Parcel): ViewableFileType { + return when (parcel.readInt()) { + 0 -> ViewableFileType.Image + 1 -> ViewableFileType.Video + 2 -> ViewableFileType.Audio + else -> ViewableFileType.Other + } + } + + override fun ViewableFileType.write(parcel: Parcel, flags: Int) { + parcel.writeInt( + when (this) { + ViewableFileType.Image -> 0 + ViewableFileType.Video -> 1 + ViewableFileType.Audio -> 2 + ViewableFileType.Other -> 3 + } + ) + } +} + +internal sealed class ViewableFileType { + data object Image : ViewableFileType() + data object Video : ViewableFileType() + data object Audio : ViewableFileType() + data object Other : ViewableFileType() +} + +internal fun PopupAttachmentState.getViewableFileType(): ViewableFileType = when (this.popupAttachmentType) { + PopupAttachmentType.Image -> ViewableFileType.Image + PopupAttachmentType.Video -> ViewableFileType.Video + PopupAttachmentType.Document -> ViewableFileType.Other + PopupAttachmentType.Other -> + if (this.contentType.lowercase().contains("audio")) ViewableFileType.Audio else ViewableFileType.Other + +} + +/** + * Saves the file to the device. + */ +internal suspend fun ViewableFile.saveToDevice(context: Context): Result = withContext(Dispatchers.IO) { + runCatching { + val sourceFile = File(path).takeIf { it.exists() } + ?: throw FileNotFoundException("File not found: $path") + + // define the file values + val fileValues = ContentValues().apply { + put(MediaStore.Images.Media.DISPLAY_NAME, name) + put(MediaStore.Images.Media.MIME_TYPE, contentType) + } + + @SuppressLint("InlinedApi") + val uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) + MediaStore.VOLUME_EXTERNAL_PRIMARY + else + MediaStore.VOLUME_EXTERNAL + + val contentCollection = when (type) { + ViewableFileType.Video -> MediaStore.Video.Media.getContentUri(uri) + ViewableFileType.Image -> MediaStore.Images.Media.getContentUri(uri) + ViewableFileType.Audio -> MediaStore.Audio.Media.getContentUri(uri) + else -> throw UnsupportedOperationException("Cannot save this file type") + } + val destinationUri = context.contentResolver.insert(contentCollection, fileValues) + ?: throw IOException("Failed to save file") + + // copy file to destination + val sourceUri = Uri.fromFile(sourceFile) + context.contentResolver?.openInputStream(sourceUri)?.use { inputStream -> + context.contentResolver.openOutputStream(destinationUri)?.use { outputStream -> + val buffer = ByteArray(1024) + var length: Int + while (inputStream.read(buffer).also { length = it } > 0) { + outputStream.write(buffer, 0, length) + } + } + } ?: throw IOException("Failed to save file") + } +} + +/** + * Shares the file using Android's share sheet. + */ +internal suspend fun ViewableFile.share(context: Context) = withContext(Dispatchers.IO) { + val file = File(path) + + val uri = FileProvider.getUriForFile( + context.applicationContext, + "${context.applicationContext.applicationInfo.packageName}.arcgis.popup.fileprovider", + file + ) + val intent = Intent(Intent.ACTION_SEND).apply { + setDataAndType(uri, contentType) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + putExtra(Intent.EXTRA_STREAM, uri) + } + + context.startActivity( + Intent.createChooser(intent, context.getString(R.string.share)) + ) +} diff --git a/toolkit/popup/src/main/java/com/arcgismaps/toolkit/popup/internal/util/MediaImageProvider.kt b/toolkit/popup/src/main/java/com/arcgismaps/toolkit/popup/internal/util/MediaImageProvider.kt new file mode 100644 index 000000000..ea3a013ab --- /dev/null +++ b/toolkit/popup/src/main/java/com/arcgismaps/toolkit/popup/internal/util/MediaImageProvider.kt @@ -0,0 +1,50 @@ +/* + * Copyright 2024 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.popup.internal.util + +import android.graphics.Bitmap +import com.arcgismaps.mapping.popup.PopupMedia +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.BufferedOutputStream +import java.io.File +import java.io.FileOutputStream + +/** + * A class to persist popup media images on disk + * + * @property folderName the name of the folder in which to persist the image + * @property media the [PopupMedia] which the image represents + * @property imageGenerator a lambda which provides the bits to persist as an image. + */ +internal class MediaImageProvider( + private val folderName: String, + var media: PopupMedia, + private val imageGenerator: suspend (PopupMedia) -> Bitmap +) { + suspend fun get(fileName: String): String = withContext(Dispatchers.IO) { + val bitmap = imageGenerator(media) + val directory = File(folderName) + directory.mkdirs() + val file = File(directory, fileName) + file.createNewFile() + BufferedOutputStream(FileOutputStream(file)).use { bos -> + bitmap.compress(Bitmap.CompressFormat.PNG, 100, bos) + } + file.canonicalPath + } +} diff --git a/toolkit/popup/src/main/res/values/strings.xml b/toolkit/popup/src/main/res/values/strings.xml new file mode 100644 index 000000000..878835907 --- /dev/null +++ b/toolkit/popup/src/main/res/values/strings.xml @@ -0,0 +1,29 @@ + + + + expand popup element content + Share + Save + Save successful + Save failed + More + Image + Video + Other + Back + attachment thumbnail + diff --git a/toolkit/popup/src/main/res/xml/files.xml b/toolkit/popup/src/main/res/xml/files.xml new file mode 100644 index 000000000..359751298 --- /dev/null +++ b/toolkit/popup/src/main/res/xml/files.xml @@ -0,0 +1,19 @@ + + + + + diff --git a/toolkit/template/build.gradle.kts b/toolkit/template/build.gradle.kts index 5024926a1..41b767903 100644 --- a/toolkit/template/build.gradle.kts +++ b/toolkit/template/build.gradle.kts @@ -1,6 +1,6 @@ /* * - * Copyright 2023 Esri + * Copyright 2024 Esri * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -59,6 +59,19 @@ android { kotlinOptions.freeCompilerArgs += "-Xexplicit-api=strict" } } + + /** + * Configures the test report for connected (instrumented) tests to be copied to a central + * folder in the project's root directory. + */ + testOptions { + targetSdk = libs.versions.compileSdk.get().toInt() + val connectedTestReportsPath: String by project + reportDir = "$connectedTestReportsPath/${project.name}" + } + lint { + targetSdk = libs.versions.compileSdk.get().toInt() + } } dependencies { diff --git a/toolkit/template/src/androidTest/java/com/arcgismaps/toolkit/template/ExampleInstrumentedTest.kt b/toolkit/template/src/androidTest/java/com/arcgismaps/toolkit/template/ExampleInstrumentedTest.kt index fb7ee0cc0..5b31f3193 100644 --- a/toolkit/template/src/androidTest/java/com/arcgismaps/toolkit/template/ExampleInstrumentedTest.kt +++ b/toolkit/template/src/androidTest/java/com/arcgismaps/toolkit/template/ExampleInstrumentedTest.kt @@ -1,6 +1,6 @@ /* * - * Copyright 2023 Esri + * Copyright 2024 Esri * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -35,6 +35,6 @@ class ExampleInstrumentedTest { fun useAppContext() { // Context of the app under test. val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("com.arcgismaps.toolkit.authentication.test", appContext.packageName) + assertEquals("com.arcgismaps.toolkit.template.test", appContext.packageName) } } diff --git a/toolkit/template/src/main/AndroidManifest.xml b/toolkit/template/src/main/AndroidManifest.xml index 9b94d167c..73e6dbaf1 100644 --- a/toolkit/template/src/main/AndroidManifest.xml +++ b/toolkit/template/src/main/AndroidManifest.xml @@ -1,7 +1,7 @@