diff --git a/microapps/OfflineMapAreasApp/README.md b/microapps/OfflineMapAreasApp/README.md
index 3e65b6667..e56580df4 100644
--- a/microapps/OfflineMapAreasApp/README.md
+++ b/microapps/OfflineMapAreasApp/README.md
@@ -2,12 +2,8 @@
This micro-app demonstrates the use of the `OfflineMapAreas` toolkit component to take a web map offline by downloading map areas.
-| Online map | Opened map | Offline mode |
-|:---: |--- |:---: |
-|
|
|
|
-
## Usage
-The application displays the ahead-of-time (preplanned) maps available to download using the stormwater network within Naperville [web-map](https://arcgisruntime.maps.arcgis.com/home/item.html?id=acc027394bc84c2fb04d1ed317aac674). This app displays, downloads, and monitors the status of map areas, allowing users to view details or remove downloaded files. For preplanned maps, it shows available areas when online and downloaded ones when offline; for on-demand, users can define and download custom map areas.
+ToDo…
-For more information on the `OfflineMapAreas` component and how it works, see its [Readme](../../toolkit/offline).
\ No newline at end of file
+For more information on the `OfflineMapAreas` component and how it works, see its [Readme](../../toolkit/offline).
diff --git a/microapps/OfflineMapAreasApp/app/build.gradle.kts b/microapps/OfflineMapAreasApp/app/build.gradle.kts
index 841c7bf79..d77c65404 100644
--- a/microapps/OfflineMapAreasApp/app/build.gradle.kts
+++ b/microapps/OfflineMapAreasApp/app/build.gradle.kts
@@ -21,6 +21,8 @@ plugins {
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
id("com.google.android.libraries.mapsplatform.secrets-gradle-plugin")
+ id("com.google.dagger.hilt.android")
+ id("com.google.devtools.ksp")
}
secrets {
@@ -77,16 +79,33 @@ android {
}
dependencies {
+ implementation(project(":authentication"))
implementation(project(":geoview-compose"))
- implementation(arcgis.mapsSdk)
implementation(project(":offline"))
implementation(project(":microapps-lib"))
+ // sdk
+ implementation(arcgis.mapsSdk)
+ // hilt
+ implementation(libs.hilt.android.core)
+ implementation(libs.androidx.hilt.navigation.compose)
+ ksp(libs.hilt.compiler)
+ // room
+ implementation(libs.room.runtime)
+ annotationProcessor(libs.room.compiler)
+ implementation(libs.room.ext)
+ ksp(libs.room.compiler)
+ // jetpack window manager
+ 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.activity.compose)
+ implementation(libs.androidx.compose.navigation)
implementation(libs.androidx.lifecycle.viewmodel.compose)
testImplementation(libs.bundles.unitTest)
+ testImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.bundles.composeTest)
debugImplementation(libs.bundles.debug)
diff --git a/microapps/OfflineMapAreasApp/app/src/main/AndroidManifest.xml b/microapps/OfflineMapAreasApp/app/src/main/AndroidManifest.xml
index 4484e24e8..df8305fd5 100644
--- a/microapps/OfflineMapAreasApp/app/src/main/AndroidManifest.xml
+++ b/microapps/OfflineMapAreasApp/app/src/main/AndroidManifest.xml
@@ -25,6 +25,7 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/MainActivity.kt b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/MainActivity.kt
index 1acd8c448..87c3f5643 100644
--- a/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/MainActivity.kt
+++ b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/MainActivity.kt
@@ -1,6 +1,6 @@
/*
*
- * Copyright 2025 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.
@@ -26,41 +26,113 @@ import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts.RequestPermission
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
-import com.arcgismaps.ApiKey
+import androidx.lifecycle.lifecycleScope
+import androidx.navigation.compose.rememberNavController
import com.arcgismaps.ArcGISEnvironment
-import com.arcgismaps.toolkit.offlinemapareasapp.screens.MainScreen
-import com.esri.microappslib.theme.MicroAppTheme
+import com.arcgismaps.httpcore.authentication.ArcGISCredentialStore
+import com.arcgismaps.httpcore.authentication.NetworkCredentialStore
+import com.arcgismaps.portal.Portal
+import com.arcgismaps.toolkit.offlinemapareasapp.data.PortalSettings
+import com.arcgismaps.toolkit.offlinemapareasapp.navigation.AppNavigation
+import com.arcgismaps.toolkit.offlinemapareasapp.navigation.NavigationRoute
+import com.arcgismaps.toolkit.offlinemapareasapp.navigation.Navigator
+import com.arcgismaps.toolkit.offlinemapareasapp.theme.OfflineMapAreasAppTheme
+import dagger.hilt.EntryPoint
+import dagger.hilt.InstallIn
+import dagger.hilt.android.AndroidEntryPoint
+import dagger.hilt.android.EntryPointAccessors
+import dagger.hilt.components.SingletonComponent
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import javax.inject.Inject
+@AndroidEntryPoint
class MainActivity : ComponentActivity() {
+
+ // Interface to get the PortalSettings instance from Hilt
+ @EntryPoint
+ @InstallIn(SingletonComponent::class)
+ interface PortalSettingsFactory {
+ fun getPortalSettings(): PortalSettings
+ }
+
+ @Inject
+ lateinit var navigator: Navigator
+
+ private val appState: MutableStateFlow = MutableStateFlow(AppState.Loading)
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
- ArcGISEnvironment.apiKey = ApiKey.create(BuildConfig.API_KEY)
+ ArcGISEnvironment.applicationContext = this
+ enableEdgeToEdge()
setContent {
- MicroAppTheme {
- OfflineMapAreasApp()
- RequestNotificationPermission(
- onResult = { isGranted ->
- if (!isGranted) {
- Log.e("OfflineMapAreas", "Notification permission request was denied.")
- }
- })
+ OfflineMapAreasAppTheme {
+ OfflineMapAreasApp(
+ appState.collectAsState().value,
+ navigator
+ )
}
}
+ lifecycleScope.launch {
+ // fetch the singleton PortalSettings
+ val factory = EntryPointAccessors.fromApplication(
+ this@MainActivity,
+ PortalSettingsFactory::class.java
+ )
+ loadCredentials(factory.getPortalSettings())
+ }
}
-}
-@Composable
-fun OfflineMapAreasApp() {
- MainScreen()
+ private suspend fun loadCredentials(portalSettings: PortalSettings) =
+ withContext(Dispatchers.Default) {
+ // create and set a ArcGISCredentialStore that persists
+ val arcGISCredentialStore = ArcGISCredentialStore.createWithPersistence().getOrThrow()
+ ArcGISEnvironment.authenticationManager.arcGISCredentialStore = arcGISCredentialStore
+ // create and set a NetworkCredentialStore that persists
+ val networkCredentialStore = NetworkCredentialStore.createWithPersistence().getOrThrow()
+ ArcGISEnvironment.authenticationManager.networkCredentialStore = networkCredentialStore
+ // get the portal settings url
+ val url = portalSettings.getPortalUrl()
+ // check if any credentials are present for this portal
+ val credential =
+ ArcGISEnvironment.authenticationManager.arcGISCredentialStore.getCredential(url)
+ appState.value = if (credential == null) {
+ // if the portal connection type set it Anonymous, then the user has skipped sign in
+ if (portalSettings.getPortalConnection() == Portal.Connection.Anonymous) {
+ AppState.SkipSignIn
+ } else {
+ AppState.NotLoggedIn
+ }
+ } else {
+ AppState.LoggedIn
+ }
+ }
}
@Composable
@@ -102,3 +174,75 @@ private fun RequestNotificationPermission(
}
}
}
+
+@Composable
+fun OfflineMapAreasApp(
+ appState: AppState,
+ navigator: Navigator
+) {
+ if (appState is AppState.Loading) {
+ AnimatedLoading({ true }, modifier = Modifier.fillMaxSize())
+ } else {
+ // create a NavController
+ val navController = rememberNavController()
+ // if the user has logged in or skipped sign in, go to the Home screen, else present
+ // login screen
+ val startDestination =
+ if (appState is AppState.LoggedIn || appState is AppState.SkipSignIn) {
+ NavigationRoute.Home.route
+ } else {
+ NavigationRoute.Login.route
+ }
+ AppNavigation(
+ navController = navController,
+ navigator = navigator,
+ startDestination = startDestination
+ )
+ }
+ RequestNotificationPermission(
+ onResult = { isGranted ->
+ if (!isGranted) {
+ Log.e("OfflineMapAreas", "Notification permission request was denied.")
+ }
+ })
+}
+
+@Composable
+fun AnimatedLoading(
+ visibilityProvider: () -> Boolean,
+ modifier: Modifier = Modifier,
+ backgroundColor: Color = MaterialTheme.colorScheme.surface,
+ statusText: String = "",
+) {
+ val visible = visibilityProvider()
+ if (visible) {
+ Surface(
+ modifier = modifier,
+ color = backgroundColor
+ ) {
+ Column(
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(30.dp),
+ strokeWidth = 5.dp
+ )
+ if (statusText.isNotEmpty()) {
+ Spacer(modifier = Modifier.size(10.dp))
+ Text(text = statusText)
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Represents the current app state based on the login state of the user.
+ */
+sealed class AppState {
+ data object Loading : AppState()
+ data object LoggedIn : AppState()
+ data object NotLoggedIn : AppState()
+ data object SkipSignIn : AppState()
+}
diff --git a/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/OfflineMapAreasApplication.kt b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/OfflineMapAreasApplication.kt
new file mode 100644
index 000000000..e6c707eac
--- /dev/null
+++ b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/OfflineMapAreasApplication.kt
@@ -0,0 +1,25 @@
+/*
+ *
+ * Copyright 2023 Esri
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package com.arcgismaps.toolkit.offlinemapareasapp
+
+import android.app.Application
+import dagger.hilt.android.HiltAndroidApp
+
+@HiltAndroidApp
+class OfflineMapAreasApplication : Application()
diff --git a/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/data/PortalItemRepository.kt b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/data/PortalItemRepository.kt
new file mode 100644
index 000000000..736abb792
--- /dev/null
+++ b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/data/PortalItemRepository.kt
@@ -0,0 +1,172 @@
+/*
+ *
+ * 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.offlinemapareasapp.data
+
+import android.util.Log
+import com.arcgismaps.LoadStatus
+import com.arcgismaps.mapping.PortalItem
+import com.arcgismaps.portal.Portal
+import com.arcgismaps.toolkit.offlinemapareasapp.data.local.ItemCacheDao
+import com.arcgismaps.toolkit.offlinemapareasapp.data.local.ItemCacheEntry
+import com.arcgismaps.toolkit.offlinemapareasapp.data.local.ItemData
+import com.arcgismaps.toolkit.offlinemapareasapp.data.network.ItemRemoteDataSource
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.mapLatest
+import kotlinx.coroutines.joinAll
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withContext
+
+/**
+ * A repository to map the data source items into loaded PortalItems. This is the primary repository
+ * to interact with by the UI/domain layer. This also uses the [ItemCacheDao] to provide a caching
+ * mechanism.
+ */
+class PortalItemRepository(
+ private val dispatcher: CoroutineDispatcher,
+ private val remoteDataSource: ItemRemoteDataSource,
+ private val itemCacheDao: ItemCacheDao,
+ private val filesDir: String
+) {
+ // in memory cache of loaded portal items
+ private val portalItems: MutableMap = mutableMapOf()
+
+ // to protect shared state of portalItems
+ private val mutex = Mutex()
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ private val portalItemsFlow: Flow> =
+ itemCacheDao.observeAll().mapLatest { entries ->
+ // map the cache entries into loaded portal items
+ entries.mapNotNull { entry ->
+ val portal = Portal(entry.portalUrl)
+ val portalItem = PortalItem.fromJsonOrNull(entry.json, portal)
+ portalItem?.let {
+ portalItem.also {
+ portalItems[portalItem.itemId] = it
+ }
+ }
+ }
+ }.flowOn(dispatcher)
+
+ /**
+ * Returns the list of loaded PortalItemData as a flow.
+ */
+ fun observe(): Flow> = portalItemsFlow
+
+ /**
+ * Refreshes the underlying data source to fetch the latest content.
+ *
+ * This operation is suspending and will wait until the underlying data source has finished
+ * AND the repository has finished loading the portal items.
+ */
+ suspend fun refresh(
+ portalUri: String,
+ connection: Portal.Connection
+ ) = withContext(dispatcher) {
+ mutex.withLock {
+ // delete existing cache items
+ itemCacheDao.deleteAll()
+ portalItems.clear()
+ // get local items
+ val localItems = getListOfMaps().map { ItemData(it) }
+ // get network items
+ val remoteItems = remoteDataSource.fetchItemData(portalUri, connection)
+ // load the portal items and add them to cache
+ loadAndCachePortalItems(localItems + remoteItems)
+ }
+ }
+
+ /**
+ * Deletes all the portal items from the local cache storage.
+ */
+ suspend fun deleteAll() = withContext(Dispatchers.IO) {
+ mutex.withLock {
+ itemCacheDao.deleteAll()
+ portalItems.clear()
+ }
+ }
+
+ /**
+ * Returns the number of items in the repository.
+ */
+ suspend fun getItemCount(): Int = withContext(dispatcher) {
+ itemCacheDao.getCount()
+ }
+
+ /**
+ * Loads the list of [items] into loaded portal items and adds them to the Cache.
+ */
+ private suspend fun loadAndCachePortalItems(items: List) = withContext(dispatcher) {
+ // create PortalItems from the urls
+ val portalItems = items.map {
+ PortalItem(it.url)
+ }
+ portalItems.map { item ->
+ // load each portal item and its thumbnail in a new coroutine
+ launch {
+ item.load().onFailure {
+ Log.e("PortalItemRepository", "loadAndCachePortalItems: $it")
+ }
+ item.thumbnail?.load()
+ }
+ // suspend till all the portal loading jobs are complete
+ }.joinAll()
+ // create entries to be inserted into the local cache storage.
+ val entries = portalItems.mapNotNull { item ->
+ // ignore if the portal item failed to load
+ if (item.loadStatus.value is LoadStatus.FailedToLoad) {
+ null
+ } else {
+ ItemCacheEntry(
+ itemId = item.itemId,
+ json = item.toJson(),
+ portalUrl = item.portal.url
+ )
+ }
+ }
+ // insert all the items into the local cache storage
+ insertCacheEntries(entries)
+ }
+
+ /**
+ * Deletes and inserts the list of [entries] using the [ItemCacheDao].
+ */
+ private suspend fun insertCacheEntries(entries: List) =
+ withContext(dispatcher) {
+ itemCacheDao.deleteAndInsert(entries)
+ }
+
+ operator fun invoke(itemId: String): PortalItem? = portalItems[itemId]
+}
+
+/**
+ * Local data source of a list of portal urls
+ */
+fun getListOfMaps(): List =
+ listOf(
+ "https://www.arcgis.com/home/item.html?id=acc027394bc84c2fb04d1ed317aac674",
+ "https://www.arcgis.com/home/item.html?id=3da658f2492f4cfd8494970ef489d2c5",
+ "https://www.arcgis.com/home/item.html?id=b95fe18073bc4f7788f0375af2bb445e"
+ )
diff --git a/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/data/PortalSettings.kt b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/data/PortalSettings.kt
new file mode 100644
index 000000000..3eb1b7cb8
--- /dev/null
+++ b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/data/PortalSettings.kt
@@ -0,0 +1,75 @@
+/*
+ * 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.offlinemapareasapp.data
+
+import android.content.Context
+import android.content.SharedPreferences
+import com.arcgismaps.ArcGISEnvironment
+import com.arcgismaps.portal.Portal
+import com.arcgismaps.toolkit.authentication.signOut
+import com.arcgismaps.toolkit.offlinemapareasapp.R
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+class PortalSettings(
+ context: Context
+) {
+ private val preferences: SharedPreferences =
+ context.getSharedPreferences("portal_settings", Context.MODE_PRIVATE)
+
+ val defaultPortalUrl: String = context.getString(R.string.agol_portal_url)
+
+ private val urlKey = "url"
+ private val connectionKey = "connection"
+
+ fun getPortalConnection() : Portal.Connection {
+ val connection = preferences.getInt(connectionKey, 0)
+ return if (connection == 0) {
+ Portal.Connection.Authenticated
+ } else {
+ Portal.Connection.Anonymous
+ }
+ }
+
+ suspend fun setPortalConnection(connection: Portal.Connection) = withContext(Dispatchers.IO) {
+ with(preferences.edit()) {
+ val value = if (connection is Portal.Connection.Authenticated) {
+ 0
+ } else {
+ 1
+ }
+ putInt(connectionKey, value)
+ commit()
+ }
+ }
+
+ fun getPortalUrl(): String {
+ return preferences.getString(urlKey, "") ?: ""
+ }
+
+ suspend fun setPortalUrl(url: String) = withContext(Dispatchers.IO) {
+ with(preferences.edit()) {
+ putString(urlKey, url)
+ commit()
+ }
+ }
+
+ suspend fun signOut() = withContext(Dispatchers.IO) {
+ setPortalConnection(Portal.Connection.Authenticated)
+ ArcGISEnvironment.authenticationManager.signOut()
+ }
+}
diff --git a/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/data/local/ItemCacheDao.kt b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/data/local/ItemCacheDao.kt
new file mode 100644
index 000000000..4c76df461
--- /dev/null
+++ b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/data/local/ItemCacheDao.kt
@@ -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.
+ *
+ */
+
+package com.arcgismaps.toolkit.offlinemapareasapp.data.local
+
+import androidx.room.Dao
+import androidx.room.Database
+import androidx.room.Entity
+import androidx.room.Insert
+import androidx.room.PrimaryKey
+import androidx.room.Query
+import androidx.room.RoomDatabase
+import androidx.room.Transaction
+import kotlinx.coroutines.flow.Flow
+
+/**
+ * Model to represent a PortalItem Cache entry.
+ */
+@Entity
+data class ItemCacheEntry(
+ @PrimaryKey val itemId: String,
+ val json: String,
+ val portalUrl: String
+)
+
+@Dao
+interface ItemCacheDao {
+
+ /**
+ * Insert an item into the itemcacheentry table.
+ *
+ * @param item the ItemCacheEntry type to insert.
+ */
+ @Insert
+ suspend fun insert(item: ItemCacheEntry) : Long
+
+ /**
+ * Observes list of ItemData.
+ *
+ * @return all ItemData.
+ */
+ @Query("SELECT * FROM itemcacheentry")
+ fun observeAll(): Flow>
+
+ /**
+ * fetch an entry by id.
+ *
+ * @param itemId the item id.
+ * @return the cache entry with the item id.
+ */
+ @Query("SELECT * FROM itemcacheentry WHERE itemId = :itemId")
+ suspend fun getById(itemId: String): ItemCacheEntry?
+
+ /**
+ * Get the number of items in the table.
+ *
+ * @return the number of items.
+ */
+ @Query("SELECT COUNT(*) FROM itemcacheentry")
+ suspend fun getCount() : Int
+
+
+ /**
+ * Deletes all existing items in the table and inserts the new list [items].
+ *
+ * @param items the list of items to insert.
+ * @return the list of row id's that were inserted.
+ */
+ @Transaction
+ suspend fun deleteAndInsert(items: List) : List {
+ deleteAll()
+ return items.map {
+ insert(it)
+ }
+ }
+
+ /**
+ * Delete all entries.
+ */
+ @Query("DELETE FROM itemcacheentry")
+ suspend fun deleteAll()
+}
+
+/**
+ * The room database that contains the ItemCacheEntry table.
+ */
+@Database(entities = [ItemCacheEntry::class], version = 2, exportSchema = false)
+abstract class ItemCacheDatabase : RoomDatabase() {
+ abstract fun itemCacheDao() : ItemCacheDao
+}
diff --git a/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/data/local/ItemData.kt b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/data/local/ItemData.kt
new file mode 100644
index 000000000..6edf22c92
--- /dev/null
+++ b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/data/local/ItemData.kt
@@ -0,0 +1,33 @@
+/*
+ *
+ * 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.offlinemapareasapp.data.local
+
+import com.arcgismaps.portal.Portal
+
+/**
+ * the data for the item. Just an URL, but abstracted away
+ */
+data class ItemData(val url: String)
+
+/**
+ * The API to use to get the items
+ */
+interface ItemApi {
+ suspend fun fetchItems(portalUri: String, connection : Portal.Connection): List
+}
diff --git a/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/data/local/UrlHistoryDao.kt b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/data/local/UrlHistoryDao.kt
new file mode 100644
index 000000000..37cc61250
--- /dev/null
+++ b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/data/local/UrlHistoryDao.kt
@@ -0,0 +1,70 @@
+/*
+ * 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.offlinemapareasapp.data.local
+
+import androidx.room.Dao
+import androidx.room.Database
+import androidx.room.Delete
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+import androidx.room.Query
+import androidx.room.RoomDatabase
+import androidx.room.Upsert
+import kotlinx.coroutines.flow.Flow
+
+/**
+ * Model to represent a url entry.
+ */
+@Entity
+data class UrlEntry(
+ @PrimaryKey val url: String,
+)
+
+@Dao
+interface UrlHistoryDao {
+
+ /**
+ * Insert an item into the url entry table.
+ *
+ * @param entry the url to insert.
+ * @return the rowId for the inserted item.
+ */
+ @Upsert
+ suspend fun insert(entry: UrlEntry) : Long
+
+ /**
+ * Observes list of url entries.
+ *
+ * @return all entries.
+ */
+ @Query("SELECT * FROM urlentry")
+ fun observeAll(): Flow>
+
+ /**
+ * Deletes an entry.
+ */
+ @Delete
+ suspend fun delete(entry: UrlEntry)
+}
+
+/**
+ * The room database that contains the [UrlEntry] table.
+ */
+@Database(entities = [UrlEntry::class], version = 1, exportSchema = false)
+abstract class UrlHistoryDatabase : RoomDatabase() {
+ abstract fun urlHistoryDao() : UrlHistoryDao
+}
diff --git a/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/data/network/ItemRemoteDataSource.kt b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/data/network/ItemRemoteDataSource.kt
new file mode 100644
index 000000000..4c85877c1
--- /dev/null
+++ b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/data/network/ItemRemoteDataSource.kt
@@ -0,0 +1,85 @@
+/*
+ *
+ * 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.offlinemapareasapp.data.network
+
+import android.util.Log
+import com.arcgismaps.portal.Portal
+import com.arcgismaps.portal.PortalItemType
+import com.arcgismaps.toolkit.offlinemapareasapp.data.local.ItemApi
+import com.arcgismaps.toolkit.offlinemapareasapp.data.local.ItemData
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.withContext
+
+/**
+ * Main data source for accessing the portal item data from the network.
+ */
+class ItemRemoteDataSource(
+ private val dispatcher: CoroutineDispatcher,
+ private val itemApi: ItemApi = object : ItemApi {
+ override suspend fun fetchItems(
+ portalUri: String,
+ connection: Portal.Connection
+ ): List {
+ // create a Portal
+ val portal = Portal(
+ portalUri,
+ connection = connection
+ )
+ // log an exception and return if the portal loading fails
+ portal.load().onFailure {
+ Log.e("ItemRemoteDataSource", "error in fetchItems: ${it.message}")
+ return emptyList()
+ }
+ val user = portal.user ?: return emptyList()
+ // fetch the users content
+ val portalUserContent = user.fetchContent().getOrElse { return emptyList() }
+ // get the specified folder under the users content
+ val folder = portalUserContent.folders.firstOrNull {
+ it.title == portalFolder
+ }
+ return if (folder != null) {
+ // fetch and return content within the specified folder
+ user.fetchContentInFolder(folder.folderId).getOrDefault(emptyList()).filter {
+ // filter the content by WebMaps only
+ it.type == PortalItemType.WebMap
+ }.map {
+ ItemData(it.url)
+ }
+ } else {
+ portalUserContent.items.filter {
+ it.type == PortalItemType.WebMap
+ }.map {
+ ItemData(it.url)
+ }
+ }
+ }
+ }
+) {
+ companion object {
+ /**
+ * Folder under the portal to fetch the portal items from.
+ */
+ const val portalFolder = "Apollo"
+ }
+
+ suspend fun fetchItemData(portalUri: String, connection: Portal.Connection): List =
+ withContext(dispatcher) {
+ itemApi.fetchItems(portalUri, connection)
+ }
+}
diff --git a/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/di/CoroutinesModule.kt b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/di/CoroutinesModule.kt
new file mode 100644
index 000000000..2f5048a89
--- /dev/null
+++ b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/di/CoroutinesModule.kt
@@ -0,0 +1,76 @@
+/*
+ *
+ * Copyright 2023 Esri
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package com.arcgismaps.toolkit.offlinemapareasapp.di
+
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import javax.inject.Qualifier
+import javax.inject.Singleton
+
+
+/**
+ * Provide an annotation to inject the IO dispatcher
+ */
+@Qualifier
+@Retention(AnnotationRetention.RUNTIME)
+annotation class IoDispatcher
+
+
+/**
+ * Provide an annotation to inject the default dispatcher
+ */
+@Retention(AnnotationRetention.RUNTIME)
+@Qualifier
+annotation class DefaultDispatcher
+
+/**
+ * Provide an injectable supervisor scope
+ */
+@Retention(AnnotationRetention.RUNTIME)
+@Qualifier
+annotation class ApplicationScope
+
+/**
+ * The providers of the scope and dispatchers
+ */
+@Module
+@InstallIn(SingletonComponent::class)
+object CoroutinesModule {
+
+ @Provides
+ @IoDispatcher
+ fun providesIODispatcher(): CoroutineDispatcher = Dispatchers.IO
+
+ @Provides
+ @DefaultDispatcher
+ fun providesDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default
+
+ @Provides
+ @Singleton
+ @ApplicationScope
+ fun providesCoroutineScope(
+ @DefaultDispatcher dispatcher: CoroutineDispatcher
+ ): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher)
+}
diff --git a/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/di/DataModule.kt b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/di/DataModule.kt
new file mode 100644
index 000000000..d6b2ff718
--- /dev/null
+++ b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/di/DataModule.kt
@@ -0,0 +1,86 @@
+/*
+ *
+ * Copyright 2023 Esri
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package com.arcgismaps.toolkit.offlinemapareasapp.di
+
+import android.content.Context
+import com.arcgismaps.toolkit.offlinemapareasapp.data.PortalItemRepository
+import com.arcgismaps.toolkit.offlinemapareasapp.data.PortalSettings
+import com.arcgismaps.toolkit.offlinemapareasapp.data.local.ItemCacheDao
+import com.arcgismaps.toolkit.offlinemapareasapp.data.network.ItemRemoteDataSource
+import com.arcgismaps.toolkit.offlinemapareasapp.navigation.Navigator
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import kotlinx.coroutines.CoroutineDispatcher
+import javax.inject.Qualifier
+import javax.inject.Singleton
+
+/**
+ * Provide an annotation to inject the ItemRemoteDataSource
+ */
+@Qualifier
+@Retention(AnnotationRetention.RUNTIME)
+annotation class ItemRemoteSource
+
+/**
+ * Provide an annotation to inject the PortalItemRepository
+ */
+@Qualifier
+@Retention(AnnotationRetention.SOURCE)
+annotation class PortalItemRepo
+
+
+@Module
+@InstallIn(SingletonComponent::class)
+class DataModule {
+
+ /**
+ * The provider of the ItemRemoteDataSource.
+ */
+ @Provides
+ @ItemRemoteSource
+ internal fun provideItemRemoteDataSource(@IoDispatcher dispatcher: CoroutineDispatcher): ItemRemoteDataSource =
+ ItemRemoteDataSource(dispatcher)
+
+ /**
+ * The provider of the PortalItemRepository.
+ */
+ @Provides
+ @Singleton
+ @PortalItemRepo
+ internal fun providePortalItemRepository(
+ @IoDispatcher dispatcher: CoroutineDispatcher,
+ @ItemRemoteSource remoteDataSource: ItemRemoteDataSource,
+ @ItemCache itemCacheDao: ItemCacheDao,
+ @ApplicationContext context: Context
+ ): PortalItemRepository =
+ PortalItemRepository(dispatcher, remoteDataSource, itemCacheDao, context.filesDir.absolutePath)
+
+ @Singleton
+ @Provides
+ internal fun providePortalSettings(
+ @ApplicationContext context: Context,
+ ): PortalSettings = PortalSettings(context = context)
+
+ @Singleton
+ @Provides
+ internal fun provideNavigator(): Navigator = Navigator()
+}
diff --git a/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/di/PersistenceModule.kt b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/di/PersistenceModule.kt
new file mode 100644
index 000000000..fea653325
--- /dev/null
+++ b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/di/PersistenceModule.kt
@@ -0,0 +1,85 @@
+/*
+ *
+ * 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.offlinemapareasapp.di
+
+import android.content.Context
+import androidx.room.Room
+import com.arcgismaps.toolkit.offlinemapareasapp.data.local.ItemCacheDao
+import com.arcgismaps.toolkit.offlinemapareasapp.data.local.ItemCacheDatabase
+import com.arcgismaps.toolkit.offlinemapareasapp.data.local.UrlHistoryDao
+import com.arcgismaps.toolkit.offlinemapareasapp.data.local.UrlHistoryDatabase
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Qualifier
+import javax.inject.Singleton
+
+/**
+ * Provide an annotation to inject the ItemCache Data Access Object.
+ */
+@Qualifier
+@Retention(AnnotationRetention.RUNTIME)
+annotation class ItemCache
+
+@Qualifier
+@Retention(AnnotationRetention.SOURCE)
+annotation class UrlHistory
+
+@Module
+@InstallIn(SingletonComponent::class)
+object PersistenceModule {
+
+ /**
+ * The provider of the item cache dao.
+ */
+ @Singleton
+ @Provides
+ @ItemCache
+ fun provideItemCacheDao(database: ItemCacheDatabase): ItemCacheDao = database.itemCacheDao()
+
+ /**
+ * The provider of the ItemCacheDatabase.
+ */
+ @Singleton
+ @Provides
+ fun provideItemCacheDataBase(@ApplicationContext context: Context): ItemCacheDatabase {
+ return Room.databaseBuilder(
+ context,
+ ItemCacheDatabase::class.java,
+ "portal_items.db"
+ ).build()
+ }
+
+ @Singleton
+ @Provides
+ @UrlHistory
+ fun provideUrlHistoryDao(database: UrlHistoryDatabase) : UrlHistoryDao = database.urlHistoryDao()
+
+ @Singleton
+ @Provides
+ fun provideUrlHistoryDatabase(@ApplicationContext context: Context): UrlHistoryDatabase {
+ return Room.databaseBuilder(
+ context,
+ UrlHistoryDatabase::class.java,
+ "url_history.db"
+ ).build()
+ }
+}
diff --git a/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/navigation/Navigator.kt b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/navigation/Navigator.kt
new file mode 100644
index 000000000..9ca15f1bf
--- /dev/null
+++ b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/navigation/Navigator.kt
@@ -0,0 +1,112 @@
+/*
+ * Copyright 2023 Esri
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.arcgismaps.toolkit.offlinemapareasapp.navigation
+
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.slideInHorizontally
+import androidx.compose.animation.slideOutHorizontally
+import androidx.compose.material3.Surface
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.navigation.NavHostController
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import com.arcgismaps.toolkit.offlinemapareasapp.screens.browse.MapListScreen
+import com.arcgismaps.toolkit.offlinemapareasapp.screens.login.LoginScreen
+import com.arcgismaps.toolkit.offlinemapareasapp.screens.map.MapScreen
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import java.net.URLEncoder
+import java.nio.charset.StandardCharsets
+
+class Navigator {
+ private val _navigationFlow = MutableSharedFlow(extraBufferCapacity = 1)
+ val navigationFlow = _navigationFlow.asSharedFlow()
+
+ fun navigateTo(route: NavigationRoute) {
+ _navigationFlow.tryEmit(route)
+ }
+}
+
+sealed class NavigationRoute private constructor(val route: String) {
+ data object Login : NavigationRoute("login")
+ data object Home : NavigationRoute("home")
+ data object MapView : NavigationRoute("mapview/{uri}")
+}
+
+@Composable
+fun AppNavigation(
+ navController: NavHostController,
+ navigator: Navigator,
+ startDestination: String,
+) {
+ LaunchedEffect(Unit) {
+ navigator.navigationFlow.collect {
+ navController.navigate(it.route) {
+ if (it == NavigationRoute.Login) {
+ navController.popBackStack()
+ }
+ }
+ }
+ }
+ // create a NavHost with a navigation graph builder
+ NavHost(navController = navController, startDestination = startDestination) {
+ // Login screen
+ composable(
+ NavigationRoute.Login.route,
+ enterTransition = { fadeIn() },
+ exitTransition = { fadeOut() }
+ ) {
+ LoginScreen {
+ // on successful login, go to the map list screen
+ navController.navigate(NavigationRoute.Home.route) {
+ // remove this entry from the nav stack to disable a "back" action
+ navController.popBackStack()
+ }
+ }
+ }
+ // Home screen - shows the list of maps
+ composable(
+ NavigationRoute.Home.route,
+ enterTransition = { fadeIn() },
+ exitTransition = { fadeOut() }
+ ) {
+ Surface {
+ MapListScreen { uri ->
+ // encode the uri since it is equivalent to a navigation route
+ val encodedUri = URLEncoder.encode(uri, StandardCharsets.UTF_8.toString())
+ val route = "mapview/$encodedUri"
+ // navigate to the mapview
+ navController.navigate(route)
+ }
+ }
+
+ }
+ // MapView Screen - shows the map and the OfflineMapAreas
+ composable(
+ NavigationRoute.MapView.route,
+ enterTransition = { slideInHorizontally { h -> h } },
+ exitTransition = { slideOutHorizontally { h -> h } }
+ ) {
+ MapScreen {
+ // navigate back on back pressed
+ navController.navigateUp()
+ }
+ }
+ }
+}
diff --git a/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/bottomsheet/BottomSheetScaffold.kt b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/bottomsheet/BottomSheetScaffold.kt
new file mode 100644
index 000000000..dbe78f5ba
--- /dev/null
+++ b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/bottomsheet/BottomSheetScaffold.kt
@@ -0,0 +1,454 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ *
+ * Modifications copyright (C) 2023 Esri Inc
+ */
+
+package com.arcgismaps.toolkit.offlinemapareasapp.screens.bottomsheet
+
+import androidx.compose.foundation.gestures.Orientation
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.requiredHeightIn
+import androidx.compose.foundation.layout.requiredWidth
+import androidx.compose.material3.BottomSheetDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.Surface
+import androidx.compose.material3.contentColorFor
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Alignment.Companion.CenterHorizontally
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.layout.SubcomposeLayout
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.semantics.collapse
+import androidx.compose.ui.semantics.dismiss
+import androidx.compose.ui.semantics.expand
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.unit.Dp
+import kotlinx.coroutines.launch
+import java.lang.Float.max
+import kotlin.math.roundToInt
+
+/**
+ * Material Design standard bottom sheet scaffold.
+ *
+ * Standard bottom sheets co-exist with the screen’s main UI region and allow for simultaneously
+ * viewing and interacting with both regions. They are commonly used to keep a feature or
+ * secondary content visible on screen when content in main UI region is frequently scrolled or
+ * panned.
+ *
+ * 
+ *
+ * This component provides API to put together several material components to construct your
+ * screen, by ensuring proper layout strategy for them and collecting necessary data so these
+ * components will work together correctly.
+ *
+ * A simple example of a standard bottom sheet looks like this:
+ *
+ * @sample androidx.compose.material3.samples.SimpleBottomSheetScaffoldSample
+ *
+ * @param sheetContent the content of the bottom sheet
+ * @param modifier the [Modifier] to be applied to this scaffold
+ * @param scaffoldState the state of the bottom sheet scaffold
+ * @param sheetPeekHeight the height of the bottom sheet when it is collapsed
+ * @param sheetShape the shape of the bottom sheet
+ * @param sheetExpansionHeight the height for the bottom sheet when it is partially expanded and
+ * expanded
+ * @param sheetContainerColor the background color of the bottom sheet
+ * @param sheetContentColor the preferred content color provided by the bottom sheet to its
+ * children. Defaults to the matching content color for [sheetContainerColor], or if that is
+ * not a color from the theme, this will keep the same content color set above the bottom sheet.
+ * @param sheetTonalElevation the tonal elevation of the bottom sheet
+ * @param sheetShadowElevation the shadow elevation of the bottom sheet
+ * @param sheetDragHandle optional visual marker to pull the scaffold's bottom sheet
+ * @param sheetSwipeEnabled whether the sheet swiping is enabled and should react to the user's
+ * input
+ * @param topBar top app bar of the screen, typically a [SmallTopAppBar]
+ * @param snackbarHost component to host [Snackbar]s that are pushed to be shown via
+ * [SnackbarHostState.showSnackbar], typically a [SnackbarHost]
+ * @param containerColor the color used for the background of this scaffold. Use [Color.Transparent]
+ * to have no color.
+ * @param contentColor the preferred color for content inside this scaffold. Defaults to either the
+ * matching content color for [containerColor], or to the current [LocalContentColor] if
+ * [containerColor] is not a color from the theme.
+ * @param content content of the screen. The lambda receives a [PaddingValues] that should be
+ * applied to the content root via [Modifier.padding] and [Modifier.consumeWindowInsets] to
+ * properly offset top and bottom bars. If using [Modifier.verticalScroll], apply this modifier to
+ * the child of the scroll, and not on the scroll itself.
+ */
+@Composable
+@ExperimentalMaterial3Api
+fun BottomSheetScaffold(
+ sheetContent: @Composable ColumnScope.() -> Unit,
+ modifier: Modifier = Modifier,
+ scaffoldState: BottomSheetScaffoldState = rememberBottomSheetScaffoldState(),
+ sheetPeekHeight: Dp = BottomSheetDefaults.SheetPeekHeight,
+ sheetShape: Shape = BottomSheetDefaults.ExpandedShape,
+ sheetExpansionHeight: SheetExpansionHeight = SheetExpansionHeight(),
+ sheetContainerColor: Color = BottomSheetDefaults.ContainerColor,
+ sheetContentColor: Color = contentColorFor(sheetContainerColor),
+ sheetTonalElevation: Dp = BottomSheetDefaults.Elevation,
+ sheetShadowElevation: Dp = BottomSheetDefaults.Elevation,
+ sheetDragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() },
+ sheetSwipeEnabled: Boolean = true,
+ topBar: @Composable (() -> Unit)? = null,
+ snackbarHost: @Composable (SnackbarHostState) -> Unit = { SnackbarHost(it) },
+ containerColor: Color = MaterialTheme.colorScheme.surface,
+ contentColor: Color = contentColorFor(containerColor),
+ content: @Composable (PaddingValues) -> Unit
+) {
+ BottomSheetScaffoldLayout(
+ modifier = modifier,
+ topBar = topBar,
+ body = content,
+ snackbarHost = {
+ snackbarHost(scaffoldState.snackbarHostState)
+ },
+ sheetPeekHeight = sheetPeekHeight,
+ sheetOffset = { scaffoldState.bottomSheetState.requireOffset() },
+ sheetState = scaffoldState.bottomSheetState,
+ containerColor = containerColor,
+ contentColor = contentColor,
+ bottomSheet = { layoutHeight ->
+ StandardBottomSheet(
+ state = scaffoldState.bottomSheetState,
+ peekHeight = sheetPeekHeight,
+ expansionHeight = sheetExpansionHeight,
+ sheetSwipeEnabled = sheetSwipeEnabled,
+ layoutHeight = layoutHeight.toFloat(),
+ sheetWidth = BottomSheetMaxWidth,
+ shape = sheetShape,
+ containerColor = sheetContainerColor,
+ contentColor = sheetContentColor,
+ tonalElevation = sheetTonalElevation,
+ shadowElevation = sheetShadowElevation,
+ dragHandle = sheetDragHandle,
+ content = sheetContent
+ )
+ }
+ )
+}
+
+/**
+ * State of the [BottomSheetScaffold] composable.
+ *
+ * @param bottomSheetState the state of the persistent bottom sheet
+ * @param snackbarHostState the [SnackbarHostState] used to show snackbars inside the scaffold
+ */
+@ExperimentalMaterial3Api
+@Stable
+class BottomSheetScaffoldState(
+ val bottomSheetState: SheetState,
+ val snackbarHostState: SnackbarHostState
+)
+
+/**
+ * Create and [remember] a [BottomSheetScaffoldState].
+ *
+ * @param bottomSheetState the state of the standard bottom sheet. See
+ * [rememberStandardBottomSheetState]
+ * @param snackbarHostState the [SnackbarHostState] used to show snackbars inside the scaffold
+ */
+@Composable
+@ExperimentalMaterial3Api
+fun rememberBottomSheetScaffoldState(
+ bottomSheetState: SheetState = rememberStandardBottomSheetState(),
+ snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }
+): BottomSheetScaffoldState {
+ return remember(bottomSheetState, snackbarHostState) {
+ BottomSheetScaffoldState(
+ bottomSheetState = bottomSheetState,
+ snackbarHostState = snackbarHostState
+ )
+ }
+}
+
+/**
+ * Create and [remember] a [SheetState] for [BottomSheetScaffold].
+ *
+ * @param initialValue the initial value of the state. Should be either [PartiallyExpanded] or
+ * [Expanded] if [skipHiddenState] is true
+ * @param confirmValueChange optional callback invoked to confirm or veto a pending state change
+ * @param [skipHiddenState] whether Hidden state is skipped for [BottomSheetScaffold]
+ */
+@Composable
+@ExperimentalMaterial3Api
+fun rememberStandardBottomSheetState(
+ initialValue: SheetValue = SheetValue.PartiallyExpanded,
+ confirmValueChange: (SheetValue) -> Boolean = { true },
+ skipHiddenState: Boolean = true,
+) = rememberSheetState(false, confirmValueChange, initialValue, skipHiddenState)
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun StandardBottomSheet(
+ state: SheetState,
+ peekHeight: Dp,
+ expansionHeight: SheetExpansionHeight,
+ layoutHeight: Float,
+ sheetWidth: Dp,
+ sheetSwipeEnabled: Boolean = true,
+ shape: Shape = BottomSheetDefaults.ExpandedShape,
+ containerColor: Color = BottomSheetDefaults.ContainerColor,
+ contentColor: Color = contentColorFor(containerColor),
+ tonalElevation: Dp = BottomSheetDefaults.Elevation,
+ shadowElevation: Dp = BottomSheetDefaults.Elevation,
+ dragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() },
+ content: @Composable ColumnScope.() -> Unit
+) {
+ val scope = rememberCoroutineScope()
+ val peekHeightPx = with(LocalDensity.current) { peekHeight.toPx() }
+ val orientation = Orientation.Vertical
+ // Callback that is invoked when the anchors have changed.
+ val anchorChangeHandler = remember(state, scope) {
+ BottomSheetScaffoldAnchorChangeHandler(
+ state = state,
+ animateTo = { target, velocity ->
+ scope.launch {
+ state.swipeableState.animateTo(
+ target, velocity = velocity
+ )
+ }
+ },
+ snapTo = { target ->
+ scope.launch { state.swipeableState.snapTo(target) }
+ }
+ )
+ }
+ Surface(
+ modifier = Modifier
+ .requiredWidth(sheetWidth)
+ .fillMaxWidth()
+ .requiredHeightIn(min = peekHeight)
+ .nestedScroll(
+ remember(state.swipeableState) {
+ ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection(
+ sheetState = state,
+ orientation = orientation,
+ onFling = { scope.launch { state.settle(it) } }
+ )
+ }
+ )
+ .swipeableV2(
+ state = state.swipeableState,
+ orientation = orientation,
+ enabled = sheetSwipeEnabled
+ )
+ .swipeAnchors(
+ state.swipeableState,
+ possibleValues = setOf(
+ SheetValue.Hidden,
+ SheetValue.Minimized,
+ SheetValue.PartiallyExpanded,
+ SheetValue.Expanded
+ ),
+ anchorChangeHandler = anchorChangeHandler
+ ) { value, sheetSize ->
+ when (value) {
+ SheetValue.PartiallyExpanded -> (layoutHeight - peekHeightPx - (sheetSize.height * expansionHeight.partialHeightFraction))
+ SheetValue.Expanded -> if (sheetSize.height == peekHeightPx.roundToInt()) {
+ null
+ } else {
+ max(
+ 0f,
+ (layoutHeight - (sheetSize.height * expansionHeight.fullHeightFraction))
+ )
+ }
+
+ SheetValue.Minimized -> layoutHeight - peekHeightPx
+ SheetValue.Hidden -> layoutHeight
+ }
+ },
+ shape = shape,
+ color = containerColor,
+ contentColor = contentColor,
+ tonalElevation = tonalElevation,
+ shadowElevation = shadowElevation,
+ ) {
+ Column(Modifier.fillMaxWidth()) {
+ if (dragHandle != null) {
+ val partialExpandActionLabel =
+ getString(Strings.BottomSheetPartialExpandDescription)
+ val dismissActionLabel = getString(Strings.BottomSheetDismissDescription)
+ val expandActionLabel = getString(Strings.BottomSheetExpandDescription)
+ Box(
+ Modifier
+ .align(CenterHorizontally)
+ .semantics(mergeDescendants = true) {
+ with(state) {
+ // Provides semantics to interact with the bottomsheet if there is more
+ // than one anchor to swipe to and swiping is enabled.
+ if (swipeableState.anchors.size > 1 && sheetSwipeEnabled) {
+ if (currentValue == SheetValue.PartiallyExpanded) {
+ if (swipeableState.confirmValueChange(SheetValue.Expanded)) {
+ expand(expandActionLabel) {
+ scope.launch { expand() }; true
+ }
+ }
+ } else {
+ if (swipeableState.confirmValueChange(SheetValue.PartiallyExpanded)) {
+ collapse(partialExpandActionLabel) {
+ scope.launch { partialExpand() }; true
+ }
+ }
+ }
+ if (!state.skipHiddenState) {
+ dismiss(dismissActionLabel) {
+ scope.launch { hide() }
+ true
+ }
+ }
+ }
+ }
+ },
+ ) {
+ dragHandle()
+ }
+ }
+ content()
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun BottomSheetScaffoldLayout(
+ modifier: Modifier,
+ topBar: @Composable (() -> Unit)?,
+ body: @Composable (innerPadding: PaddingValues) -> Unit,
+ bottomSheet: @Composable (layoutHeight: Int) -> Unit,
+ snackbarHost: @Composable () -> Unit,
+ sheetPeekHeight: Dp,
+ sheetOffset: () -> Float,
+ sheetState: SheetState,
+ containerColor: Color,
+ contentColor: Color,
+) {
+ SubcomposeLayout { constraints ->
+ val layoutWidth = constraints.maxWidth
+ val layoutHeight = constraints.maxHeight
+ val looseConstraints = constraints.copy(minWidth = 0, minHeight = 0)
+
+ val topBarPlaceable = topBar?.let {
+ subcompose(BottomSheetScaffoldLayoutSlot.TopBar) { topBar() }[0]
+ .measure(looseConstraints)
+ }
+ val topBarHeight = topBarPlaceable?.height ?: 0
+
+ val sheetPlaceable = subcompose(BottomSheetScaffoldLayoutSlot.Sheet) {
+ bottomSheet(layoutHeight - topBarHeight)
+ }[0].measure(looseConstraints.copy(maxHeight = layoutHeight - topBarHeight))
+ val sheetOffsetY = sheetOffset().roundToInt() + topBarHeight
+ val sheetOffsetX = Integer.max(0, (layoutWidth - sheetPlaceable.width) / 2)
+
+ val bodyConstraints = looseConstraints.copy(maxHeight = layoutHeight - topBarHeight)
+ val bodyPlaceable = subcompose(BottomSheetScaffoldLayoutSlot.Body) {
+ Surface(
+ modifier = modifier,
+ color = containerColor,
+ contentColor = contentColor,
+ ) { body(PaddingValues(bottom = sheetPeekHeight)) }
+ }[0].measure(bodyConstraints)
+
+ val snackbarPlaceable = subcompose(BottomSheetScaffoldLayoutSlot.Snackbar, snackbarHost)[0]
+ .measure(looseConstraints)
+ val snackbarOffsetX = (layoutWidth - snackbarPlaceable.width) / 2
+ val snackbarOffsetY = when (sheetState.currentValue) {
+ SheetValue.PartiallyExpanded -> sheetOffsetY - snackbarPlaceable.height
+ SheetValue.Expanded, SheetValue.Hidden, SheetValue.Minimized -> layoutHeight - snackbarPlaceable.height
+ }
+
+ layout(layoutWidth, layoutHeight) {
+ // Placement order is important for elevation
+ bodyPlaceable.placeRelative(0, topBarHeight)
+ topBarPlaceable?.placeRelative(0, 0)
+ sheetPlaceable.placeRelative(sheetOffsetX, sheetOffsetY)
+ snackbarPlaceable.placeRelative(snackbarOffsetX, snackbarOffsetY)
+ }
+ }
+}
+
+@ExperimentalMaterial3Api
+private fun BottomSheetScaffoldAnchorChangeHandler(
+ state: SheetState,
+ animateTo: (target: SheetValue, velocity: Float) -> Unit,
+ snapTo: (target: SheetValue) -> Unit,
+) = AnchorChangeHandler { previousTarget, previousAnchors, newAnchors ->
+ val previousTargetOffset = previousAnchors[previousTarget]
+ val newTarget = when (previousTarget) {
+ SheetValue.Minimized -> SheetValue.PartiallyExpanded
+ SheetValue.Hidden, SheetValue.PartiallyExpanded -> SheetValue.PartiallyExpanded
+ SheetValue.Expanded -> if (newAnchors.containsKey(SheetValue.Expanded)) SheetValue.Expanded else SheetValue.PartiallyExpanded
+ }
+ val newTargetOffset = newAnchors.getValue(newTarget)
+ if (newTargetOffset != previousTargetOffset) {
+ if (state.swipeableState.isAnimationRunning) {
+ // Re-target the animation to the new offset if it changed
+ animateTo(newTarget, state.swipeableState.lastVelocity)
+ } else {
+ // Snap to the new offset value of the target if no animation was running
+ snapTo(newTarget)
+ }
+ }
+}
+
+@Composable
+fun StandardBottomSheetLayout(
+ modifier: Modifier = Modifier,
+ sheetOffset: () -> Float,
+ bottomSheet: @Composable (layoutHeight: Int) -> Unit,
+) {
+ SubcomposeLayout(modifier = modifier) { constraints ->
+ val layoutWidth = constraints.maxWidth
+ val layoutHeight = constraints.maxHeight
+
+ val sheetPlaceable = subcompose(0) {
+ bottomSheet(layoutHeight)
+ }[0].measure(constraints)
+ val sheetOffsetY = sheetOffset().roundToInt()
+ val sheetOffsetX = Integer.max(0, (layoutWidth - sheetPlaceable.width) / 2)
+
+ layout(layoutWidth, layoutHeight) {
+ sheetPlaceable.placeRelative(sheetOffsetX, sheetOffsetY)
+ }
+ }
+}
+
+private enum class BottomSheetScaffoldLayoutSlot { TopBar, Body, Sheet, Snackbar }
+
+/**
+ * Defines the height values as fractions for the PartiallyExpanded and Expanded Sheet Values
+ * of the bottom sheet
+ *
+ * @param partialHeightFraction fractional for the PartiallyExpanded height between [0,1]
+ * @param fullHeightFraction fractional for the Expanded height between [0,1]
+ */
+class SheetExpansionHeight(
+ val partialHeightFraction: Float = 0.5f,
+ val fullHeightFraction: Float = 1f
+)
diff --git a/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/bottomsheet/InternalMutatorMutex.kt b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/bottomsheet/InternalMutatorMutex.kt
new file mode 100644
index 000000000..bb3e20d49
--- /dev/null
+++ b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/bottomsheet/InternalMutatorMutex.kt
@@ -0,0 +1,166 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ *
+ * Modifications copyright (C) 2023 Esri Inc
+ */
+
+package com.arcgismaps.toolkit.offlinemapareasapp.screens.bottomsheet
+
+import androidx.compose.foundation.MutatePriority
+import androidx.compose.runtime.Stable
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+
+internal typealias InternalAtomicReference =
+ java.util.concurrent.atomic.AtomicReference
+
+/**
+ * Mutual exclusion for UI state mutation over time.
+ *
+ * [mutate] permits interruptible state mutation over time using a standard [MutatePriority].
+ * A [InternalMutatorMutex] enforces that only a single writer can be active at a time for a particular
+ * state resource. Instead of queueing callers that would acquire the lock like a traditional
+ * [Mutex], new attempts to [mutate] the guarded state will either cancel the current mutator or
+ * if the current mutator has a higher priority, the new caller will throw [CancellationException].
+ *
+ * [InternalMutatorMutex] should be used for implementing hoisted state objects that many mutators may
+ * want to manipulate over time such that those mutators can coordinate with one another. The
+ * [InternalMutatorMutex] instance should be hidden as an implementation detail. For example:
+ *
+ */
+@Stable
+internal class InternalMutatorMutex {
+ private class Mutator(val priority: MutatePriority, val job: Job) {
+ fun canInterrupt(other: Mutator) = priority >= other.priority
+
+ fun cancel() = job.cancel()
+ }
+
+ private val currentMutator = InternalAtomicReference(null)
+ private val mutex = Mutex()
+
+ private fun tryMutateOrCancel(mutator: Mutator) {
+ while (true) {
+ val oldMutator = currentMutator.get()
+ if (oldMutator == null || mutator.canInterrupt(oldMutator)) {
+ if (currentMutator.compareAndSet(oldMutator, mutator)) {
+ oldMutator?.cancel()
+ break
+ }
+ } else throw CancellationException("Current mutation had a higher priority")
+ }
+ }
+
+ /**
+ * Enforce that only a single caller may be active at a time.
+ *
+ * If [mutate] is called while another call to [mutate] or [mutateWith] is in progress, their
+ * [priority] values are compared. If the new caller has a [priority] equal to or higher than
+ * the call in progress, the call in progress will be cancelled, throwing
+ * [CancellationException] and the new caller's [block] will be invoked. If the call in
+ * progress had a higher [priority] than the new caller, the new caller will throw
+ * [CancellationException] without invoking [block].
+ *
+ * @param priority the priority of this mutation; [MutatePriority.Default] by default.
+ * Higher priority mutations will interrupt lower priority mutations.
+ * @param block mutation code to run mutually exclusive with any other call to [mutate],
+ * [mutateWith] or [tryMutate].
+ */
+ suspend fun mutate(
+ priority: MutatePriority = MutatePriority.Default,
+ block: suspend () -> R
+ ) = coroutineScope {
+ val mutator = Mutator(priority, coroutineContext[Job]!!)
+
+ tryMutateOrCancel(mutator)
+
+ mutex.withLock {
+ try {
+ block()
+ } finally {
+ currentMutator.compareAndSet(mutator, null)
+ }
+ }
+ }
+
+ /**
+ * Enforce that only a single caller may be active at a time.
+ *
+ * If [mutateWith] is called while another call to [mutate] or [mutateWith] is in progress,
+ * their [priority] values are compared. If the new caller has a [priority] equal to or
+ * higher than the call in progress, the call in progress will be cancelled, throwing
+ * [CancellationException] and the new caller's [block] will be invoked. If the call in
+ * progress had a higher [priority] than the new caller, the new caller will throw
+ * [CancellationException] without invoking [block].
+ *
+ * This variant of [mutate] calls its [block] with a [receiver], removing the need to create
+ * an additional capturing lambda to invoke it with a receiver object. This can be used to
+ * expose a mutable scope to the provided [block] while leaving the rest of the state object
+ * read-only. For example:
+ *
+ * @param receiver the receiver `this` that [block] will be called with
+ * @param priority the priority of this mutation; [MutatePriority.Default] by default.
+ * Higher priority mutations will interrupt lower priority mutations.
+ * @param block mutation code to run mutually exclusive with any other call to [mutate],
+ * [mutateWith] or [tryMutate].
+ */
+ suspend fun mutateWith(
+ receiver: T,
+ priority: MutatePriority = MutatePriority.Default,
+ block: suspend T.() -> R
+ ) = coroutineScope {
+ val mutator = Mutator(priority, coroutineContext[Job]!!)
+
+ tryMutateOrCancel(mutator)
+
+ mutex.withLock {
+ try {
+ receiver.block()
+ } finally {
+ currentMutator.compareAndSet(mutator, null)
+ }
+ }
+ }
+
+ /**
+ * Attempt to mutate synchronously if there is no other active caller.
+ * If there is no other active caller, the [block] will be executed in a lock. If there is
+ * another active caller, this method will return false, indicating that the active caller
+ * needs to be cancelled through a [mutate] or [mutateWith] call with an equal or higher
+ * mutation priority.
+ *
+ * Calls to [mutate] and [mutateWith] will suspend until execution of the [block] has finished.
+ *
+ * @param block mutation code to run mutually exclusive with any other call to [mutate],
+ * [mutateWith] or [tryMutate].
+ * @return true if the [block] was executed, false if there was another active caller and the
+ * [block] was not executed.
+ */
+ fun tryMutate(block: () -> Unit): Boolean {
+ val didLock = mutex.tryLock()
+ if (didLock) {
+ try {
+ block()
+ } finally {
+ mutex.unlock()
+ }
+ }
+ return didLock
+ }
+}
diff --git a/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/bottomsheet/SheetDefaults.kt b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/bottomsheet/SheetDefaults.kt
new file mode 100644
index 000000000..b9c73884c
--- /dev/null
+++ b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/bottomsheet/SheetDefaults.kt
@@ -0,0 +1,340 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ *
+ * Modifications copyright (C) 2023 Esri Inc
+ */
+
+package com.arcgismaps.toolkit.offlinemapareasapp.screens.bottomsheet
+
+import androidx.compose.foundation.gestures.Orientation
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.saveable.Saver
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
+import androidx.compose.ui.input.nestedscroll.NestedScrollSource
+import androidx.compose.ui.unit.Velocity
+import androidx.compose.ui.unit.dp
+import kotlinx.coroutines.CancellationException
+
+/**
+ * State of a sheet composable, such as [ModalBottomSheet]
+ *
+ * Contains states relating to it's swipe position as well as animations between state values.
+ *
+ * @param skipPartiallyExpanded Whether the partially expanded state, if the sheet is large
+ * enough, should be skipped. If true, the sheet will always expand to the [Expanded] state and move
+ * to the [Hidden] state if available when hiding the sheet, either programmatically or by user
+ * interaction.
+ * @param initialValue The initial value of the state.
+ * @param confirmValueChange Optional callback invoked to confirm or veto a pending state change.
+ * @param skipHiddenState Whether the hidden state should be skipped. If true, the sheet will always
+ * expand to the [Expanded] state and move to the [PartiallyExpanded] if available, either
+ * programmatically or by user interaction.
+ */
+@Stable
+@ExperimentalMaterial3Api
+class SheetState(
+ internal val skipPartiallyExpanded: Boolean,
+ initialValue: SheetValue = SheetValue.PartiallyExpanded,
+ confirmValueChange: (SheetValue) -> Boolean = { true },
+ internal val skipHiddenState: Boolean = false,
+) {
+ init {
+ if (skipPartiallyExpanded) {
+ require(initialValue != SheetValue.PartiallyExpanded) {
+ "The initial value must not be set to PartiallyExpanded if skipPartiallyExpanded " +
+ "is set to true."
+ }
+ }
+ if (skipHiddenState) {
+ require(initialValue != SheetValue.Hidden) {
+ "The initial value must not be set to Hidden if skipHiddenState is set to true."
+ }
+ }
+ }
+
+ /**
+ * The current value of the state.
+ *
+ * If no swipe or animation is in progress, this corresponds to the state the bottom sheet is
+ * currently in. If a swipe or an animation is in progress, this corresponds the state the sheet
+ * was in before the swipe or animation started.
+ */
+
+ val currentValue: SheetValue get() = swipeableState.currentValue
+
+ /**
+ * The target value of the bottom sheet state.
+ *
+ * If a swipe is in progress, this is the value that the sheet would animate to if the
+ * swipe finishes. If an animation is running, this is the target value of that animation.
+ * Finally, if no swipe or animation is in progress, this is the same as the [currentValue].
+ */
+ val targetValue: SheetValue get() = swipeableState.targetValue
+
+ /**
+ * Whether the modal bottom sheet is visible.
+ */
+ val isVisible: Boolean
+ get() = swipeableState.currentValue != SheetValue.Hidden
+
+ /**
+ * Require the current offset (in pixels) of the bottom sheet.
+ *
+ * The offset will be initialized during the first measurement phase of the provided sheet
+ * content.
+ *
+ * These are the phases:
+ * Composition { -> Effects } -> Layout { Measurement -> Placement } -> Drawing
+ *
+ * During the first composition, an [IllegalStateException] is thrown. In subsequent
+ * compositions, the offset will be derived from the anchors of the previous pass. Always prefer
+ * accessing the offset from a LaunchedEffect as it will be scheduled to be executed the next
+ * frame, after layout.
+ *
+ * @throws IllegalStateException If the offset has not been initialized yet
+ */
+ fun requireOffset(): Float = swipeableState.requireOffset()
+
+ /**
+ * Whether the sheet has an expanded state defined.
+ */
+
+ val hasExpandedState: Boolean
+ get() = swipeableState.hasAnchorForValue(SheetValue.Expanded)
+
+ /**
+ * Whether the modal bottom sheet has a partially expanded state defined.
+ */
+ val hasPartiallyExpandedState: Boolean
+ get() = swipeableState.hasAnchorForValue(SheetValue.PartiallyExpanded)
+
+ /**
+ * Fully expand the bottom sheet with animation and suspend until it is fully expanded or
+ * animation has been cancelled.
+ * *
+ * @throws [CancellationException] if the animation is interrupted
+ */
+ suspend fun expand() {
+ swipeableState.animateTo(SheetValue.Expanded)
+ }
+
+ /**
+ * Animate the bottom sheet and suspend until it is partially expanded or animation has been
+ * cancelled.
+ * @throws [CancellationException] if the animation is interrupted
+ * @throws [IllegalStateException] if [skipPartiallyExpanded] is set to true
+ */
+ suspend fun partialExpand() {
+ check(!skipPartiallyExpanded) {
+ "Attempted to animate to partial expanded when skipPartiallyExpanded was enabled. Set" +
+ " skipPartiallyExpanded to false to use this function."
+ }
+ animateTo(SheetValue.PartiallyExpanded)
+ }
+
+ /**
+ * Expand the bottom sheet with animation and suspend until it is [PartiallyExpanded] if defined
+ * else [Expanded].
+ * @throws [CancellationException] if the animation is interrupted
+ */
+ suspend fun show() {
+ val targetValue = when {
+ hasPartiallyExpandedState -> SheetValue.PartiallyExpanded
+ else -> SheetValue.Expanded
+ }
+ animateTo(targetValue)
+ }
+
+ /**
+ * Hide the bottom sheet with animation and suspend until it is fully hidden or animation has
+ * been cancelled.
+ * @throws [CancellationException] if the animation is interrupted
+ */
+ suspend fun hide() {
+ check(!skipHiddenState) {
+ "Attempted to animate to hidden when skipHiddenState was enabled. Set skipHiddenState" +
+ " to false to use this function."
+ }
+ animateTo(SheetValue.Hidden)
+ }
+
+ /**
+ * Animate to a [targetValue].
+ * If the [targetValue] is not in the set of anchors, the [currentValue] will be updated to the
+ * [targetValue] without updating the offset.
+ *
+ * @throws CancellationException if the interaction interrupted by another interaction like a
+ * gesture interaction or another programmatic interaction like a [animateTo] or [snapTo] call.
+ *
+ * @param targetValue The target value of the animation
+ */
+ internal suspend fun animateTo(
+ targetValue: SheetValue,
+ velocity: Float = swipeableState.lastVelocity
+ ) {
+ swipeableState.animateTo(targetValue, velocity)
+ }
+
+ /**
+ * Snap to a [targetValue] without any animation.
+ *
+ * @throws CancellationException if the interaction interrupted by another interaction like a
+ * gesture interaction or another programmatic interaction like a [animateTo] or [snapTo] call.
+ *
+ * @param targetValue The target value of the animation
+ */
+ internal suspend fun snapTo(targetValue: SheetValue) {
+ swipeableState.snapTo(targetValue)
+ }
+
+ /**
+ * Find the closest anchor taking into account the velocity and settle at it with an animation.
+ */
+ internal suspend fun settle(velocity: Float) {
+ swipeableState.settle(velocity)
+ }
+
+ internal var swipeableState = SwipeableV2State(
+ initialValue = initialValue,
+ animationSpec = SwipeableV2Defaults.AnimationSpec,
+ confirmValueChange = confirmValueChange,
+ )
+
+ internal val offset: Float? get() = swipeableState.offset
+
+ companion object {
+ /**
+ * The default [Saver] implementation for [SheetState].
+ */
+ fun Saver(
+ skipPartiallyExpanded: Boolean,
+ confirmValueChange: (SheetValue) -> Boolean
+ ) = Saver(
+ save = { it.currentValue },
+ restore = { savedValue ->
+ SheetState(skipPartiallyExpanded, savedValue, confirmValueChange)
+ }
+ )
+ }
+}
+
+/**
+ * Possible values of [SheetState].
+ */
+@ExperimentalMaterial3Api
+enum class SheetValue {
+ /**
+ * The sheet is not visible.
+ */
+ Hidden,
+
+ /**
+ * The sheet is visible at full height.
+ */
+ Expanded,
+
+ /**
+ * The sheet is partially visible.
+ */
+ PartiallyExpanded,
+
+ /**
+ * The sheet is visible only up to its peek height.
+ */
+ Minimized
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+internal fun ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection(
+ sheetState: SheetState,
+ orientation: Orientation,
+ onFling: (velocity: Float) -> Unit
+): NestedScrollConnection = object : NestedScrollConnection {
+ override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
+ val delta = available.toFloat()
+ return if (delta < 0 && source == NestedScrollSource.UserInput) {
+ sheetState.swipeableState.dispatchRawDelta(delta).toOffset()
+ } else {
+ Offset.Zero
+ }
+ }
+
+ override fun onPostScroll(
+ consumed: Offset,
+ available: Offset,
+ source: NestedScrollSource
+ ): Offset {
+ return if (source == NestedScrollSource.UserInput) {
+ sheetState.swipeableState.dispatchRawDelta(available.toFloat()).toOffset()
+ } else {
+ Offset.Zero
+ }
+ }
+
+ override suspend fun onPreFling(available: Velocity): Velocity {
+ val toFling = available.toFloat()
+ val currentOffset = sheetState.requireOffset()
+ return if (toFling < 0 && currentOffset > sheetState.swipeableState.minOffset) {
+ onFling(toFling)
+ // since we go to the anchor with tween settling, consume all for the best UX
+ available
+ } else {
+ Velocity.Zero
+ }
+ }
+
+ override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
+ onFling(available.toFloat())
+ return available
+ }
+
+ private fun Float.toOffset(): Offset = Offset(
+ x = if (orientation == Orientation.Horizontal) this else 0f,
+ y = if (orientation == Orientation.Vertical) this else 0f
+ )
+
+ @JvmName("velocityToFloat")
+ private fun Velocity.toFloat() = if (orientation == Orientation.Horizontal) x else y
+
+ @JvmName("offsetToFloat")
+ private fun Offset.toFloat(): Float = if (orientation == Orientation.Horizontal) x else y
+}
+
+@Composable
+@ExperimentalMaterial3Api
+internal fun rememberSheetState(
+ skipPartiallyExpanded: Boolean = false,
+ confirmValueChange: (SheetValue) -> Boolean = { true },
+ initialValue: SheetValue = SheetValue.PartiallyExpanded,
+ skipHiddenState: Boolean = false,
+): SheetState {
+ return rememberSaveable(
+ skipPartiallyExpanded, confirmValueChange,
+ saver = SheetState.Saver(
+ skipPartiallyExpanded = skipPartiallyExpanded,
+ confirmValueChange = confirmValueChange
+ )
+ ) {
+ SheetState(skipPartiallyExpanded, initialValue, confirmValueChange, skipHiddenState)
+ }
+}
+
+private val DragHandleVerticalPadding = 22.dp
+internal val BottomSheetMaxWidth = 640.dp
diff --git a/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/bottomsheet/SheetLayout.kt b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/bottomsheet/SheetLayout.kt
new file mode 100644
index 000000000..0e5fda81d
--- /dev/null
+++ b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/bottomsheet/SheetLayout.kt
@@ -0,0 +1,92 @@
+/*
+ * Copyright 2023 Esri
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.arcgismaps.toolkit.offlinemapareasapp.screens.bottomsheet
+
+import android.content.res.Configuration
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.SubcomposeLayout
+import androidx.compose.ui.platform.LocalConfiguration
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.Constraints.Companion.Infinity
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.window.core.layout.WindowSizeClass
+import androidx.window.core.layout.WindowWidthSizeClass
+import kotlin.math.roundToInt
+
+/**
+ * A custom layout that places the [sheetContent] in the center of the screen if the current
+ * orientation is portrait. The [sheetContent] is shown as a side sheet on the right side of the
+ * screen if the orientation is landscape and the [WindowSizeClass.windowWidthSizeClass] is
+ * [WindowWidthSizeClass.EXPANDED] as provided by [windowSizeClass].
+ *
+ * @param windowSizeClass The current [WindowSizeClass].
+ * @param sheetOffsetY An offset in pixels for the [sheetContent] in the Y axis.
+ * @param modifier The [Modifier]
+ * @param maxWidth A maximum width if specified will be enforced only when the orientation is portrait
+ * and the [WindowSizeClass.windowWidthSizeClass] is not [WindowWidthSizeClass.EXPANDED]. Otherwise
+ * this is set to [Infinity] which indicates to the maximum width available.
+ * @param sheetContent The sheet content lambda which is passed the width and height of the layout in pixels.
+ */
+@Composable
+fun SheetLayout(
+ windowSizeClass: WindowSizeClass,
+ sheetOffsetY: () -> Float,
+ modifier: Modifier = Modifier,
+ maxWidth: Dp = Infinity.dp,
+ sheetContent: @Composable (Int, Int) -> Unit
+) {
+ val configuration = LocalConfiguration.current
+ val showAsSideSheet = windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.EXPANDED
+ && configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
+ // convert the max width from dp into pixels
+ val maxWidthInPx = with(LocalDensity.current) {
+ maxWidth.roundToPx()
+ }
+
+ SubcomposeLayout(modifier = modifier) { constraints ->
+ val layoutWidth = if (showAsSideSheet) {
+ // set the max width to 40% of the available size
+ constraints.maxWidth * 2/5
+ } else {
+ // set the max width to the lesser of the available size or the maxWidth
+ Integer.min(constraints.maxWidth, maxWidthInPx)
+ }
+ // use all the available height
+ val layoutHeight = constraints.maxHeight
+ // measure the sheet content with the constraints
+ val sheetPlaceable = subcompose(0) {
+ sheetContent(layoutWidth, layoutHeight)
+ }[0].measure(
+ constraints.copy(
+ maxWidth = layoutWidth,
+ maxHeight = layoutHeight
+ )
+ )
+ val sheetOffsetX = if (showAsSideSheet) {
+ // anchor on right edge of the screen
+ Integer.max(0, (constraints.maxWidth - sheetPlaceable.width))
+ } else {
+ // anchor in the center of the screen
+ Integer.max(0, (constraints.maxWidth - sheetPlaceable.width) / 2)
+ }
+ layout(layoutWidth, layoutHeight) {
+ sheetPlaceable.place(sheetOffsetX, sheetOffsetY().roundToInt())
+ }
+ }
+}
diff --git a/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/bottomsheet/Strings.kt b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/bottomsheet/Strings.kt
new file mode 100644
index 000000000..56c365b4f
--- /dev/null
+++ b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/bottomsheet/Strings.kt
@@ -0,0 +1,64 @@
+/*
+ * Copyright 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ *
+ * Modifications copyright (C) 2023 Esri Inc
+ */
+
+package com.arcgismaps.toolkit.offlinemapareasapp.screens.bottomsheet
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Immutable
+import androidx.compose.ui.R
+import androidx.compose.ui.platform.LocalConfiguration
+import androidx.compose.ui.platform.LocalContext
+
+@Immutable
+@JvmInline
+value class Strings private constructor(
+ @Suppress("unused") private val value: Int = nextId()
+) {
+ companion object {
+ private var id = 0
+ private fun nextId() = id++
+
+ val NavigationMenu = Strings()
+ val CloseDrawer = Strings()
+ val CloseSheet = Strings()
+ val DefaultErrorMessage = Strings()
+ val ExposedDropdownMenu = Strings()
+ val SliderRangeStart = Strings()
+ val SliderRangeEnd = Strings()
+ val BottomSheetPartialExpandDescription = Strings()
+ val BottomSheetDismissDescription = Strings()
+ val BottomSheetExpandDescription = Strings()
+ }
+}
+
+@Composable
+fun getString(string: Strings): String {
+ LocalConfiguration.current
+ val resources = LocalContext.current.resources
+ return when (string) {
+ Strings.NavigationMenu -> resources.getString(R.string.navigation_menu)
+ Strings.CloseDrawer -> resources.getString(R.string.close_drawer)
+ Strings.CloseSheet -> resources.getString(R.string.close_sheet)
+ Strings.DefaultErrorMessage -> resources.getString(R.string.default_error_message)
+ Strings.ExposedDropdownMenu -> resources.getString(R.string.dropdown_menu)
+ Strings.SliderRangeStart -> resources.getString(R.string.range_start)
+ Strings.SliderRangeEnd -> resources.getString(R.string.range_end)
+ else -> ""
+ }
+}
diff --git a/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/bottomsheet/SwipeableV2.kt b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/bottomsheet/SwipeableV2.kt
new file mode 100644
index 000000000..35aa096f8
--- /dev/null
+++ b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/bottomsheet/SwipeableV2.kt
@@ -0,0 +1,693 @@
+/*
+ * Copyright 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ *
+ * Modifications copyright (C) 2023 Esri Inc
+ */
+
+package com.arcgismaps.toolkit.offlinemapareasapp.screens.bottomsheet
+
+import androidx.compose.animation.core.AnimationSpec
+import androidx.compose.animation.core.SpringSpec
+import androidx.compose.animation.core.animate
+import androidx.compose.foundation.MutatePriority
+import androidx.compose.foundation.gestures.DragScope
+import androidx.compose.foundation.gestures.DraggableState
+import androidx.compose.foundation.gestures.Orientation
+import androidx.compose.foundation.gestures.draggable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.offset
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.Saver
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.LayoutModifier
+import androidx.compose.ui.layout.Measurable
+import androidx.compose.ui.layout.MeasureResult
+import androidx.compose.ui.layout.MeasureScope
+import androidx.compose.ui.layout.OnRemeasuredModifier
+import androidx.compose.ui.platform.InspectorInfo
+import androidx.compose.ui.platform.InspectorValueInfo
+import androidx.compose.ui.platform.debugInspectorInfo
+import androidx.compose.ui.unit.Constraints
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.unit.dp
+import kotlin.math.abs
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.launch
+
+/**
+ * Enable swipe gestures between a set of predefined values.
+ *
+ * When a swipe is detected, the offset of the [SwipeableV2State] will be updated with the swipe
+ * delta. You should use this offset to move your content accordingly (see [Modifier.offset]).
+ * When the swipe ends, the offset will be animated to one of the anchors and when that anchor is
+ * reached, the value of the [SwipeableV2State] will also be updated to the value corresponding to
+ * the new anchor.
+ *
+ * Swiping is constrained between the minimum and maximum anchors.
+ *
+ * @param state The associated [SwipeableV2State].
+ * @param orientation The orientation in which the swipeable can be swiped.
+ * @param enabled Whether this [swipeableV2] is enabled and should react to the user's input.
+ * @param reverseDirection Whether to reverse the direction of the swipe, so a top to bottom
+ * swipe will behave like bottom to top, and a left to right swipe will behave like right to left.
+ * @param interactionSource Optional [MutableInteractionSource] that will passed on to
+ * the internal [Modifier.draggable].
+ */
+@ExperimentalMaterial3Api
+internal fun Modifier.swipeableV2(
+ state: SwipeableV2State,
+ orientation: Orientation,
+ enabled: Boolean = true,
+ reverseDirection: Boolean = false,
+ interactionSource: MutableInteractionSource? = null
+) = draggable(
+ state = state.swipeDraggableState,
+ orientation = orientation,
+ enabled = enabled,
+ interactionSource = interactionSource,
+ reverseDirection = reverseDirection,
+ startDragImmediately = state.isAnimationRunning,
+ onDragStopped = { velocity -> launch { state.settle(velocity) } }
+)
+
+/**
+ * Define anchor points for a given [SwipeableV2State] based on this node's layout size and update
+ * the state with them.
+ *
+ * @param state The associated [SwipeableV2State]
+ * @param possibleValues All possible values the [SwipeableV2State] could be in.
+ * @param anchorChangeHandler A callback to be invoked when the anchors have changed,
+ * `null` by default. Components with custom reconciliation logic should implement this callback,
+ * i.e. to re-target an in-progress animation.
+ * @param calculateAnchor This method will be invoked to calculate the position of all
+ * [possibleValues], given this node's layout size. Return the anchor's offset from the initial
+ * anchor, or `null` to indicate that a value does not have an anchor.
+ */
+@ExperimentalMaterial3Api
+internal fun Modifier.swipeAnchors(
+ state: SwipeableV2State,
+ possibleValues: Set,
+ anchorChangeHandler: AnchorChangeHandler? = null,
+ calculateAnchor: (value: T, layoutSize: IntSize) -> Float?,
+) = this.then(SwipeAnchorsModifier(
+ onDensityChanged = { state.density = it },
+ onSizeChanged = { layoutSize ->
+ val previousAnchors = state.anchors
+ val newAnchors = mutableMapOf()
+ possibleValues.forEach {
+ val anchorValue = calculateAnchor(it, layoutSize)
+ if (anchorValue != null) {
+ newAnchors[it] = anchorValue
+ }
+ }
+ if (previousAnchors != newAnchors) {
+ val previousTarget = state.targetValue
+ val stateRequiresCleanup = state.updateAnchors(newAnchors)
+ if (stateRequiresCleanup) {
+ anchorChangeHandler?.onAnchorsChanged(
+ previousTarget,
+ previousAnchors,
+ newAnchors
+ )
+ }
+ }
+ },
+ inspectorInfo = debugInspectorInfo {
+ name = "swipeAnchors"
+ properties["state"] = state
+ properties["possibleValues"] = possibleValues
+ properties["anchorChangeHandler"] = anchorChangeHandler
+ properties["calculateAnchor"] = calculateAnchor
+ }
+))
+
+/**
+ * State of the [swipeableV2] modifier.
+ *
+ * This contains necessary information about any ongoing swipe or animation and provides methods
+ * to change the state either immediately or by starting an animation. To create and remember a
+ * [SwipeableV2State] use [rememberSwipeableV2State].
+ *
+ * @param initialValue The initial value of the state.
+ * @param animationSpec The default animation that will be used to animate to a new state.
+ * @param confirmValueChange Optional callback invoked to confirm or veto a pending state change.
+ * @param positionalThreshold The positional threshold to be used when calculating the target state
+ * while a swipe is in progress and when settling after the swipe ends. This is the distance from
+ * the start of a transition. It will be, depending on the direction of the interaction, added or
+ * subtracted from/to the origin offset. It should always be a positive value. See the
+ * [fractionalPositionalThreshold] and [fixedPositionalThreshold] methods.
+ * @param velocityThreshold The velocity threshold (in dp per second) that the end velocity has to
+ * exceed in order to animate to the next state, even if the [positionalThreshold] has not been
+ * reached.
+ */
+@Stable
+@ExperimentalMaterial3Api
+internal class SwipeableV2State(
+ initialValue: T,
+ internal val animationSpec: AnimationSpec = SwipeableV2Defaults.AnimationSpec,
+ internal val confirmValueChange: (newValue: T) -> Boolean = { true },
+ internal val positionalThreshold: Density.(totalDistance: Float) -> Float =
+ SwipeableV2Defaults.PositionalThreshold,
+ internal val velocityThreshold: Dp = SwipeableV2Defaults.VelocityThreshold,
+) {
+
+ private val swipeMutex = InternalMutatorMutex()
+
+ internal val swipeDraggableState = object : DraggableState {
+ private val dragScope = object : DragScope {
+ override fun dragBy(pixels: Float) {
+ this@SwipeableV2State.dispatchRawDelta(pixels)
+ }
+ }
+
+ override suspend fun drag(
+ dragPriority: MutatePriority,
+ block: suspend DragScope.() -> Unit
+ ) {
+ swipe(dragPriority) { dragScope.block() }
+ }
+
+ override fun dispatchRawDelta(delta: Float) {
+ this@SwipeableV2State.dispatchRawDelta(delta)
+ }
+ }
+
+ /**
+ * The current value of the [SwipeableV2State].
+ */
+ var currentValue: T by mutableStateOf(initialValue)
+ private set
+
+ /**
+ * The target value. This is the closest value to the current offset (taking into account
+ * positional thresholds). If no interactions like animations or drags are in progress, this
+ * will be the current value.
+ */
+ val targetValue: T by derivedStateOf {
+ animationTarget ?: run {
+ val currentOffset = offset
+ if (currentOffset != null) {
+ computeTarget(currentOffset, currentValue, velocity = 0f)
+ } else currentValue
+ }
+ }
+
+ /**
+ * The current offset, or null if it has not been initialized yet.
+ *
+ * The offset will be initialized during the first measurement phase of the node that the
+ * [swipeableV2] modifier is attached to. These are the phases:
+ * Composition { -> Effects } -> Layout { Measurement -> Placement } -> Drawing
+ * During the first composition, the offset will be null. In subsequent compositions, the offset
+ * will be derived from the anchors of the previous pass.
+ * Always prefer accessing the offset from a LaunchedEffect as it will be scheduled to be
+ * executed the next frame, after layout.
+ *
+ * To guarantee stricter semantics, consider using [requireOffset].
+ */
+ @get:Suppress("AutoBoxing")
+ var offset: Float? by mutableStateOf(null)
+ private set
+
+ /**
+ * Require the current offset.
+ *
+ * @throws IllegalStateException If the offset has not been initialized yet
+ */
+ fun requireOffset(): Float = checkNotNull(offset) {
+ "The offset was read before being initialized. Did you access the offset in a phase " +
+ "before layout, like effects or composition?"
+ }
+
+ /**
+ * Whether an animation is currently in progress.
+ */
+ val isAnimationRunning: Boolean get() = animationTarget != null
+
+ /**
+ * The fraction of the progress going from [currentValue] to [targetValue], within [0f..1f]
+ * bounds.
+ */
+ /*@FloatRange(from = 0f, to = 1f)*/
+ val progress: Float by derivedStateOf {
+ val a = anchors[currentValue] ?: 0f
+ val b = anchors[targetValue] ?: 0f
+ val distance = abs(b - a)
+ if (distance > 1e-6f) {
+ val progress = (this.requireOffset() - a) / (b - a)
+ // If we are very close to 0f or 1f, we round to the closest
+ if (progress < 1e-6f) 0f else if (progress > 1 - 1e-6f) 1f else progress
+ } else 1f
+ }
+
+ /**
+ * The velocity of the last known animation. Gets reset to 0f when an animation completes
+ * successfully, but does not get reset when an animation gets interrupted.
+ * You can use this value to provide smooth reconciliation behavior when re-targeting an
+ * animation.
+ */
+ var lastVelocity: Float by mutableStateOf(0f)
+ private set
+
+ /**
+ * The minimum offset this state can reach. This will be the smallest anchor, or
+ * [Float.NEGATIVE_INFINITY] if the anchors are not initialized yet.
+ */
+ val minOffset by derivedStateOf { anchors.minOrNull() ?: Float.NEGATIVE_INFINITY }
+
+ /**
+ * The maximum offset this state can reach. This will be the biggest anchor, or
+ * [Float.POSITIVE_INFINITY] if the anchors are not initialized yet.
+ */
+ val maxOffset by derivedStateOf { anchors.maxOrNull() ?: Float.POSITIVE_INFINITY }
+
+ private var animationTarget: T? by mutableStateOf(null)
+
+ internal var anchors by mutableStateOf(emptyMap())
+
+ internal var density: Density? = null
+
+ /**
+ * Update the anchors.
+ * If the previous set of anchors was empty, attempt to update the offset to match the initial
+ * value's anchor.
+ *
+ * @return true if the state needs to be adjusted after updating the anchors, e.g. if the
+ * initial value is not found in the initial set of anchors. false if no further updates are
+ * needed.
+ */
+ internal fun updateAnchors(newAnchors: Map): Boolean {
+ val previousAnchorsEmpty = anchors.isEmpty()
+ anchors = newAnchors
+ val initialValueHasAnchor = if (previousAnchorsEmpty) {
+ val initialValue = currentValue
+ val initialValueAnchor = anchors[initialValue]
+ val initialValueHasAnchor = initialValueAnchor != null
+ if (initialValueHasAnchor) trySnapTo(initialValue)
+ initialValueHasAnchor
+ } else true
+ return !initialValueHasAnchor || !previousAnchorsEmpty
+ }
+
+ /**
+ * Whether the [value] has an anchor associated with it.
+ */
+ fun hasAnchorForValue(value: T): Boolean = anchors.containsKey(value)
+
+ /**
+ * Snap to a [targetValue] without any animation.
+ * If the [targetValue] is not in the set of anchors, the [currentValue] will be updated to the
+ * [targetValue] without updating the offset.
+ *
+ * @throws CancellationException if the interaction interrupted by another interaction like a
+ * gesture interaction or another programmatic interaction like a [animateTo] or [snapTo] call.
+ *
+ * @param targetValue The target value of the animation
+ */
+ suspend fun snapTo(targetValue: T) {
+ swipe { snap(targetValue) }
+ }
+
+ /**
+ * Animate to a [targetValue].
+ * If the [targetValue] is not in the set of anchors, the [currentValue] will be updated to the
+ * [targetValue] without updating the offset.
+ *
+ * @throws CancellationException if the interaction interrupted by another interaction like a
+ * gesture interaction or another programmatic interaction like a [animateTo] or [snapTo] call.
+ *
+ * @param targetValue The target value of the animation
+ * @param velocity The velocity the animation should start with, [lastVelocity] by default
+ */
+ suspend fun animateTo(
+ targetValue: T,
+ velocity: Float = lastVelocity,
+ ) {
+ val targetOffset = anchors[targetValue]
+ if (targetOffset != null) {
+ try {
+ swipe {
+ animationTarget = targetValue
+ var prev = offset ?: 0f
+ animate(prev, targetOffset, velocity, animationSpec) { value, velocity ->
+ // Our onDrag coerces the value within the bounds, but an animation may
+ // overshoot, for example a spring animation or an overshooting interpolator
+ // We respect the user's intention and allow the overshoot, but still use
+ // DraggableState's drag for its mutex.
+ offset = value
+ prev = value
+ lastVelocity = velocity
+ }
+ lastVelocity = 0f
+ }
+ } finally {
+ animationTarget = null
+ val endOffset = requireOffset()
+ val endState = anchors
+ .entries
+ .firstOrNull { (_, anchorOffset) -> abs(anchorOffset - endOffset) < 0.5f }
+ ?.key
+ this.currentValue = endState ?: currentValue
+ }
+ } else {
+ currentValue = targetValue
+ }
+ }
+
+ /**
+ * Find the closest anchor taking into account the velocity and settle at it with an animation.
+ */
+ suspend fun settle(velocity: Float) {
+ val previousValue = this.currentValue
+ val targetValue = computeTarget(
+ offset = requireOffset(),
+ currentValue = previousValue,
+ velocity = velocity
+ )
+ if (confirmValueChange(targetValue)) {
+ animateTo(targetValue, velocity)
+ } else {
+ // If the user vetoed the state change, rollback to the previous state.
+ animateTo(previousValue, velocity)
+ }
+ }
+
+ /**
+ * Swipe by the [delta], coerce it in the bounds and dispatch it to the [SwipeableV2State].
+ *
+ * @return The delta the consumed by the [SwipeableV2State]
+ */
+ fun dispatchRawDelta(delta: Float): Float {
+ val currentDragPosition = offset ?: 0f
+ val potentiallyConsumed = currentDragPosition + delta
+ val clamped = potentiallyConsumed.coerceIn(minOffset, maxOffset)
+ val deltaToConsume = clamped - currentDragPosition
+ if (abs(deltaToConsume) >= 0) {
+ offset = ((offset ?: 0f) + deltaToConsume).coerceIn(minOffset, maxOffset)
+ }
+ return deltaToConsume
+ }
+
+ private fun computeTarget(
+ offset: Float,
+ currentValue: T,
+ velocity: Float
+ ): T {
+ val currentAnchors = anchors
+ val currentAnchor = currentAnchors[currentValue]
+ val currentDensity = requireDensity()
+ val velocityThresholdPx = with(currentDensity) { velocityThreshold.toPx() }
+ return if (currentAnchor == offset || currentAnchor == null) {
+ currentValue
+ } else if (currentAnchor < offset) {
+ // Swiping from lower to upper (positive).
+ if (velocity >= velocityThresholdPx) {
+ currentAnchors.closestAnchor(offset, true)
+ } else {
+ val upper = currentAnchors.closestAnchor(offset, true)
+ val distance = abs(currentAnchors.getValue(upper) - currentAnchor)
+ val relativeThreshold = abs(positionalThreshold(currentDensity, distance))
+ val absoluteThreshold = abs(currentAnchor + relativeThreshold)
+ if (offset < absoluteThreshold) currentValue else upper
+ }
+ } else {
+ // Swiping from upper to lower (negative).
+ if (velocity <= -velocityThresholdPx) {
+ currentAnchors.closestAnchor(offset, false)
+ } else {
+ val lower = currentAnchors.closestAnchor(offset, false)
+ val distance = abs(currentAnchor - currentAnchors.getValue(lower))
+ val relativeThreshold = abs(positionalThreshold(currentDensity, distance))
+ val absoluteThreshold = abs(currentAnchor - relativeThreshold)
+ if (offset < 0) {
+ // For negative offsets, larger absolute thresholds are closer to lower anchors
+ // than smaller ones.
+ if (abs(offset) < absoluteThreshold) currentValue else lower
+ } else {
+ if (offset > absoluteThreshold) currentValue else lower
+ }
+ }
+ }
+ }
+
+ private fun requireDensity() = requireNotNull(density) {
+ "SwipeableState did not have a density attached. Are you using Modifier.swipeable with " +
+ "this=$this SwipeableState?"
+ }
+
+ private suspend fun swipe(
+ swipePriority: MutatePriority = MutatePriority.Default,
+ action: suspend () -> Unit
+ ): Unit = coroutineScope { swipeMutex.mutate(swipePriority, action) }
+
+ /**
+ * Attempt to snap synchronously. Snapping can happen synchronously when there is no other swipe
+ * transaction like a drag or an animation is progress. If there is another interaction in
+ * progress, the suspending [snapTo] overload needs to be used.
+ *
+ * @return true if the synchronous snap was successful, or false if we couldn't snap synchronous
+ */
+ internal fun trySnapTo(targetValue: T): Boolean = swipeMutex.tryMutate { snap(targetValue) }
+
+ private fun snap(targetValue: T) {
+ val targetOffset = anchors[targetValue]
+ if (targetOffset != null) {
+ dispatchRawDelta(targetOffset - (offset ?: 0f))
+ currentValue = targetValue
+ animationTarget = null
+ } else {
+ currentValue = targetValue
+ }
+ }
+
+ companion object {
+ /**
+ * The default [Saver] implementation for [SwipeableV2State].
+ */
+ @ExperimentalMaterial3Api
+ fun Saver(
+ animationSpec: AnimationSpec,
+ confirmValueChange: (T) -> Boolean,
+ positionalThreshold: Density.(distance: Float) -> Float,
+ velocityThreshold: Dp
+ ) = Saver, T>(
+ save = { it.currentValue },
+ restore = {
+ SwipeableV2State(
+ initialValue = it,
+ animationSpec = animationSpec,
+ confirmValueChange = confirmValueChange,
+ positionalThreshold = positionalThreshold,
+ velocityThreshold = velocityThreshold
+ )
+ }
+ )
+ }
+}
+
+/**
+ * Create and remember a [SwipeableV2State].
+ *
+ * @param initialValue The initial value.
+ * @param animationSpec The default animation that will be used to animate to a new value.
+ * @param confirmValueChange Optional callback invoked to confirm or veto a pending value change.
+ */
+@Composable
+@ExperimentalMaterial3Api
+internal fun rememberSwipeableV2State(
+ initialValue: T,
+ animationSpec: AnimationSpec = SwipeableV2Defaults.AnimationSpec,
+ confirmValueChange: (newValue: T) -> Boolean = { true }
+): SwipeableV2State {
+ return rememberSaveable(
+ initialValue, animationSpec, confirmValueChange,
+ saver = SwipeableV2State.Saver(
+ animationSpec = animationSpec,
+ confirmValueChange = confirmValueChange,
+ positionalThreshold = SwipeableV2Defaults.PositionalThreshold,
+ velocityThreshold = SwipeableV2Defaults.VelocityThreshold
+ ),
+ ) {
+ SwipeableV2State(
+ initialValue = initialValue,
+ animationSpec = animationSpec,
+ confirmValueChange = confirmValueChange,
+ positionalThreshold = SwipeableV2Defaults.PositionalThreshold,
+ velocityThreshold = SwipeableV2Defaults.VelocityThreshold
+ )
+ }
+}
+
+/**
+ * Expresses a fixed positional threshold of [threshold] dp. This will be the distance from an
+ * anchor that needs to be reached for [SwipeableV2State] to settle to the next closest anchor.
+ *
+ * @see [fractionalPositionalThreshold] for a fractional positional threshold
+ */
+@ExperimentalMaterial3Api
+internal fun fixedPositionalThreshold(threshold: Dp): Density.(distance: Float) -> Float = {
+ threshold.toPx()
+}
+
+/**
+ * Expresses a relative positional threshold of the [fraction] of the distance to the closest anchor
+ * in the current direction. This will be the distance from an anchor that needs to be reached for
+ * [SwipeableV2State] to settle to the next closest anchor.
+ *
+ * @see [fixedPositionalThreshold] for a fixed positional threshold
+ */
+@ExperimentalMaterial3Api
+internal fun fractionalPositionalThreshold(
+ fraction: Float
+): Density.(distance: Float) -> Float = { distance -> distance * fraction }
+
+/**
+ * Contains useful defaults for [swipeableV2] and [SwipeableV2State].
+ */
+@Stable
+@ExperimentalMaterial3Api
+internal object SwipeableV2Defaults {
+ /**
+ * The default animation used by [SwipeableV2State].
+ */
+ @ExperimentalMaterial3Api
+ val AnimationSpec = SpringSpec()
+
+ /**
+ * The default velocity threshold (1.8 dp per millisecond) used by [rememberSwipeableV2State].
+ */
+ @ExperimentalMaterial3Api
+ val VelocityThreshold: Dp = 125.dp
+
+ /**
+ * The default positional threshold (56 dp) used by [rememberSwipeableV2State]
+ */
+ @ExperimentalMaterial3Api
+ val PositionalThreshold: Density.(totalDistance: Float) -> Float =
+ fixedPositionalThreshold(56.dp)
+
+ /**
+ * A [AnchorChangeHandler] implementation that attempts to reconcile an in-progress animation
+ * by re-targeting it if necessary or finding the closest new anchor.
+ * If the previous anchor is not in the new set of anchors, this implementation will snap to the
+ * closest anchor.
+ *
+ * Consider implementing a custom handler for more complex components like sheets.
+ * The [animate] and [snap] lambdas hoist the animation and snap logic. Usually these will just
+ * delegate to [SwipeableV2State].
+ *
+ * @param state The [SwipeableV2State] the change handler will read from
+ * @param animate A lambda that gets invoked to start an animation to a new target
+ * @param snap A lambda that gets invoked to snap to a new target
+ */
+ @ExperimentalMaterial3Api
+ internal fun ReconcileAnimationOnAnchorChangeHandler(
+ state: SwipeableV2State,
+ animate: (target: T, velocity: Float) -> Unit,
+ snap: (target: T) -> Unit
+ ) = AnchorChangeHandler { previousTarget, previousAnchors, newAnchors ->
+ val previousTargetOffset = previousAnchors[previousTarget]
+ val newTargetOffset = newAnchors[previousTarget]
+ if (previousTargetOffset != newTargetOffset) {
+ if (newTargetOffset != null) {
+ animate(previousTarget, state.lastVelocity)
+ } else {
+ snap(newAnchors.closestAnchor(offset = state.requireOffset()))
+ }
+ }
+ }
+}
+
+/**
+ * Defines a callback that is invoked when the anchors have changed.
+ *
+ * Components with custom reconciliation logic should implement this callback, for example to
+ * re-target an in-progress animation when the anchors change.
+ *
+ * @see SwipeableV2Defaults.ReconcileAnimationOnAnchorChangeHandler for a default implementation
+ */
+@ExperimentalMaterial3Api
+internal fun interface AnchorChangeHandler {
+
+ /**
+ * Callback that is invoked when the anchors have changed, after the [SwipeableV2State] has been
+ * updated with them. Use this hook to re-launch animations or interrupt them if needed.
+ *
+ * @param previousTargetValue The target value before the anchors were updated
+ * @param previousAnchors The previously set anchors
+ * @param newAnchors The newly set anchors
+ */
+ fun onAnchorsChanged(
+ previousTargetValue: T,
+ previousAnchors: Map,
+ newAnchors: Map
+ )
+}
+
+@Stable
+private class SwipeAnchorsModifier(
+ private val onDensityChanged: (density: Density) -> Unit,
+ private val onSizeChanged: (layoutSize: IntSize) -> Unit,
+ inspectorInfo: InspectorInfo.() -> Unit,
+) : LayoutModifier, OnRemeasuredModifier, InspectorValueInfo(inspectorInfo) {
+
+ private var lastDensity: Float = -1f
+ private var lastFontScale: Float = -1f
+
+ override fun MeasureScope.measure(
+ measurable: Measurable,
+ constraints: Constraints
+ ): MeasureResult {
+ if (density != lastDensity || fontScale != lastFontScale) {
+ onDensityChanged(Density(density, fontScale))
+ lastDensity = density
+ lastFontScale = fontScale
+ }
+ val placeable = measurable.measure(constraints)
+ return layout(placeable.width, placeable.height) { placeable.place(0, 0) }
+ }
+
+ override fun onRemeasured(size: IntSize) {
+ onSizeChanged(size)
+ }
+
+ override fun toString() = "SwipeAnchorsModifierImpl(updateDensity=$onDensityChanged, " +
+ "onSizeChanged=$onSizeChanged)"
+}
+
+private fun Map.closestAnchor(
+ offset: Float = 0f,
+ searchUpwards: Boolean = false
+): T {
+ require(isNotEmpty()) { "The anchors were empty when trying to find the closest anchor" }
+ return minBy { (_, anchor) ->
+ val delta = if (searchUpwards) anchor - offset else offset - anchor
+ if (delta < 0) Float.POSITIVE_INFINITY else delta
+ }.key
+}
+
+private fun Map.minOrNull() = minOfOrNull { (_, offset) -> offset }
+private fun Map.maxOrNull() = maxOfOrNull { (_, offset) -> offset }
diff --git a/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/browse/AsyncImage.kt b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/browse/AsyncImage.kt
new file mode 100644
index 000000000..1e49cc820
--- /dev/null
+++ b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/browse/AsyncImage.kt
@@ -0,0 +1,90 @@
+/*
+ * 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.offlinemapareasapp.screens.browse
+
+import androidx.compose.foundation.Image
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.State
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.ColorFilter
+import androidx.compose.ui.graphics.DefaultAlpha
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.graphics.painter.BitmapPainter
+import androidx.compose.ui.graphics.painter.Painter
+import androidx.compose.ui.layout.ContentScale
+import com.arcgismaps.portal.LoadableImage
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.CoroutineStart.UNDISPATCHED
+import kotlinx.coroutines.launch
+
+/**
+ * Loads an image asynchronously using the [ImageLoader].
+ */
+@Composable
+fun AsyncImage(
+ imageLoader: ImageLoader,
+ modifier: Modifier = Modifier,
+ alignment: Alignment = Alignment.Center,
+ contentScale: ContentScale = ContentScale.Fit,
+ alpha: Float = DefaultAlpha,
+ colorFilter: ColorFilter? = null
+) {
+ val painter = imageLoader.image.value
+ Image(
+ painter = painter,
+ contentDescription = null,
+ modifier = modifier,
+ alignment = alignment,
+ contentScale = contentScale,
+ alpha = alpha,
+ colorFilter = colorFilter
+ )
+}
+
+/**
+ * A model to asynchronously load the image from a [LoadableImage]. Once the loading is complete
+ * the loaded image is presented via [image] State.
+ *
+ * @param loadable the [LoadableImage] to load.
+ * @param scope the CoroutineScope to run the loading job on.
+ * @param placeholder the placeholder image to show until the loading is complete.
+ */
+class ImageLoader(
+ private val loadable: LoadableImage,
+ scope: CoroutineScope,
+ placeholder: Painter,
+) {
+ private val _image: MutableState = mutableStateOf(placeholder)
+ val image: State = _image
+
+ init {
+ scope.launch(start = UNDISPATCHED) {
+ load()
+ }
+ }
+
+ private suspend fun load() {
+ loadable.load().onSuccess {
+ loadable.image?.let {
+ _image.value = BitmapPainter(it.bitmap.asImageBitmap())
+ }
+ }
+ }
+}
diff --git a/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/browse/MapListScreen.kt b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/browse/MapListScreen.kt
new file mode 100644
index 000000000..5cc19a826
--- /dev/null
+++ b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/browse/MapListScreen.kt
@@ -0,0 +1,480 @@
+/*
+ *
+ * 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.offlinemapareasapp.screens.browse
+
+import androidx.compose.animation.Crossfade
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.safeContentPadding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.layout.wrapContentSize
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ExitToApp
+import androidx.compose.material.icons.filled.AccountCircle
+import androidx.compose.material.icons.filled.Refresh
+import androidx.compose.material.icons.outlined.Close
+import androidx.compose.material.icons.outlined.Search
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.SegmentedButton
+import androidx.compose.material3.SegmentedButtonDefaults
+import androidx.compose.material3.SingleChoiceSegmentedButtonRow
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextField
+import androidx.compose.material3.TextFieldDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.painter.Painter
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.DpOffset
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import com.arcgismaps.portal.LoadableImage
+import com.arcgismaps.toolkit.offline.OfflineRepository
+import com.arcgismaps.toolkit.offlinemapareasapp.AnimatedLoading
+import com.arcgismaps.toolkit.offlinemapareasapp.R
+import java.time.Instant
+import java.time.ZoneId
+import java.time.format.DateTimeFormatter
+import java.util.Locale
+
+/**
+ * Displays a list of PortalItems using the [mapListViewModel]. Provides a callback [onItemClick]
+ * when an item is tapped.
+ */
+@Composable
+fun MapListScreen(
+ modifier: Modifier = Modifier,
+ mapListViewModel: MapListViewModel = hiltViewModel(),
+ onItemClick: (String) -> Unit = {}
+) {
+ val uiState by mapListViewModel.uiState.collectAsState()
+ val lazyListState = rememberLazyListState()
+ var showSignOutProgress by rememberSaveable { mutableStateOf(false) }
+ var isShowingOnDeviceMaps by rememberSaveable { mutableStateOf(false) }
+ Column(
+ modifier = modifier
+ .fillMaxSize()
+ .safeContentPadding()
+ ) {
+ AppSearchBar(
+ uiState.searchText,
+ isLoading = uiState.isLoading,
+ username = mapListViewModel.getUsername(),
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ onQueryChange = mapListViewModel::filterPortalItems,
+ onRefresh = mapListViewModel::refresh,
+ onSignOut = {
+ showSignOutProgress = true
+ mapListViewModel.signOut()
+ }
+ )
+
+ SingleChoiceSegmentedButtonRow(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(8.dp)
+ ) {
+ SegmentedButton(
+ shape = SegmentedButtonDefaults.itemShape(0, 2),
+ selected = (isShowingOnDeviceMaps),
+ onClick = { isShowingOnDeviceMaps = !isShowingOnDeviceMaps },
+ ) {
+ Text(
+ text = "View on-device maps",
+ fontWeight = if (isShowingOnDeviceMaps) FontWeight.Bold else FontWeight.Normal
+ )
+ }
+
+ SegmentedButton(
+ shape = SegmentedButtonDefaults.itemShape(1, 2),
+ selected = (!isShowingOnDeviceMaps),
+ onClick = { isShowingOnDeviceMaps = !isShowingOnDeviceMaps },
+ ) {
+ Text(
+ text = "View online maps",
+ fontWeight = if (!isShowingOnDeviceMaps) FontWeight.Bold else FontWeight.Normal
+ )
+ }
+ }
+
+ // use a cross fade animation to show a loading indicator when the data is loading
+ // and transition to the list of portalItems once loaded
+ Crossfade(
+ targetState = uiState.isLoading,
+ modifier = Modifier.padding(top = 12.dp),
+ label = "list fade"
+ ) { state ->
+ when (state) {
+ true -> Box(modifier = modifier.fillMaxSize()) {
+ LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
+ Text(
+ text = "Loading...",
+ modifier = Modifier.align(Alignment.Center),
+ style = MaterialTheme.typography.titleMedium
+ )
+ }
+
+ false -> if (!isShowingOnDeviceMaps) {
+ if (uiState.data.isNotEmpty()) {
+ val itemThumbnailPlaceholder =
+ painterResource(id = R.drawable.ic_default_map)
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ state = lazyListState,
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ items(
+ uiState.data
+ ) { item ->
+ MapListItem(
+ title = item.title,
+ lastModified = item.modified?.format("MMM dd yyyy")
+ ?: "",
+ shareType = item.access.encoding.uppercase(Locale.getDefault()),
+ thumbnail = item.thumbnail,
+ placeholder = itemThumbnailPlaceholder,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(100.dp)
+ ) {
+ onItemClick(item.itemId)
+ }
+ }
+ }
+ } else if (!uiState.isLoading) {
+ Column(
+ modifier = Modifier.fillMaxSize(),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text(text = "Nothing to show.")
+ }
+ }
+ } else {
+ // Showing on device maps
+ OfflineRepository.refreshOfflineMapInfos(LocalContext.current)
+ OnDeviceMapInfo(
+ offlineMapInfos = OfflineRepository.offlineMapInfos,
+ onClick = { itemId -> onItemClick(itemId) }
+ )
+ }
+ }
+ }
+ }
+ AnimatedLoading(
+ visibilityProvider = {
+ showSignOutProgress
+ },
+ modifier = Modifier.fillMaxSize(),
+ statusText = if (mapListViewModel.getUsername().isEmpty()) "Loading.." else "Signing out.."
+ )
+}
+
+/**
+ * A list item row for a PortalItem that shows the [title], [lastModified] and thumbnail. Provides
+ * an [onClick] callback when the item is tapped.
+ */
+@Composable
+fun MapListItem(
+ title: String,
+ lastModified: String,
+ shareType: String,
+ thumbnail: LoadableImage?,
+ placeholder: Painter,
+ modifier: Modifier = Modifier,
+ onClick: () -> Unit = {}
+) {
+ Row(
+ modifier = modifier.clickable { onClick() },
+ horizontalArrangement = Arrangement.Start,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Spacer(modifier = Modifier.width(20.dp))
+ Box {
+ MapListItemThumbnail(
+ loadableImage = thumbnail,
+ placeholder = placeholder,
+ modifier = Modifier
+ .fillMaxHeight(0.8f)
+ .aspectRatio(16 / 9f)
+ .clip(RoundedCornerShape(15.dp)),
+ contentScale = ContentScale.Crop
+ )
+ Box(
+ modifier = Modifier
+ .padding(5.dp)
+ .clip(RoundedCornerShape(10.dp))
+ .wrapContentSize()
+ .background(MaterialTheme.colorScheme.primaryContainer)
+ ) {
+ Text(
+ text = shareType,
+ modifier = Modifier.padding(5.dp),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onPrimaryContainer
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.width(20.dp))
+ Column(verticalArrangement = Arrangement.spacedBy(5.dp)) {
+ Text(text = title, style = MaterialTheme.typography.bodyLarge)
+ Text(text = "Last Updated: $lastModified", style = MaterialTheme.typography.labelSmall)
+ }
+ }
+}
+
+@Composable
+fun MapListItemThumbnail(
+ loadableImage: LoadableImage?,
+ placeholder: Painter,
+ modifier: Modifier,
+ contentScale: ContentScale
+) {
+ val scope = rememberCoroutineScope()
+ loadableImage?.let {
+ val imageLoader = remember {
+ ImageLoader(
+ loadable = it,
+ scope = scope,
+ placeholder = placeholder,
+ )
+ }
+ AsyncImage(
+ imageLoader = imageLoader,
+ modifier = modifier,
+ contentScale = contentScale
+ )
+ } ?: Image(
+ painter = placeholder,
+ contentDescription = null,
+ modifier = modifier,
+ contentScale = contentScale
+ )
+}
+
+@Composable
+fun AppSearchBar(
+ query: String,
+ isLoading: Boolean,
+ username: String,
+ modifier: Modifier = Modifier,
+ onQueryChange: (String) -> Unit = {},
+ onRefresh: () -> Unit = {},
+ onSignOut: () -> Unit = {}
+) {
+ val focusManager = LocalFocusManager.current
+ var active by remember { mutableStateOf(false) }
+ var expanded by remember { mutableStateOf(false) }
+
+ TextField(
+ value = query,
+ onValueChange = {
+ onQueryChange(it)
+ },
+ modifier = modifier
+ .onFocusChanged {
+ if (it.hasFocus) {
+ active = true
+ }
+ }
+ .height(56.dp),
+ placeholder = {
+ Text(text = "Search Maps")
+ },
+ leadingIcon = {
+ Icon(imageVector = Icons.Outlined.Search, contentDescription = null)
+ },
+ trailingIcon = {
+ Row(
+ modifier = Modifier
+ .widthIn(80.dp)
+ .padding(end = 16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.End
+ ) {
+ if (query.isNotEmpty()) {
+ IconButton(onClick = { onQueryChange("") }) {
+ Icon(
+ imageVector = Icons.Outlined.Close,
+ contentDescription = null
+ )
+ }
+ }
+ IconButton(onClick = {
+ expanded = !expanded
+ active = false
+ }) {
+ Icon(
+ imageVector = Icons.Default.AccountCircle,
+ contentDescription = null,
+ modifier = Modifier.size(30.dp)
+ )
+ }
+ MaterialTheme(
+ shapes = MaterialTheme.shapes.copy(
+ extraSmall = RoundedCornerShape(
+ 16.dp
+ )
+ )
+ ) {
+ DropdownMenu(
+ expanded = expanded,
+ onDismissRequest = { expanded = false },
+ modifier = Modifier.widthIn(150.dp),
+ offset = DpOffset(0.dp, 10.dp)
+ ) {
+ DropdownMenuItem(
+ text = {
+ Text(
+ text = if (username.isEmpty()) {
+ "Not logged in"
+ } else {
+ "Logged in as $username"
+ }
+ )
+ },
+ onClick = { }
+ )
+ DropdownMenuItem(
+ text = { Text(text = "Refresh") },
+ enabled = !isLoading,
+ onClick = {
+ expanded = false
+ onRefresh()
+ },
+ leadingIcon = {
+ Icon(imageVector = Icons.Default.Refresh, contentDescription = null)
+ }
+ )
+ DropdownMenuItem(
+ text = {
+ Text(
+ text = if (username.isEmpty()) {
+ "Sign In"
+ } else {
+ "Sign Out"
+ }
+ )
+ },
+ enabled = !isLoading,
+ onClick = {
+ expanded = false
+ onSignOut()
+ },
+ leadingIcon = {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.ExitToApp,
+ contentDescription = null
+ )
+ }
+ )
+ }
+ }
+ }
+ },
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
+ keyboardActions = KeyboardActions(onSearch = {
+ active = false
+ }),
+ shape = RoundedCornerShape(30.dp),
+ colors = TextFieldDefaults.colors(
+ focusedIndicatorColor = Color.Transparent,
+ unfocusedIndicatorColor = Color.Transparent
+ ),
+ textStyle = MaterialTheme.typography.titleMedium
+ )
+
+ LaunchedEffect(active) {
+ if (!active) {
+ focusManager.clearFocus()
+ }
+ }
+}
+
+/**
+ * Utility function to convert an Instant into a string based on [format]
+ */
+fun Instant.format(format: String): String =
+ DateTimeFormatter.ofPattern(format).withZone(ZoneId.systemDefault()).format(this)
+
+@Preview
+@Composable
+fun MapListItemPreview() {
+ MapListItem(
+ title = "Water Utility",
+ lastModified = "June 1 2023",
+ shareType = "Public",
+ modifier = Modifier.size(width = 485.dp, height = 100.dp),
+ thumbnail = null,
+ placeholder = painterResource(id = R.drawable.ic_default_map)
+ )
+}
+
+@Composable
+@Preview
+fun AppBarPreview() {
+ AppSearchBar("", false, "User")
+}
diff --git a/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/browse/MapListViewModel.kt b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/browse/MapListViewModel.kt
new file mode 100644
index 000000000..88722effa
--- /dev/null
+++ b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/browse/MapListViewModel.kt
@@ -0,0 +1,129 @@
+/*
+ *
+ * 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.offlinemapareasapp.screens.browse
+
+import androidx.lifecycle.SavedStateHandle
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.arcgismaps.ArcGISEnvironment
+import com.arcgismaps.mapping.PortalItem
+import com.arcgismaps.toolkit.offlinemapareasapp.data.PortalItemRepository
+import com.arcgismaps.toolkit.offlinemapareasapp.data.PortalSettings
+import com.arcgismaps.toolkit.offlinemapareasapp.navigation.NavigationRoute
+import com.arcgismaps.toolkit.offlinemapareasapp.navigation.Navigator
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+data class MapListUIState(
+ val isLoading: Boolean,
+ val searchText: String,
+ val data: List
+)
+
+/**
+ * ViewModel class which acts as the data source of PortalItems to load.
+ */
+@HiltViewModel
+class MapListViewModel @Inject constructor(
+ @Suppress("UNUSED_PARAMETER") savedStateHandle: SavedStateHandle,
+ private val portalItemRepository: PortalItemRepository,
+ private val portalSettings: PortalSettings,
+ private val navigator: Navigator
+) : ViewModel() {
+
+ // State flow to keep track of current loading state
+ private val _isLoading = MutableStateFlow(false)
+
+ private val _searchText = MutableStateFlow("")
+
+ // State flow that combines the _isLoading and the PortalItemUseCase data flow to create
+ // a MapListUIState
+ val uiState: StateFlow =
+ combine(
+ _isLoading,
+ _searchText,
+ portalItemRepository.observe()
+ ) { isLoading, searchText, portalItemData ->
+ val data = portalItemData.filter {
+ searchText.isEmpty()
+ || it.title.uppercase().contains(searchText.uppercase())
+ || it.itemId.contains(searchText)
+ }
+ MapListUIState(isLoading, searchText, data)
+ }.stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.WhileSubscribed(1000),
+ initialValue = MapListUIState(true, "", emptyList())
+ )
+
+ init {
+ viewModelScope.launch {
+ // if the data is empty, refresh it
+ // this is used to identify first launch
+ if (portalItemRepository.getItemCount() == 0) {
+ refresh()
+ }
+ }
+ }
+
+ fun getUsername(): String {
+ val credential =
+ ArcGISEnvironment.authenticationManager.arcGISCredentialStore.getCredential(
+ portalSettings.getPortalUrl()
+ )
+ return credential?.username ?: ""
+ }
+
+ /**
+ * Refreshes the data.
+ */
+ fun refresh() {
+ if (!_isLoading.value) {
+ viewModelScope.launch {
+ _isLoading.emit(true)
+ portalItemRepository.refresh(
+ portalSettings.getPortalUrl(),
+ portalSettings.getPortalConnection()
+ )
+ _isLoading.emit(false)
+ }
+ }
+ }
+
+ fun filterPortalItems(filterText: String) {
+ _searchText.value = filterText
+ }
+
+ fun signOut() {
+ viewModelScope.launch {
+ portalItemRepository.deleteAll()
+ portalSettings.signOut()
+ // add a artificial delay before navigating screens
+ delay(500)
+ navigator.navigateTo(NavigationRoute.Login)
+ }
+ }
+}
diff --git a/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/browse/OnDeviceMapInfo.kt b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/browse/OnDeviceMapInfo.kt
new file mode 100644
index 000000000..acad59c72
--- /dev/null
+++ b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/browse/OnDeviceMapInfo.kt
@@ -0,0 +1,194 @@
+package com.arcgismaps.toolkit.offlinemapareasapp.screens.browse
+
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.text.Html
+import androidx.compose.animation.animateContentSize
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+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.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.Refresh
+import androidx.compose.material3.Button
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import com.arcgismaps.toolkit.offline.OfflineMapInfo
+import com.arcgismaps.toolkit.offline.OfflineRepository
+
+
+@Composable
+fun OnDeviceMapInfo(
+ onClick: (String) -> Unit, offlineMapInfos: List
+) {
+ val context = LocalContext.current
+
+ LazyColumn(
+ modifier = Modifier.animateContentSize(),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ offlineMapInfos.forEach { offlineMapInfo ->
+ item {
+ OfflineMapInfoCard(info = offlineMapInfo, onOpen = {
+ onClick.invoke(offlineMapInfo.id)
+ }, onDelete = {
+ OfflineRepository.removeDownloads(context, offlineMapInfo)
+ })
+ }
+ }
+ item {
+ Text(
+ modifier = Modifier.fillMaxWidth(),
+ text = "Offline map infos will be displayed here when downloads have completed.",
+ style = MaterialTheme.typography.labelSmall,
+ textAlign = TextAlign.Center
+ )
+ }
+ item {
+ OutlinedButton(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 32.dp),
+ onClick = { OfflineRepository.refreshOfflineMapInfos(context) }) {
+ Icon(Icons.Default.Refresh, null)
+ Spacer(Modifier.width(4.dp))
+ Text("Refresh: Offline map infos")
+ }
+ }
+ item {
+ Button(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 32.dp),
+ enabled = offlineMapInfos.isNotEmpty(),
+ onClick = { OfflineRepository.removeAllDownloads(context) }) {
+ Icon(Icons.Default.Delete, null)
+ Spacer(Modifier.width(4.dp))
+ Text("Remove all downloads")
+ }
+ }
+ }
+}
+
+@Composable
+fun OfflineMapInfoCard(
+ info: OfflineMapInfo,
+ placeholder: Bitmap = rememberInfoPlaceholderBitmap(),
+ onOpen: () -> Unit,
+ onDelete: () -> Unit
+) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .border(1.dp, MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(16.dp))
+ .clip(RoundedCornerShape(16.dp))
+ .background(MaterialTheme.colorScheme.surfaceContainerLowest)
+ .clickable { onOpen.invoke() },
+ contentAlignment = Alignment.Center
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(4.dp, vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ val bmp = info.thumbnail ?: placeholder
+ Image(
+ bitmap = bmp.asImageBitmap(),
+ contentDescription = "Map thumbnail",
+ contentScale = ContentScale.Crop,
+ modifier = Modifier
+ .width(100.dp)
+ .clip(RoundedCornerShape(16.dp))
+ )
+
+ Spacer(Modifier.width(8.dp))
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .weight(1f)
+ ) {
+ Text(
+ text = info.title,
+ style = MaterialTheme.typography.bodyLarge,
+ overflow = TextOverflow.Ellipsis
+ )
+ if (info.description.isNotBlank()) {
+ Spacer(Modifier.height(8.dp))
+ Text(
+ text = Html.fromHtml(info.description, Html.FROM_HTML_MODE_LEGACY).toString(),
+ style = MaterialTheme.typography.bodySmall,
+ maxLines = 4,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+ }
+ Spacer(Modifier.width(8.dp))
+ IconButton(
+ onClick = onDelete,
+ modifier = Modifier
+ .size(24.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Default.Delete,
+ contentDescription = "Delete map",
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier
+ .size(24.dp)
+ )
+ }
+ IconButton(
+ onClick = {},
+ modifier = Modifier
+ .size(24.dp)
+ ) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
+ contentDescription = "Open map",
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier
+ .size(24.dp)
+ )
+ }
+ }
+ }
+}
+
+
+@Composable
+fun rememberInfoPlaceholderBitmap(): Bitmap {
+ val resources = LocalContext.current.resources
+ return remember {
+ BitmapFactory.decodeResource(
+ resources, android.R.drawable.ic_dialog_info
+ )
+ }
+}
diff --git a/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/login/LoginScreen.kt b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/login/LoginScreen.kt
new file mode 100644
index 000000000..1b3b76a91
--- /dev/null
+++ b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/login/LoginScreen.kt
@@ -0,0 +1,476 @@
+/*
+ * 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.offlinemapareasapp.screens.login
+
+import android.widget.Toast
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.Crossfade
+import androidx.compose.animation.core.animateFloatAsState
+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.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
+import androidx.compose.foundation.layout.wrapContentHeight
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyListState
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.KeyboardArrowDown
+import androidx.compose.material.icons.filled.KeyboardArrowUp
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+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.TextField
+import androidx.compose.material3.TextFieldDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.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
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
+import androidx.hilt.navigation.compose.hiltViewModel
+import com.arcgismaps.toolkit.authentication.Authenticator
+import com.arcgismaps.toolkit.offlinemapareasapp.AnimatedLoading
+import com.arcgismaps.toolkit.offlinemapareasapp.R
+
+@Composable
+fun LoginScreen(
+ viewModel: LoginViewModel = hiltViewModel(),
+ onSuccessfulLogin: () -> Unit = {}
+) {
+ val context = LocalContext.current
+ val loginState by viewModel.loginState.collectAsState()
+ var showEnterpriseLogin by rememberSaveable { mutableStateOf(false) }
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.background
+ ) {
+ Column(
+ modifier = Modifier.padding(30.dp),
+ verticalArrangement = Arrangement.Top,
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text(
+ text = stringResource(R.string.app_name),
+ style = MaterialTheme.typography.titleLarge.copy(
+ fontWeight = FontWeight.Bold
+ )
+ )
+ AnimatedContent(
+ targetState = loginState is LoginState.Loading || loginState is LoginState.Success,
+ transitionSpec = {
+ slideInVertically { h -> h } togetherWith
+ slideOutVertically(
+ animationSpec = tween()
+ ) { h -> h } + fadeOut()
+ },
+ label = "evaluation loading animation"
+ ) {
+ Column {
+ if (it) {
+ // show a loading indicator if the currently in progress or for a successful login
+ AnimatedLoading(
+ { true },
+ modifier = Modifier.fillMaxSize(),
+ statusText = stringResource(R.string.signing_in)
+ )
+ } else {
+ Spacer(modifier = Modifier.height(50.dp))
+ LoginOptions(
+ onAgolLoginTapped = {
+ viewModel.login(useOAuth = true)
+ },
+ onEnterpriseLoginTapped = {
+ showEnterpriseLogin = true
+ },
+ skipSignInTapped = {
+ viewModel.skipSignIn()
+ }
+ )
+ }
+ }
+ }
+ }
+ }
+ EnterpriseLogin(
+ visibilityProvider = { showEnterpriseLogin },
+ loginViewModel = viewModel,
+ onCancel = {
+ showEnterpriseLogin = false
+ }
+ )
+ Authenticator(authenticatorState = viewModel.authenticatorState)
+ LaunchedEffect(Unit) {
+ viewModel.loginState.collect {
+ if (it is LoginState.Success) {
+ onSuccessfulLogin()
+ } else if (it is LoginState.Failed) {
+ showEnterpriseLogin = false
+ Toast.makeText(context, "Login failed : ${it.message}", Toast.LENGTH_LONG).show()
+ }
+ }
+ }
+}
+
+@Composable
+fun EnterpriseLogin(
+ visibilityProvider: () -> Boolean,
+ loginViewModel: LoginViewModel,
+ onCancel: () -> Unit
+) {
+ val visible = visibilityProvider()
+ if (visible) {
+ var showPortalUrlForm by remember { mutableStateOf(true) }
+ if (showPortalUrlForm) {
+ PortalURLForm(
+ recents = loginViewModel.urlHistory.collectAsState().value,
+ onSubmit = { url ->
+ showPortalUrlForm = false
+ loginViewModel.addUrlToHistory(url)
+ loginViewModel.login(url, useOAuth = false)
+ },
+ onCancel = onCancel
+ )
+ }
+ }
+}
+
+@Composable
+fun PortalURLForm(
+ recents: List,
+ onSubmit: (String) -> Unit,
+ onCancel: () -> Unit
+) {
+ var url by remember { mutableStateOf("https://") }
+ Dialog(
+ onDismissRequest = {},
+ properties = DialogProperties(
+ usePlatformDefaultWidth = false
+ )
+ ) {
+ Surface(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 20.dp),
+ shape = RoundedCornerShape(25.dp)
+ ) {
+ Column(
+ modifier = Modifier.padding(20.dp),
+ verticalArrangement = Arrangement.spacedBy(20.dp),
+ ) {
+ Text(
+ text = stringResource(R.string.enter_enterprise_url),
+ style = MaterialTheme.typography.titleMedium,
+ modifier = Modifier.fillMaxWidth(),
+ textAlign = TextAlign.Center
+ )
+ TextFieldWithHistory(
+ value = url,
+ recents = recents
+ ) {
+ url = it
+ }
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 25.dp),
+ horizontalArrangement = Arrangement.End,
+ ) {
+ Button(onClick = onCancel) {
+ Text(text = stringResource(R.string.cancel))
+ }
+ Spacer(modifier = Modifier.width(25.dp))
+ Button(onClick = {
+ onSubmit(url)
+ }) {
+ Text(text = stringResource(R.string.login))
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun TextFieldWithHistory(
+ value: String,
+ recents: List,
+ onValueChange: (String) -> Unit
+) {
+ var showRecent by remember { mutableStateOf(false) }
+ val focusManager = LocalFocusManager.current
+ Column(
+ verticalArrangement = Arrangement.spacedBy(10.dp)
+ ) {
+ TextField(
+ modifier = Modifier
+ .fillMaxWidth()
+ .wrapContentHeight()
+ .onFocusChanged {
+ showRecent = it.hasFocus
+ },
+ value = value,
+ onValueChange = onValueChange,
+ trailingIcon = {
+ if (recents.isNotEmpty()) {
+ IconButton(onClick = { showRecent = !showRecent }) {
+ Icon(
+ imageVector = if (!showRecent) Icons.Default.KeyboardArrowDown
+ else Icons.Default.KeyboardArrowUp,
+ contentDescription = null
+ )
+ }
+ }
+ },
+ prefix = {
+ Icon(
+ painter = painterResource(id = R.drawable.ic_world),
+ contentDescription = null,
+ modifier = Modifier.padding(end = 5.dp)
+ )
+ },
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Uri,
+ imeAction = ImeAction.Done
+ ),
+ keyboardActions = KeyboardActions(
+ onDone = { focusManager.clearFocus() }
+ ),
+ shape = RoundedCornerShape(10.dp),
+ colors = TextFieldDefaults.colors(
+ focusedIndicatorColor = Color.Transparent,
+ unfocusedIndicatorColor = Color.Transparent
+ )
+ )
+ Crossfade(
+ targetState = showRecent && recents.isNotEmpty(), label = "recent urls anim",
+ ) {
+ if (it) {
+ Card(
+ modifier = Modifier.heightIn(max = 175.dp)
+ ) {
+ val state = rememberLazyListState()
+ LazyColumn(
+ modifier = Modifier.verticalScrollbar(state),
+ state = state
+ ) {
+ itemsIndexed(recents) { index, url ->
+ Row(
+ modifier = Modifier
+ .clickable { onValueChange(url) }
+ .padding(
+ horizontal = 10.dp,
+ vertical = 10.dp
+ )
+ .fillMaxWidth()
+ .wrapContentHeight(),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ painter = painterResource(id = R.drawable.icon_history),
+ contentDescription = null
+ )
+ Text(
+ text = url,
+ modifier = Modifier
+ .padding(horizontal = 10.dp)
+ .weight(1f),
+ style = MaterialTheme.typography.bodyMedium,
+ overflow = TextOverflow.Ellipsis,
+ )
+ Icon(
+ painter = painterResource(id = R.drawable.icon_restore),
+ contentDescription = null
+ )
+ }
+ if (index < recents.lastIndex)
+ HorizontalDivider()
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun LoginOptions(
+ modifier: Modifier = Modifier,
+ onAgolLoginTapped: () -> Unit,
+ onEnterpriseLoginTapped: () -> Unit,
+ skipSignInTapped: () -> Unit
+) {
+ Column(
+ 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 = onAgolLoginTapped,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 40.dp)
+ ) {
+ Text(
+ text = stringResource(R.string.sign_in_with_agol),
+ modifier = Modifier.padding(5.dp),
+ )
+ }
+ Button(
+ onClick = onEnterpriseLoginTapped,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 40.dp)
+ ) {
+ Text(
+ text = stringResource(R.string.sign_in_with_enterprise),
+ modifier = Modifier.padding(5.dp)
+ )
+ }
+ }
+}
+
+fun Modifier.verticalScrollbar(
+ state: LazyListState,
+ width: Dp = 5.dp
+): Modifier = composed {
+ val targetAlpha = if (state.isScrollInProgress) 1f else 0f
+ val duration = if (state.isScrollInProgress) 150 else 500
+ val color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f)
+
+ val alpha by animateFloatAsState(
+ targetValue = targetAlpha,
+ animationSpec = tween(durationMillis = duration),
+ label = ""
+ )
+
+ drawWithContent {
+ drawContent()
+
+ val firstVisibleElementIndex = state.layoutInfo.visibleItemsInfo.firstOrNull()?.index
+ val needDrawScrollbar = state.isScrollInProgress || alpha > 0.0f
+
+ // Draw scrollbar if scrolling or if the animation is still running and lazy column has content
+ if (needDrawScrollbar && firstVisibleElementIndex != null) {
+ val elementHeight = this.size.height / state.layoutInfo.totalItemsCount
+ val scrollbarOffsetY = firstVisibleElementIndex * elementHeight
+ val scrollbarHeight = state.layoutInfo.visibleItemsInfo.size * elementHeight
+
+ drawRoundRect(
+ color = color,
+ topLeft = Offset(this.size.width - width.toPx(), scrollbarOffsetY),
+ size = Size(width.toPx(), scrollbarHeight),
+ cornerRadius = CornerRadius(10F, 10F),
+ alpha = alpha
+ )
+ }
+ }
+}
+
+@Preview
+@Composable
+fun EnterpriseLoginPreview() {
+ PortalURLForm(
+ recents = listOf(
+ "https://url1.com/portal",
+ "https://url2.com/portal",
+ "https://url3.com/portal"
+ ),
+ onSubmit = { _ ->
+ }
+ ) {
+
+ }
+}
diff --git a/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/login/LoginViewModel.kt b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/login/LoginViewModel.kt
new file mode 100644
index 000000000..b9faa4ddb
--- /dev/null
+++ b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/login/LoginViewModel.kt
@@ -0,0 +1,116 @@
+/*
+ * 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.offlinemapareasapp.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.offlinemapareasapp.data.PortalSettings
+import com.arcgismaps.toolkit.offlinemapareasapp.data.local.UrlEntry
+import com.arcgismaps.toolkit.offlinemapareasapp.data.local.UrlHistoryDao
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class LoginViewModel @Inject constructor(
+ private val portalSettings: PortalSettings,
+ private val urlHistoryDao: UrlHistoryDao
+) : ViewModel() {
+ val authenticatorState = AuthenticatorState()
+
+ val urlHistory: StateFlow> = urlHistoryDao.observeAll().map { urlEntries ->
+ urlEntries.map {
+ it.url
+ }
+ }.stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.WhileSubscribed(1000),
+ initialValue = emptyList()
+ )
+
+ private val _loginState: MutableStateFlow = MutableStateFlow(LoginState.NotLoggedIn)
+ val loginState = _loginState.asStateFlow()
+
+ private val oAuthRedirectUri = "featureformsapp://auth"
+ private val clientId = "iFmvhJGQEKGK1Ahf"
+
+ /**
+ * Save this url to the search history.
+ */
+ fun addUrlToHistory(url: String) {
+ viewModelScope.launch {
+ if (url.isNotEmpty()) {
+ urlHistoryDao.insert(UrlEntry(url))
+ }
+ }
+ }
+
+ /**
+ * 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) {
+ authenticatorState.oAuthUserConfigurations =
+ if (useOAuth)
+ listOf(
+ OAuthUserConfiguration(
+ portalUrl = url,
+ clientId = clientId,
+ redirectUrl = oAuthRedirectUri,
+ )
+ )
+ else listOf()
+ portalSettings.setPortalUrl(url)
+ portalSettings.setPortalConnection(Portal.Connection.Authenticated)
+ val portal = Portal(url, Portal.Connection.Authenticated)
+ portal.load().onFailure {
+ _loginState.value = LoginState.Failed(it.message ?: "")
+ }.onSuccess {
+ _loginState.value = LoginState.Success
+ }
+ }
+ }
+
+ /**
+ * Skip authentication and use the portal as an anonymous user to load any public content.
+ */
+ fun skipSignIn() {
+ viewModelScope.launch {
+ portalSettings.setPortalUrl(portalSettings.defaultPortalUrl)
+ portalSettings.setPortalConnection(Portal.Connection.Anonymous)
+ _loginState.value = LoginState.Success
+ }
+ }
+}
+
+sealed class LoginState {
+ data object Loading : LoginState()
+ data object Success : LoginState()
+ data class Failed(val message: String) : LoginState()
+ data object NotLoggedIn : LoginState()
+}
diff --git a/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/map/MapScreen.kt b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/map/MapScreen.kt
new file mode 100644
index 000000000..6dd49b063
--- /dev/null
+++ b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/map/MapScreen.kt
@@ -0,0 +1,149 @@
+/*
+ *
+ * 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.offlinemapareasapp.screens.map
+
+import androidx.compose.animation.animateContentSize
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.safeContentPadding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.material3.BottomSheetScaffold
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.SheetValue
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.rememberBottomSheetScaffoldState
+import androidx.compose.material3.rememberStandardBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import com.arcgismaps.toolkit.geoviewcompose.MapView
+import com.arcgismaps.toolkit.offline.OfflineMapAreas
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun MapScreen(mapViewModel: MapViewModel = hiltViewModel(), onBackPressed: () -> Unit = {}) {
+ val options = listOf("Go Online", "Offline Maps")
+ var isDropdownExpanded by remember { mutableStateOf(false) }
+ val state = rememberBottomSheetScaffoldState(
+ bottomSheetState = rememberStandardBottomSheetState(
+ initialValue = SheetValue.Expanded
+ )
+ )
+ var onHideSheet by remember { mutableStateOf(false) }
+ var onShowSheet by remember { mutableStateOf(true) }
+
+ LaunchedEffect(onHideSheet) {
+ state.bottomSheetState.partialExpand()
+ onHideSheet = false
+ }
+ LaunchedEffect(onShowSheet) {
+ state.bottomSheetState.expand()
+ onShowSheet = false
+ }
+
+ BottomSheetScaffold(
+ modifier = Modifier.fillMaxSize(),
+ scaffoldState = state,
+ sheetContainerColor = MaterialTheme.colorScheme.surface,
+ sheetPeekHeight = 150.dp,
+ sheetContent = {
+ OfflineMapAreas(
+ offlineMapState = mapViewModel.offlineMapState,
+ modifier = Modifier
+ .navigationBarsPadding()
+ .padding(horizontal = 16.dp)
+ .animateContentSize()
+ )
+ },
+ topBar = {
+ TopAppBar(
+ title = {
+ Text(
+ text = mapViewModel.portalItem.title,
+ style = MaterialTheme.typography.titleMedium,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ },
+ navigationIcon = {
+ IconButton(onClick = onBackPressed) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = "Back"
+ )
+ }
+ },
+ actions = {
+ IconButton(onClick = { isDropdownExpanded = true }) {
+ Icon(Icons.Filled.MoreVert, contentDescription = "More options")
+ }
+ DropdownMenu(
+ expanded = isDropdownExpanded,
+ onDismissRequest = { isDropdownExpanded = false }
+ ) {
+ options.forEach { option ->
+ DropdownMenuItem(
+ text = { Text(option) },
+ onClick = {
+ isDropdownExpanded = false
+ if (option == "Go Online") {
+ mapViewModel.selectedMap.value = null
+ mapViewModel.offlineMapState.resetSelectedMapArea()
+ onShowSheet = true
+ } else if (option == "Offline Maps") {
+ onShowSheet = true
+ }
+ },
+ enabled = option == "Offline Maps" || mapViewModel.selectedMap.value != null
+ )
+ }
+ }
+ }
+ )
+ }
+ ) { padding ->
+ Box(modifier = Modifier.padding(padding)) {
+ // show the composable map using the mapViewModel
+ MapView(
+ arcGISMap = mapViewModel.arcGISMap,
+ mapViewProxy = mapViewModel.proxy,
+ onDown = { onHideSheet = true },
+ modifier = Modifier.fillMaxSize()
+ )
+ }
+ }
+}
+
diff --git a/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/map/MapViewModel.kt b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/map/MapViewModel.kt
new file mode 100644
index 000000000..5ef187147
--- /dev/null
+++ b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/map/MapViewModel.kt
@@ -0,0 +1,78 @@
+/*
+ *
+ * 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.offlinemapareasapp.screens.map
+
+import android.app.Application
+import androidx.compose.runtime.mutableStateOf
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.SavedStateHandle
+import com.arcgismaps.mapping.ArcGISMap
+import com.arcgismaps.mapping.PortalItem
+import com.arcgismaps.toolkit.geoviewcompose.MapViewProxy
+import com.arcgismaps.toolkit.offline.OfflineMapState
+import com.arcgismaps.toolkit.offlinemapareasapp.data.PortalItemRepository
+import com.arcgismaps.toolkit.offlinemapareasapp.di.ApplicationScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+/**
+ * Base class for context aware AndroidViewModel. This class must have only a single application
+ * parameter.
+ */
+open class BaseMapViewModel(application: Application) : AndroidViewModel(application)
+
+/**
+ * A view model for the OfflineMapAreas MapView UI
+ * @constructor to be invoked by injection
+ */
+@HiltViewModel
+class MapViewModel @Inject constructor(
+ savedStateHandle: SavedStateHandle,
+ portalItemRepository: PortalItemRepository,
+ application: Application,
+ @ApplicationScope private val scope: CoroutineScope
+) : BaseMapViewModel(application) {
+ private val itemId: String = savedStateHandle["uri"]!!
+
+ val proxy: MapViewProxy = MapViewProxy()
+
+ var portalItem: PortalItem = portalItemRepository(itemId)
+ ?: throw IllegalStateException("portal item not found with id $itemId")
+
+ private val onlineMap = ArcGISMap(portalItem)
+ val selectedMap = mutableStateOf(null)
+
+ val arcGISMap
+ get() = selectedMap.value ?: onlineMap
+
+ val offlineMapState = OfflineMapState(arcGISMap) {
+ selectedMap.value = it
+ }
+
+
+ init {
+ scope.launch {
+ // load the map and set the UI state to not editing
+ arcGISMap.load()
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/theme/Color.kt b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/theme/Color.kt
new file mode 100644
index 000000000..d53c785cc
--- /dev/null
+++ b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/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.offlinemapareasapp.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/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/theme/Theme.kt b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/theme/Theme.kt
new file mode 100644
index 000000000..5e9012a9c
--- /dev/null
+++ b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/theme/Theme.kt
@@ -0,0 +1,89 @@
+/*
+ *
+ * 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.offlinemapareasapp.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
+@Suppress("DEPRECATION")
+fun OfflineMapAreasAppTheme(
+ 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/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/theme/Type.kt b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/theme/Type.kt
new file mode 100644
index 000000000..d6b7378c5
--- /dev/null
+++ b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/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.offlinemapareasapp.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/OfflineMapAreasApp/app/src/main/res/drawable/ic_default_map.xml b/microapps/OfflineMapAreasApp/app/src/main/res/drawable/ic_default_map.xml
new file mode 100644
index 000000000..e7df7b132
--- /dev/null
+++ b/microapps/OfflineMapAreasApp/app/src/main/res/drawable/ic_default_map.xml
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/microapps/OfflineMapAreasApp/app/src/main/res/drawable/ic_topographic_map.jpeg b/microapps/OfflineMapAreasApp/app/src/main/res/drawable/ic_topographic_map.jpeg
new file mode 100644
index 000000000..6145fa4a8
Binary files /dev/null and b/microapps/OfflineMapAreasApp/app/src/main/res/drawable/ic_topographic_map.jpeg differ
diff --git a/microapps/OfflineMapAreasApp/app/src/main/res/drawable/ic_world.xml b/microapps/OfflineMapAreasApp/app/src/main/res/drawable/ic_world.xml
new file mode 100644
index 000000000..6bde79441
--- /dev/null
+++ b/microapps/OfflineMapAreasApp/app/src/main/res/drawable/ic_world.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
diff --git a/microapps/OfflineMapAreasApp/app/src/main/res/drawable/icon_history.xml b/microapps/OfflineMapAreasApp/app/src/main/res/drawable/icon_history.xml
new file mode 100644
index 000000000..e838a897c
--- /dev/null
+++ b/microapps/OfflineMapAreasApp/app/src/main/res/drawable/icon_history.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
diff --git a/microapps/OfflineMapAreasApp/app/src/main/res/drawable/icon_restore.xml b/microapps/OfflineMapAreasApp/app/src/main/res/drawable/icon_restore.xml
new file mode 100644
index 000000000..bb3031acd
--- /dev/null
+++ b/microapps/OfflineMapAreasApp/app/src/main/res/drawable/icon_restore.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
diff --git a/microapps/OfflineMapAreasApp/app/src/main/res/values/strings.xml b/microapps/OfflineMapAreasApp/app/src/main/res/values/strings.xml
index d9483d2a7..14719e903 100644
--- a/microapps/OfflineMapAreasApp/app/src/main/res/values/strings.xml
+++ b/microapps/OfflineMapAreasApp/app/src/main/res/values/strings.xml
@@ -18,4 +18,13 @@
OfflineMapAreasApp
+ https://www.arcgis.com
+ Login
+ Cancel
+ "Enter an URL for ArcGIS Enterprise"
+ Signing in..
+ Sign in with ArcGIS Online
+ Sign in with ArcGIS Enterprise
+ Skip sign in
+ Browse Demo Maps