diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4318ae64c..e5c383274 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,6 +21,7 @@ hiltExt = "1.2.0" junit = "4.13.2" kotlin = "1.9.23" ksp = "1.9.23-1.0.20" +media3Exoplayer = "1.3.1" minSdk = "26" kotlinxCoroutinesTest = "1.8.0" kotlinxSerializationJson = "1.6.3" @@ -50,6 +51,9 @@ 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" } diff --git a/microapps/PopupApp/.gitignore b/microapps/PopupApp/.gitignore new file mode 100644 index 000000000..aa724b770 --- /dev/null +++ b/microapps/PopupApp/.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/PopupApp/README.md b/microapps/PopupApp/README.md new file mode 100644 index 000000000..783ef30cf --- /dev/null +++ b/microapps/PopupApp/README.md @@ -0,0 +1,9 @@ +# Popup Micro-app + +This micro-app demonstrates the use of the `Popup` toolkit component which provides a rich, responsive view of information about a GeoElement. + +## Usage + +The application provides a map viewer, which invokes the form when GeoElements are tapped. + +For more information on the `Popup` component and how it works, see it's [Readme](../../toolkit/popup/README.md). diff --git a/microapps/PopupApp/app/.gitignore b/microapps/PopupApp/app/.gitignore new file mode 100644 index 000000000..796b96d1c --- /dev/null +++ b/microapps/PopupApp/app/.gitignore @@ -0,0 +1 @@ +/build diff --git a/microapps/PopupApp/app/build.gradle.kts b/microapps/PopupApp/app/build.gradle.kts new file mode 100644 index 000000000..cdc8ef1c7 --- /dev/null +++ b/microapps/PopupApp/app/build.gradle.kts @@ -0,0 +1,105 @@ +/* + * + * 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.popupapp" + compileSdk = libs.versions.compileSdk.get().toInt() + + defaultConfig { + applicationId ="com.arcgismaps.toolkit.popupapp" + 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 = true + isShrinkResources = true + + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } + + buildFeatures { + compose = true + buildConfig = true + } + + composeOptions { + kotlinCompilerExtensionVersion = libs.versions.androidxComposeCompiler.get() + } + + /** + * 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}" + } + + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +dependencies { + implementation(project(":geoview-compose")) + implementation(arcgis.mapsSdk) + implementation(project(":popup")) + 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) + testImplementation(libs.bundles.unitTest) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.bundles.composeTest) + debugImplementation(libs.bundles.debug) +} diff --git a/microapps/PopupApp/app/proguard-rules.pro b/microapps/PopupApp/app/proguard-rules.pro new file mode 100644 index 000000000..f1b424510 --- /dev/null +++ b/microapps/PopupApp/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/microapps/PopupApp/app/src/main/AndroidManifest.xml b/microapps/PopupApp/app/src/main/AndroidManifest.xml new file mode 100644 index 000000000..05fd65c58 --- /dev/null +++ b/microapps/PopupApp/app/src/main/AndroidManifest.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + diff --git a/microapps/PopupApp/app/src/main/java/com/arcgismaps/toolkit/popupapp/MainActivity.kt b/microapps/PopupApp/app/src/main/java/com/arcgismaps/toolkit/popupapp/MainActivity.kt new file mode 100644 index 000000000..a2dd8ec3d --- /dev/null +++ b/microapps/PopupApp/app/src/main/java/com/arcgismaps/toolkit/popupapp/MainActivity.kt @@ -0,0 +1,81 @@ +/* + * + * + * 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.popupapp + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.viewModels +import androidx.compose.runtime.Composable +import com.arcgismaps.ArcGISEnvironment +import com.arcgismaps.httpcore.authentication.ArcGISAuthenticationChallenge +import com.arcgismaps.httpcore.authentication.ArcGISAuthenticationChallengeHandler +import com.arcgismaps.httpcore.authentication.ArcGISAuthenticationChallengeResponse +import com.arcgismaps.httpcore.authentication.TokenCredential +import com.arcgismaps.toolkit.popupapp.screens.mapscreen.MainScreen +import com.arcgismaps.toolkit.popupapp.screens.mapscreen.MapViewModel +import com.arcgismaps.toolkit.popupapp.ui.theme.PopupAppTheme + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val viewModel: MapViewModel by viewModels { MapViewModel.Factory } + ArcGISEnvironment.authenticationManager.arcGISAuthenticationChallengeHandler = + TestArcGISAuthenticationChallengeHandler( + BuildConfig.webMapUser, + BuildConfig.webMapPassword + ) + setContent { + PopupAppTheme { + PopupApp(viewModel) + } + } + } +} + +@Composable +fun PopupApp(viewModel: MapViewModel) { + MainScreen(viewModel) +} + +class TestArcGISAuthenticationChallengeHandler( + private val username: String, + private val password: String +) : ArcGISAuthenticationChallengeHandler { + override suspend fun handleArcGISAuthenticationChallenge( + challenge: ArcGISAuthenticationChallenge + ): ArcGISAuthenticationChallengeResponse { + val result: Result = + TokenCredential.create( + challenge.requestUrl, + username, + password, + tokenExpirationInterval = 0 + ) + return result.let { + if (it.isSuccess) { + ArcGISAuthenticationChallengeResponse.ContinueWithCredential(it.getOrThrow()) + } else { + ArcGISAuthenticationChallengeResponse.ContinueAndFailWithError(it.exceptionOrNull()!!) + } + } + } +} diff --git a/microapps/PopupApp/app/src/main/java/com/arcgismaps/toolkit/popupapp/screens/mapscreen/MainScreen.kt b/microapps/PopupApp/app/src/main/java/com/arcgismaps/toolkit/popupapp/screens/mapscreen/MainScreen.kt new file mode 100644 index 000000000..8cb25da2d --- /dev/null +++ b/microapps/PopupApp/app/src/main/java/com/arcgismaps/toolkit/popupapp/screens/mapscreen/MainScreen.kt @@ -0,0 +1,223 @@ +/* + * + * + * 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.popupapp.screens.mapscreen + +import android.widget.Toast +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.BottomSheetScaffold +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SheetValue +import androidx.compose.material3.rememberBottomSheetScaffoldState +import androidx.compose.material3.rememberStandardBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import com.arcgismaps.data.Feature +import com.arcgismaps.mapping.GeoElement +import com.arcgismaps.mapping.layers.FeatureLayer +import com.arcgismaps.mapping.layers.Layer +import com.arcgismaps.mapping.popup.Popup +import com.arcgismaps.realtime.DynamicEntityObservation +import com.arcgismaps.toolkit.geoviewcompose.MapView +import com.arcgismaps.toolkit.popup.Popup +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +private fun unselectFeature(feature: GeoElement?, layer: Layer?) { + if (feature is Feature && layer is FeatureLayer) { + layer.unselectFeature(feature) + } +} +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MainScreen(viewModel: MapViewModel) { + val scope = rememberCoroutineScope() + val context = LocalContext.current + + val scaffoldState = rememberBottomSheetScaffoldState( + bottomSheetState = rememberStandardBottomSheetState( + initialValue = SheetValue.Hidden, + skipHiddenState = false + ) + ) + BottomSheetScaffold( + sheetContent = { + AnimatedVisibility( + visible = viewModel.popup != null, + enter = slideInVertically { h -> h }, + exit = slideOutVertically { h -> h }, + label = "popup", + modifier = Modifier.heightIn(min = 0.dp, max = 400.dp) + ) { + if (viewModel.popup != null) { + Popup( + viewModel.popup!!, + modifier = Modifier.fillMaxSize() + ) + } + } + }, + modifier = Modifier.fillMaxSize(), + scaffoldState = scaffoldState, + sheetSwipeEnabled = true, + topBar = null + ) { padding -> + // show the composable map using the mapViewModel + MapView( + arcGISMap = viewModel.map, + mapViewProxy = viewModel.proxy, + modifier = Modifier + .padding(padding) + .fillMaxSize(), + onSingleTapConfirmed = { + scope.launch { + viewModel.proxy.identifyLayers( + screenCoordinate = it.screenCoordinate, + tolerance = 22.dp, + returnPopupsOnly = true + ).onSuccess { results -> + if (results.isEmpty()) { + unselectFeature(viewModel.geoElement, viewModel.layer) + viewModel.setPopup(null) + viewModel.setLayer(null) + viewModel.setGeoElement(null) + if (scaffoldState.bottomSheetState.currentValue == SheetValue.Expanded) { + scaffoldState.bottomSheetState.hide() + } + withContext(Dispatchers.Main) { + Toast.makeText( + context, + "Tap did not identify any Popups", + Toast.LENGTH_LONG + ).show() + } + } else { + try { + val result = results.first() + var popup = result.popups.first() + val newLayer = result.layerContent + var newGeoElement = popup.geoElement + + // if the identified GeoElement is a DynamicEntityObservation, + // get the underlying DynamicEntity and create a new Popup with + // the same definition. + if (newGeoElement is DynamicEntityObservation && newGeoElement.dynamicEntity != null) { + val dynamicEntity = newGeoElement.dynamicEntity + if (dynamicEntity != null) { + newGeoElement = dynamicEntity + popup = Popup( + newGeoElement, + popup.popupDefinition + ) + } + } + + if (viewModel.geoElement.sameSelection(newGeoElement)) { + withContext(Dispatchers.Main) { + Toast.makeText( + context, + "the same GeoElement was selected", + Toast.LENGTH_LONG + ).show() + } + } else { + unselectFeature( + viewModel.geoElement, + viewModel.layer + ) + + when (newLayer) { + is FeatureLayer -> { + // the Popup exists on a FeatureLayer + if (newGeoElement is Feature) { + // the tap was on a Feature + // unselect any previously selected Feature + newLayer.selectFeature(newGeoElement) + } + // otherwise the tap was on some non-Feature GeoElement + viewModel.setLayer(newLayer) + viewModel.setGeoElement(newGeoElement) + } + + is Layer -> { + // the popup exists on a non-FeatureLayer + viewModel.setLayer(newLayer) + viewModel.setGeoElement(newGeoElement) + } + + else -> { + // the popup exists on a sublayer, which is a complication + // that doesn't offer any testing value for the Popup + // toolkit component. + throw IllegalStateException("popups on sublayers are not supported by the PopupApp") + } + } + viewModel.setPopup(popup) + scaffoldState.bottomSheetState.expand() + } + } catch (e: Exception) { + e.printStackTrace() + withContext(Dispatchers.Main) { + Toast.makeText( + context, + "failed to create a Popup for the GeoElement", + Toast.LENGTH_LONG + ).show() + } + } + } + } + } + } + ) + } +} + +private fun GeoElement?.sameSelection(other: GeoElement): Boolean = + if (this == null) { + false + } else { + val geometriesEqual = if (geometry != null && other.geometry != null) { + this.geometry == other.geometry + } else if (geometry == null && other.geometry == null) { + true + } else { + false + } + + if (geometriesEqual) { + attributes.entries.all { + other.attributes[it.key]?.let { otherValue -> + it.value == otherValue + } ?: (it.value == null) + } + } else { + false + } + } diff --git a/microapps/PopupApp/app/src/main/java/com/arcgismaps/toolkit/popupapp/screens/mapscreen/MapViewModel.kt b/microapps/PopupApp/app/src/main/java/com/arcgismaps/toolkit/popupapp/screens/mapscreen/MapViewModel.kt new file mode 100644 index 000000000..256f6269a --- /dev/null +++ b/microapps/PopupApp/app/src/main/java/com/arcgismaps/toolkit/popupapp/screens/mapscreen/MapViewModel.kt @@ -0,0 +1,150 @@ +/* + * 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.popupapp.screens.mapscreen + +import android.app.Application +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY +import androidx.lifecycle.createSavedStateHandle +import androidx.lifecycle.viewmodel.CreationExtras +import com.arcgismaps.mapping.ArcGISMap +import com.arcgismaps.mapping.GeoElement +import com.arcgismaps.mapping.PortalItem +import com.arcgismaps.mapping.Viewpoint +import com.arcgismaps.mapping.layers.Layer +import com.arcgismaps.mapping.popup.Popup +import com.arcgismaps.portal.Portal +import com.arcgismaps.toolkit.geoviewcompose.MapViewProxy +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import java.io.Closeable +import kotlin.coroutines.CoroutineContext + +/** + * Base class for context aware AndroidViewModel. This class must have only a single application + * parameter. + */ +open class BaseMapViewModel(application: Application) : AndroidViewModel(application) + +/** + * Simple android view model for the Popup app map screen. + */ +@Suppress("unused_parameter") +class MapViewModel( + savedStateHandle: SavedStateHandle, + application: Application, + coroutineScope: CoroutineScope = CloseableCoroutineScope() +) : BaseMapViewModel(application) { + + private var _geoElement: GeoElement? = null + val geoElement: GeoElement? + get() = _geoElement + + private var _layer: Layer? = null + val layer: Layer? + get() = _layer + + @Suppress("unused") + private val fourteenersId = "9f3a674e998f461580006e626611f9ad" + @Suppress("unused") + private val ranchoId = "dd94764601554f1ea958f2d81906c698" + @Suppress("unused") + private val streamServiceMap = "aef32323d1f248368b1663cfc938995e" + + /** + * The Popup read by the composition is held as a state variable. + * We want the composition to recompose when the Popup changes. + */ + private var _popup: MutableState = mutableStateOf(null) + val popup: Popup? + get() = _popup.value + + val map = ArcGISMap( + PortalItem( + Portal.arcGISOnline(Portal.Connection.Authenticated), + fourteenersId + ) + ).apply { + Viewpoint(40.559691, -111.869001, 150000.0) + } + + val proxy: MapViewProxy = MapViewProxy() + + init { + coroutineScope.launch { + map.load() + } + } + + fun setGeoElement(element: GeoElement?) { + _geoElement = element + } + + fun setLayer(layer: Layer?) { + _layer = layer + } + + fun setPopup(popup: Popup?) { + _popup.value = popup + } + companion object { + + /** + * The factory needed by the androidx ktx component activity to instantiate the view model. + * See onCreate() for usage. + */ + val Factory: ViewModelProvider.Factory = object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create( + modelClass: Class, + extras: CreationExtras + ): T { + // Get the Application object from extras + val application = checkNotNull(extras[APPLICATION_KEY]) + // Create a SavedStateHandle for this ViewModel from extras + val savedStateHandle = extras.createSavedStateHandle() + + return MapViewModel( + savedStateHandle, + application + ) as T + } + } + } + +} + +/** + * a CoroutineScope used by the view model. It will by closed when the view model exits its + * lifecycle. + */ +class CloseableCoroutineScope( + context: CoroutineContext = SupervisorJob() + Dispatchers.Main.immediate +) : Closeable, CoroutineScope { + override val coroutineContext: CoroutineContext = context + override fun close() { + coroutineContext.cancel() + } +} diff --git a/microapps/PopupApp/app/src/main/java/com/arcgismaps/toolkit/popupapp/ui/theme/Color.kt b/microapps/PopupApp/app/src/main/java/com/arcgismaps/toolkit/popupapp/ui/theme/Color.kt new file mode 100644 index 000000000..358b0873a --- /dev/null +++ b/microapps/PopupApp/app/src/main/java/com/arcgismaps/toolkit/popupapp/ui/theme/Color.kt @@ -0,0 +1,29 @@ +/* + * + * 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.popupapp.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/PopupApp/app/src/main/java/com/arcgismaps/toolkit/popupapp/ui/theme/Theme.kt b/microapps/PopupApp/app/src/main/java/com/arcgismaps/toolkit/popupapp/ui/theme/Theme.kt new file mode 100644 index 000000000..147db3628 --- /dev/null +++ b/microapps/PopupApp/app/src/main/java/com/arcgismaps/toolkit/popupapp/ui/theme/Theme.kt @@ -0,0 +1,90 @@ +/* + * + * + * 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.popupapp.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 PopupAppTheme( + 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/PopupApp/app/src/main/java/com/arcgismaps/toolkit/popupapp/ui/theme/Type.kt b/microapps/PopupApp/app/src/main/java/com/arcgismaps/toolkit/popupapp/ui/theme/Type.kt new file mode 100644 index 000000000..e64aa793e --- /dev/null +++ b/microapps/PopupApp/app/src/main/java/com/arcgismaps/toolkit/popupapp/ui/theme/Type.kt @@ -0,0 +1,52 @@ +/* + * + * 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.popupapp.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/PopupApp/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/microapps/PopupApp/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 000000000..92971e871 --- /dev/null +++ b/microapps/PopupApp/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + diff --git a/microapps/PopupApp/app/src/main/res/drawable/ic_launcher_background.xml b/microapps/PopupApp/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 000000000..b51b347d8 --- /dev/null +++ b/microapps/PopupApp/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,188 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/microapps/PopupApp/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/microapps/PopupApp/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 000000000..6b4a339aa --- /dev/null +++ b/microapps/PopupApp/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/microapps/PopupApp/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/microapps/PopupApp/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 000000000..6b4a339aa --- /dev/null +++ b/microapps/PopupApp/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/microapps/PopupApp/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/microapps/PopupApp/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 000000000..c209e78ec Binary files /dev/null and b/microapps/PopupApp/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/microapps/PopupApp/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/microapps/PopupApp/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 000000000..b2dfe3d1b Binary files /dev/null and b/microapps/PopupApp/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/microapps/PopupApp/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/microapps/PopupApp/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 000000000..4f0f1d64e Binary files /dev/null and b/microapps/PopupApp/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/microapps/PopupApp/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/microapps/PopupApp/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 000000000..62b611da0 Binary files /dev/null and b/microapps/PopupApp/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/microapps/PopupApp/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/microapps/PopupApp/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 000000000..948a3070f Binary files /dev/null and b/microapps/PopupApp/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/microapps/PopupApp/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/microapps/PopupApp/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 000000000..1b9a6956b Binary files /dev/null and b/microapps/PopupApp/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/microapps/PopupApp/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/microapps/PopupApp/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 000000000..28d4b77f9 Binary files /dev/null and b/microapps/PopupApp/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/microapps/PopupApp/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/microapps/PopupApp/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 000000000..9287f5083 Binary files /dev/null and b/microapps/PopupApp/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/microapps/PopupApp/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/microapps/PopupApp/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 000000000..aa7d6427e Binary files /dev/null and b/microapps/PopupApp/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/microapps/PopupApp/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/microapps/PopupApp/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 000000000..9126ae37c Binary files /dev/null and b/microapps/PopupApp/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/microapps/PopupApp/app/src/main/res/values/colors.xml b/microapps/PopupApp/app/src/main/res/values/colors.xml new file mode 100644 index 000000000..6c58071d0 --- /dev/null +++ b/microapps/PopupApp/app/src/main/res/values/colors.xml @@ -0,0 +1,28 @@ + + + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + diff --git a/microapps/PopupApp/app/src/main/res/values/strings.xml b/microapps/PopupApp/app/src/main/res/values/strings.xml new file mode 100644 index 000000000..62eff7983 --- /dev/null +++ b/microapps/PopupApp/app/src/main/res/values/strings.xml @@ -0,0 +1,21 @@ + + + + PopupApp + diff --git a/microapps/PopupApp/app/src/main/res/values/themes.xml b/microapps/PopupApp/app/src/main/res/values/themes.xml new file mode 100644 index 000000000..7357a57fa --- /dev/null +++ b/microapps/PopupApp/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..7198fef60 --- /dev/null +++ b/toolkit/popup/src/main/java/com/arcgismaps/toolkit/popup/internal/ui/ExpandableCard.kt @@ -0,0 +1,180 @@ +/* + * 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( + 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, + isExpanded = expanded + ) { + if (toggleable) { + expanded = !expanded + } + } + + AnimatedVisibility(visible = expanded) { + content() + } + + } + } +} + +@Composable +private fun ExpandableHeader( + title: String = "", + description: String = "", + expandable: Boolean, + 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, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (description.isNotEmpty() && isExpanded) { + Text( + text = description, + 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", + 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..ccd7fbb49 --- /dev/null +++ b/toolkit/popup/src/main/java/com/arcgismaps/toolkit/popup/internal/ui/ExpandableCardDefaults.kt @@ -0,0 +1,52 @@ +/* + * 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() : AttachmentElementColors = AttachmentElementColors( + 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 AttachmentElementColors( + 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..8d8cd8771 --- /dev/null +++ b/toolkit/popup/src/main/java/com/arcgismaps/toolkit/popup/internal/util/MediaImageProvider.kt @@ -0,0 +1,49 @@ +/* + * 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 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 fileName the name of the file to persist + * @property folderName the parent folder in which to save the popup media folder + * @property imageGenerator a lambda which provides the bits to persist as an image. + */ +internal class MediaImageProvider( + private val fileName: String, + private val folderName: String, + private val imageGenerator: suspend () -> Bitmap +) { + suspend fun get(): String = withContext(Dispatchers.IO) { + val bitmap = imageGenerator() + 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 @@ + + + + +