diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 97f8d40e3..84a691b02 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,7 +14,6 @@ androidxMaterialIcons = "1.4.3" androidxTestExt = "1.1.2" androidxViewmodelCompose = "2.6.1" androidxWindow = "1.2.0" -coil = "2.4.0" compileSdk = "34" compose-material3 = "1.1.0" compose-navigation = "2.7.5" @@ -50,7 +49,6 @@ androidx-test-ext = { group = "androidx.test.ext", name = "junit-ktx", version.r androidx-test-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "androidxEspresso" } androidx-window-core = { group = "androidx.window", name = "window-core", version.ref = "androidxWindow" } androidx-window = { group = "androidx.window", name = "window", version.ref = "androidxWindow" } -coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" } hilt-android-core = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" } hilt-ext-compiler = { group = "androidx.hilt", name = "hilt-compiler", version.ref = "hiltExt" } diff --git a/microapps/FeatureFormsApp/app/build.gradle.kts b/microapps/FeatureFormsApp/app/build.gradle.kts index 7ebffcf12..35b22a6ca 100644 --- a/microapps/FeatureFormsApp/app/build.gradle.kts +++ b/microapps/FeatureFormsApp/app/build.gradle.kts @@ -76,8 +76,6 @@ dependencies { annotationProcessor(libs.room.compiler) implementation(libs.room.ext) kapt(libs.room.compiler) - // coil - implementation(libs.coil.compose) // jetpack window manager implementation(libs.androidx.window) implementation(libs.androidx.window.core) diff --git a/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/data/PortalItemRepository.kt b/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/data/PortalItemRepository.kt index 14935af46..14af6a570 100644 --- a/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/data/PortalItemRepository.kt +++ b/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/data/PortalItemRepository.kt @@ -1,7 +1,7 @@ package com.arcgismaps.toolkit.featureformsapp.data -import android.graphics.Bitmap import android.util.Log +import com.arcgismaps.LoadStatus import com.arcgismaps.mapping.PortalItem import com.arcgismaps.portal.Portal import com.arcgismaps.toolkit.featureformsapp.data.local.ItemCacheDao @@ -14,16 +14,11 @@ 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 -import java.io.File -import java.io.FileOutputStream - -data class PortalItemData( - val portalItem: PortalItem, - val thumbnailUri: String -) /** * A repository to map the data source items into loaded PortalItems. This is the primary repository @@ -43,15 +38,16 @@ class PortalItemRepository( private val mutex = Mutex() @OptIn(ExperimentalCoroutinesApi::class) - private val portalItemsFlow: Flow> = + private val portalItemsFlow: Flow> = itemCacheDao.observeAll().mapLatest { entries -> // map the cache entries into loaded portal items entries.mapNotNull { entry -> val portal = Portal(entry.portalUrl) val portalItem = PortalItem.fromJsonOrNull(entry.json, portal) portalItem?.let { - portalItems[portalItem.itemId] = portalItem - PortalItemData(portalItem, entry.thumbnailUri) + portalItem.also { + portalItems[portalItem.itemId] = it + } } } }.flowOn(dispatcher) @@ -59,22 +55,21 @@ class PortalItemRepository( /** * Returns the list of loaded PortalItemData as a flow. */ - fun observe(): Flow> = portalItemsFlow + fun observe(): Flow> = portalItemsFlow /** - * Refreshes the underlying data source to fetch the latest content. [forceUpdate] when set to - * true, will clear the existing cache. + * 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, - forceUpdate: Boolean = false + connection: Portal.Connection ) = withContext(dispatcher) { mutex.withLock { - if (forceUpdate) deleteAllCacheEntries() + // delete existing cache items + itemCacheDao.deleteAll() portalItems.clear() // get local items val localItems = getListOfMaps().map { ItemData(it) } @@ -85,9 +80,12 @@ class PortalItemRepository( } } - suspend fun deleteAll() = withContext(dispatcher) { + /** + * Deletes all the portal items from the local cache storage. + */ + suspend fun deleteAll() = withContext(Dispatchers.IO) { mutex.withLock { - deleteAllCacheEntries() + itemCacheDao.deleteAll() portalItems.clear() } } @@ -102,71 +100,46 @@ class PortalItemRepository( /** * Loads the list of [items] into loaded portal items and adds them to the Cache. */ - private suspend fun loadAndCachePortalItems(items: List) { - val entries = items.mapNotNull { itemData -> - val portalItem = PortalItem(itemData.url) - // ignore if the portal items fails to load - val result = portalItem.load().onFailure { - Log.e("PortalItemRepository", "loadAndCachePortalItems: $it") + 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() } - if (result.isFailure) { + // 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 { - val thumbnailUri = portalItem.thumbnail?.let { thumbnail -> - thumbnail.load() - thumbnail.image?.bitmap?.let { bitmap -> - createThumbnail( - portalItem.itemId, - bitmap - ) - } - } ?: "" ItemCacheEntry( - itemData.url, - portalItem.toJson(), - thumbnailUri, - portalItem.portal.url + itemId = item.itemId, + json = item.toJson(), + portalUrl = item.portal.url ) } } - // purge existing items and add the updated items - createCacheEntries(entries) + // insert all the items into the local cache storage + insertCacheEntries(entries) } /** * Deletes and inserts the list of [entries] using the [ItemCacheDao]. */ - private suspend fun createCacheEntries(entries: List) = + private suspend fun insertCacheEntries(entries: List) = withContext(dispatcher) { itemCacheDao.deleteAndInsert(entries) } - /** - * Deletes all entries in the database using the [ItemCacheDao]. - */ - private suspend fun deleteAllCacheEntries() = - withContext(Dispatchers.IO) { - itemCacheDao.deleteAll() - val thumbsDir = File("$filesDir/thumbs") - if (thumbsDir.exists()) thumbsDir.deleteRecursively() - } - - /** - * Creates a JPEG thumbnail using the [bitmap] with [name] filename in the local files - * directory and returns the absolute path to the file. - */ - private suspend fun createThumbnail(name: String, bitmap: Bitmap): String = - withContext(Dispatchers.IO) { - val thumbsDir = File("$filesDir/thumbs") - if (!thumbsDir.exists()) thumbsDir.mkdirs() - val file = File("${thumbsDir.absolutePath}/${name}.jpg") - file.createNewFile() - FileOutputStream(file).use { - bitmap.compress(Bitmap.CompressFormat.JPEG, 100, it) - } - file.absolutePath - } - operator fun invoke(itemId: String): PortalItem? = portalItems[itemId] } diff --git a/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/data/local/ItemCacheDao.kt b/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/data/local/ItemCacheDao.kt index b8bb9b7a0..6287151dc 100644 --- a/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/data/local/ItemCacheDao.kt +++ b/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/data/local/ItemCacheDao.kt @@ -18,7 +18,6 @@ import kotlinx.coroutines.flow.Flow data class ItemCacheEntry( @PrimaryKey val itemId: String, val json: String, - val thumbnailUri: String, val portalUrl: String ) @@ -83,7 +82,7 @@ interface ItemCacheDao { /** * The room database that contains the ItemCacheEntry table. */ -@Database(entities = [ItemCacheEntry::class], version = 1, exportSchema = false) +@Database(entities = [ItemCacheEntry::class], version = 2, exportSchema = false) abstract class ItemCacheDatabase : RoomDatabase() { abstract fun itemCacheDao() : ItemCacheDao } diff --git a/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/screens/browse/AsyncImage.kt b/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/screens/browse/AsyncImage.kt new file mode 100644 index 000000000..e990eec48 --- /dev/null +++ b/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/screens/browse/AsyncImage.kt @@ -0,0 +1,91 @@ +/* + * Copyright 2023 Esri + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.arcgismaps.toolkit.featureformsapp.screens.browse + +import android.graphics.drawable.BitmapDrawable +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/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/screens/browse/MapListScreen.kt b/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/screens/browse/MapListScreen.kt index 3a65ca8cc..fb3d2dd74 100644 --- a/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/screens/browse/MapListScreen.kt +++ b/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/screens/browse/MapListScreen.kt @@ -1,11 +1,6 @@ package com.arcgismaps.toolkit.featureformsapp.screens.browse -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.with +import androidx.compose.animation.Crossfade import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -32,7 +27,6 @@ import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AccountCircle -import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.ExitToApp import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.outlined.Close @@ -52,6 +46,7 @@ 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 @@ -59,6 +54,7 @@ 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.LocalFocusManager import androidx.compose.ui.res.painterResource @@ -67,7 +63,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel -import coil.compose.AsyncImage +import com.arcgismaps.portal.LoadableImage import com.arcgismaps.toolkit.featureformsapp.AnimatedLoading import com.arcgismaps.toolkit.featureformsapp.R import java.time.Instant @@ -79,7 +75,6 @@ import java.util.Locale * Displays a list of PortalItems using the [mapListViewModel]. Provides a callback [onItemClick] * when an item is tapped. */ -@OptIn(ExperimentalAnimationApi::class) @Composable fun MapListScreen( modifier: Modifier = Modifier, @@ -108,12 +103,9 @@ fun MapListScreen( ) // use a cross fade animation to show a loading indicator when the data is loading // and transition to the list of portalItems once loaded - AnimatedContent( + Crossfade( targetState = uiState.isLoading, modifier = Modifier.padding(top = 88.dp), - transitionSpec = { - fadeIn(animationSpec = tween(1000)) with fadeOut() - }, label = "list fade" ) { state -> when (state) { @@ -127,6 +119,7 @@ fun MapListScreen( } false -> if (uiState.data.isNotEmpty()) { + val itemThumbnailPlaceholder = painterResource(id = R.drawable.ic_default_map) LazyColumn( modifier = Modifier.fillMaxSize(), state = lazyListState, @@ -136,16 +129,17 @@ fun MapListScreen( uiState.data ) { item -> MapListItem( - title = item.portalItem.title, - lastModified = item.portalItem.modified?.format("MMM dd yyyy") + title = item.title, + lastModified = item.modified?.format("MMM dd yyyy") ?: "", - shareType = item.portalItem.access.encoding.uppercase(Locale.getDefault()), - thumbnailUri = item.thumbnailUri.ifEmpty { null }, + shareType = item.access.encoding.uppercase(Locale.getDefault()), + thumbnail = item.thumbnail, + placeholder = itemThumbnailPlaceholder, modifier = Modifier .fillMaxWidth() .height(100.dp) ) { - onItemClick(item.portalItem.itemId) + onItemClick(item.itemId) } } } @@ -179,8 +173,9 @@ fun MapListItem( title: String, lastModified: String, shareType: String, + thumbnail: LoadableImage?, + placeholder: Painter, modifier: Modifier = Modifier, - thumbnailUri: String? = null, onClick: () -> Unit = {} ) { Row( @@ -190,26 +185,15 @@ fun MapListItem( ) { Spacer(modifier = Modifier.width(20.dp)) Box { - thumbnailUri?.let { - AsyncImage( - model = it, - contentDescription = null, - modifier = Modifier - .fillMaxHeight(0.8f) - .aspectRatio(16 / 9f) - .clip(RoundedCornerShape(15.dp)), - contentScale = ContentScale.Crop - ) - } // if thumbnail is empty then use the default map placeholder - ?: Image( - painter = painterResource(id = R.drawable.ic_default_map), - contentDescription = null, - modifier = Modifier - .fillMaxHeight(0.8f) - .aspectRatio(16 / 9f) - .clip(RoundedCornerShape(15.dp)), - contentScale = ContentScale.Crop - ) + 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) @@ -234,6 +218,35 @@ fun MapListItem( } } +@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, @@ -241,7 +254,7 @@ fun AppSearchBar( username: String, modifier: Modifier = Modifier, onQueryChange: (String) -> Unit = {}, - onRefresh: (Boolean) -> Unit = {}, + onRefresh: () -> Unit = {}, onSignOut: () -> Unit = {} ) { val focusManager = LocalFocusManager.current @@ -289,7 +302,7 @@ fun AppSearchBar( Icon( imageVector = Icons.Default.AccountCircle, contentDescription = null, - modifier = Modifier.size(24.dp) + modifier = Modifier.size(30.dp) ) } MaterialTheme( @@ -322,23 +335,12 @@ fun AppSearchBar( enabled = !isLoading, onClick = { expanded = false - onRefresh(false) + onRefresh() }, leadingIcon = { Icon(imageVector = Icons.Default.Refresh, contentDescription = null) } ) - DropdownMenuItem( - text = { Text(text = "Clear Cache") }, - enabled = !isLoading, - onClick = { - expanded = false - onRefresh(true) - }, - leadingIcon = { - Icon(imageVector = Icons.Default.Delete, contentDescription = null) - } - ) DropdownMenuItem( text = { Text( @@ -398,7 +400,9 @@ fun MapListItemPreview() { title = "Water Utility", lastModified = "June 1 2023", shareType = "Public", - modifier = Modifier.size(width = 485.dp, height = 100.dp) + modifier = Modifier.size(width = 485.dp, height = 100.dp), + thumbnail = null, + placeholder = painterResource(id = R.drawable.ic_default_map) ) } diff --git a/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/screens/browse/MapListViewModel.kt b/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/screens/browse/MapListViewModel.kt index 7c0642ef8..d7e1e6192 100644 --- a/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/screens/browse/MapListViewModel.kt +++ b/microapps/FeatureFormsApp/app/src/main/java/com/arcgismaps/toolkit/featureformsapp/screens/browse/MapListViewModel.kt @@ -4,7 +4,7 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.arcgismaps.ArcGISEnvironment -import com.arcgismaps.toolkit.featureformsapp.data.PortalItemData +import com.arcgismaps.mapping.PortalItem import com.arcgismaps.toolkit.featureformsapp.data.PortalItemRepository import com.arcgismaps.toolkit.featureformsapp.data.PortalSettings import com.arcgismaps.toolkit.featureformsapp.navigation.NavigationRoute @@ -22,7 +22,7 @@ import javax.inject.Inject data class MapListUIState( val isLoading: Boolean, val searchText: String, - val data: List + val data: List ) /** @@ -51,8 +51,8 @@ class MapListViewModel @Inject constructor( ) { isLoading, searchText, portalItemData -> val data = portalItemData.filter { searchText.isEmpty() - || it.portalItem.title.uppercase().contains(searchText.uppercase()) - || it.portalItem.itemId.contains(searchText) + || it.title.uppercase().contains(searchText.uppercase()) + || it.itemId.contains(searchText) } MapListUIState(isLoading, searchText, data) }.stateIn( @@ -66,7 +66,7 @@ class MapListViewModel @Inject constructor( // if the data is empty, refresh it // this is used to identify first launch if (portalItemRepository.getItemCount() == 0) { - refresh(false) + refresh() } } } @@ -80,16 +80,15 @@ class MapListViewModel @Inject constructor( } /** - * Refreshes the data. [forceUpdate] clears the local cache. + * Refreshes the data. */ - fun refresh(forceUpdate: Boolean) { + fun refresh() { if (!_isLoading.value) { viewModelScope.launch { _isLoading.emit(true) portalItemRepository.refresh( portalSettings.getPortalUrl(), - portalSettings.getPortalConnection(), - forceUpdate + portalSettings.getPortalConnection() ) _isLoading.emit(false) }