diff --git a/samples-lib/src/main/java/com/esri/arcgismaps/sample/sampleslib/components/BottomSheet.kt b/samples-lib/src/main/java/com/esri/arcgismaps/sample/sampleslib/components/BottomSheet.kt index c05478008..66affca60 100644 --- a/samples-lib/src/main/java/com/esri/arcgismaps/sample/sampleslib/components/BottomSheet.kt +++ b/samples-lib/src/main/java/com/esri/arcgismaps/sample/sampleslib/components/BottomSheet.kt @@ -16,26 +16,49 @@ package com.esri.arcgismaps.sample.sampleslib.components +import android.content.res.Configuration import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +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.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.esri.arcgismaps.sample.sampleslib.theme.SampleAppTheme /** * Composable component used to display a custom bottom sheet for samples. * The bottom sheet can display any @Composable content passed to the [bottomSheetContent], - * and the visibility can be toggled using [isVisible] + * and the visibility can be toggled using [isVisible]. + * + * Optionally, pass a [sheetTitle] to display a close Icon which provides an [onDismissRequest]. */ @Composable fun BottomSheet( isVisible: Boolean, - bottomSheetContent: @Composable () -> Unit + sheetTitle: String = "", + onDismissRequest: () -> Unit = { }, + bottomSheetContent: @Composable (ColumnScope) -> Unit ) { Box( modifier = Modifier.fillMaxSize() @@ -43,10 +66,57 @@ fun BottomSheet( AnimatedVisibility( modifier = Modifier.align(Alignment.BottomCenter), visible = isVisible, - enter = slideInVertically{height -> height} + fadeIn(), - exit = slideOutVertically{height -> height} + fadeOut() + enter = slideInVertically { height -> height } + fadeIn(), + exit = slideOutVertically { height -> height } + fadeOut() + ) { + SampleAppTheme { + Surface { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (sheetTitle != "") { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = sheetTitle, + style = MaterialTheme.typography.titleLarge + ) + FilledTonalIconButton(onClick = onDismissRequest) { + Icon(Icons.Filled.Close, contentDescription = "CloseSheetIcon") + } + } + } + bottomSheetContent(this) + Spacer(Modifier.height(12.dp)) + } + } + } + } + } +} + +@Preview(showBackground = true) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true) +@Composable +fun BottomSheetPreview() { + val dropDownItems = listOf("Option #1", "Option #2", "Option #3") + SamplePreviewSurface { + BottomSheet( + sheetTitle = "Bottom sheet options:", + isVisible = true ) { - bottomSheetContent() + DropDownMenuBox( + textFieldValue = dropDownItems[0], + textFieldLabel = "Select an option", + dropDownItemList = dropDownItems, + onIndexSelected = { } + ) } } } diff --git a/samples-lib/src/main/java/com/esri/arcgismaps/sample/sampleslib/components/SampleDialog.kt b/samples-lib/src/main/java/com/esri/arcgismaps/sample/sampleslib/components/SampleDialog.kt new file mode 100644 index 000000000..bf7733525 --- /dev/null +++ b/samples-lib/src/main/java/com/esri/arcgismaps/sample/sampleslib/components/SampleDialog.kt @@ -0,0 +1,89 @@ +/* Copyright 2025 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.esri.arcgismaps.sample.sampleslib.components + +import android.content.res.Configuration +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties + +/** + * Composable component to display a dialog of the [content], which provides an [onDismissRequest]. + * The [modifier] applies common dialog layout configurations using the default [properties]. + */ +@Composable +fun SampleDialog( + modifier: Modifier = Modifier, + properties: DialogProperties = DialogProperties(), + onDismissRequest: () -> Unit, + content: @Composable (ColumnScope) -> Unit +) { + Dialog(onDismissRequest = onDismissRequest, properties = properties) { + Column( + modifier = modifier + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.background) + .padding(12.dp) + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .animateContentSize(), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + content(this) + } + } +} + +@Preview(showBackground = true) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true) +@Composable +fun DialogOptionsPreview() { + SamplePreviewSurface { + SampleDialog(onDismissRequest = {}) { + Text("Sample options: ", style = MaterialTheme.typography.titleMedium) + DropDownMenuBox( + textFieldValue = "Current selection", + textFieldLabel = "Select an option", + dropDownItemList = emptyList(), + onIndexSelected = { } + ) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + OutlinedButton(onClick = { }) { Text("Dismiss") } + } + } + } +} diff --git a/samples-lib/src/main/java/com/esri/arcgismaps/sample/sampleslib/components/SamplePreview.kt b/samples-lib/src/main/java/com/esri/arcgismaps/sample/sampleslib/components/SamplePreview.kt new file mode 100644 index 000000000..b1b2e6d41 --- /dev/null +++ b/samples-lib/src/main/java/com/esri/arcgismaps/sample/sampleslib/components/SamplePreview.kt @@ -0,0 +1,31 @@ +/* Copyright 2025 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.esri.arcgismaps.sample.sampleslib.components + +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import com.esri.arcgismaps.sample.sampleslib.theme.SampleAppTheme + +/** + * Helper composable to apply sample theme to the given [content] for previews. + */ +@Composable +fun SamplePreviewSurface(content: @Composable () -> Unit) { + SampleAppTheme { + Surface { content() } + } +} diff --git a/tools/NewModuleScript.jar b/tools/NewModuleScript.jar index 9fe1a7b9c..843e7bb97 100644 Binary files a/tools/NewModuleScript.jar and b/tools/NewModuleScript.jar differ diff --git a/tools/NewModuleScript/MainScreenBottomSheetTemplate.kt b/tools/NewModuleScript/MainScreenBottomSheetTemplate.kt new file mode 100644 index 000000000..3e6f6a102 --- /dev/null +++ b/tools/NewModuleScript/MainScreenBottomSheetTemplate.kt @@ -0,0 +1,131 @@ +/* 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.esri.arcgismaps.sample.displaycomposablemapview.screens + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.arcgismaps.toolkit.geoviewcompose.MapView +import com.esri.arcgismaps.sample.displaycomposablemapview.components.MapViewModel +import com.esri.arcgismaps.sample.sampleslib.components.BottomSheet +import com.esri.arcgismaps.sample.sampleslib.components.DropDownMenuBox +import com.esri.arcgismaps.sample.sampleslib.components.MessageDialog +import com.esri.arcgismaps.sample.sampleslib.components.SamplePreviewSurface +import com.esri.arcgismaps.sample.sampleslib.components.SampleTopAppBar + +/** + * Main screen layout for the sample app + */ +@Composable +fun MainScreen(sampleName: String) { + val mapViewModel: MapViewModel = viewModel() + var isBottomSheetVisible by remember { mutableStateOf(false) } + + Scaffold( + topBar = { SampleTopAppBar(title = sampleName) }, + floatingActionButton = { + if (!isBottomSheetVisible) { + FloatingActionButton( + modifier = Modifier.padding(bottom = 36.dp, end = 12.dp), + onClick = { isBottomSheetVisible = true } + ) { Icon(Icons.Filled.Settings, contentDescription = "Show options") } + } + }, + content = { + Column( + modifier = Modifier + .fillMaxSize() + .padding(it), + ) { + MapView( + modifier = Modifier + .fillMaxSize() + .weight(1f), + arcGISMap = mapViewModel.arcGISMap, + onVisibleAreaChanged = { isBottomSheetVisible = false } + ) + } + + BottomSheet( + isVisible = isBottomSheetVisible, + sheetTitle = "Bottom sheet options", + onDismissRequest = { isBottomSheetVisible = false } + ) { + SampleOptions( + // isCurrentOptionEnabled = ..., + // onOptionToggled = { ... }, + ) + } + + mapViewModel.messageDialogVM.apply { + if (dialogStatus) { + MessageDialog( + title = messageTitle, + description = messageDescription, + onDismissRequest = ::dismissDialog + ) + } + } + } + ) +} + +@Composable +fun SampleOptions() { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + DropDownMenuBox( + textFieldValue = "", + textFieldLabel = "Select an option", + dropDownItemList = emptyList(), + onIndexSelected = { } + ) + } +} + +@Preview(showBackground = true) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true) +@Composable +fun BottomSheetPreview() { + SamplePreviewSurface { + BottomSheet( + isVisible = true, + sheetTitle = "Bottom sheet options", + ) { + SampleOptions() + } + } +} diff --git a/tools/NewModuleScript/MainScreenDialogTemplate.kt b/tools/NewModuleScript/MainScreenDialogTemplate.kt new file mode 100644 index 000000000..81365cfe6 --- /dev/null +++ b/tools/NewModuleScript/MainScreenDialogTemplate.kt @@ -0,0 +1,129 @@ +/* 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.esri.arcgismaps.sample.displaycomposablemapview.screens + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.arcgismaps.toolkit.geoviewcompose.MapView +import com.esri.arcgismaps.sample.displaycomposablemapview.components.MapViewModel +import com.esri.arcgismaps.sample.sampleslib.components.DropDownMenuBox +import com.esri.arcgismaps.sample.sampleslib.components.MessageDialog +import com.esri.arcgismaps.sample.sampleslib.components.SampleDialog +import com.esri.arcgismaps.sample.sampleslib.components.SamplePreviewSurface +import com.esri.arcgismaps.sample.sampleslib.components.SampleTopAppBar + +/** + * Main screen layout for the sample app + */ +@Composable +fun MainScreen(sampleName: String) { + val mapViewModel: MapViewModel = viewModel() + var isDialogOptionsVisible by remember { mutableStateOf(false) } + + Scaffold( + topBar = { SampleTopAppBar(title = sampleName) }, + floatingActionButton = { + if (!isDialogOptionsVisible) { + FloatingActionButton( + modifier = Modifier.padding(bottom = 36.dp, end = 12.dp), + onClick = { isDialogOptionsVisible = true } + ) { Icon(Icons.Filled.Settings, contentDescription = "Show options") } + } + }, + content = { + Column( + modifier = Modifier + .fillMaxSize() + .padding(it), + ) { + MapView( + modifier = Modifier + .fillMaxSize() + .weight(1f), + arcGISMap = mapViewModel.arcGISMap + ) + } + + if (isDialogOptionsVisible) { + DialogOptions( + // isCurrentOptionEnabled = ..., + // onOptionToggled = { ... } + onDismissRequest = { isDialogOptionsVisible = false } + ) + } + + mapViewModel.messageDialogVM.apply { + if (dialogStatus) { + MessageDialog( + title = messageTitle, + description = messageDescription, + onDismissRequest = ::dismissDialog + ) + } + } + } + ) +} + +@Composable +fun DialogOptions( + onDismissRequest: () -> Unit +) { + SampleDialog(onDismissRequest = onDismissRequest) { + Text("Sample options: ", style = MaterialTheme.typography.titleMedium) + DropDownMenuBox( + textFieldValue = "", + textFieldLabel = "Select an option", + dropDownItemList = emptyList(), + onIndexSelected = { } + ) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + OutlinedButton(onClick = onDismissRequest) { Text("Dismiss") } + } + } +} + +@Preview(showBackground = true) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true) +@Composable +fun DialogOptionsPreview() { + SamplePreviewSurface { + DialogOptions { } + } +} diff --git a/tools/NewModuleScript/MapViewModelTemplate.kt b/tools/NewModuleScript/MapViewModelTemplate.kt index ee949b341..76bfa920f 100644 --- a/tools/NewModuleScript/MapViewModelTemplate.kt +++ b/tools/NewModuleScript/MapViewModelTemplate.kt @@ -27,7 +27,7 @@ import com.arcgismaps.mapping.Viewpoint import com.esri.arcgismaps.sample.sampleslib.components.MessageDialogViewModel import kotlinx.coroutines.launch -class MapViewModel(application: Application) : AndroidViewModel(application) { +class MapViewModel(app: Application) : AndroidViewModel(app) { //TODO - delete mutable state when the map does not change or the screen does not need to observe changes val arcGISMap by mutableStateOf( ArcGISMap(BasemapStyle.ArcGISNavigationNight).apply { @@ -40,12 +40,7 @@ class MapViewModel(application: Application) : AndroidViewModel(application) { init { viewModelScope.launch { - arcGISMap.load().onFailure { error -> - messageDialogVM.showMessageDialog( - "Failed to load map", - error.message.toString() - ) - } + arcGISMap.load().onFailure { messageDialogVM.showMessageDialog(it) } } } } diff --git a/tools/NewModuleScript/src/main/kotlin/NewModuleScript.kt b/tools/NewModuleScript/src/main/kotlin/NewModuleScript.kt index e5a013446..fa0fad1b1 100644 --- a/tools/NewModuleScript/src/main/kotlin/NewModuleScript.kt +++ b/tools/NewModuleScript/src/main/kotlin/NewModuleScript.kt @@ -33,6 +33,8 @@ fun main() { run() } +private enum class SampleTemplateTypes { Basic, FAB_DialogOptions, FAB_BottomSheetOptions } + private var sampleName: String = "" private var sampleWithHyphen: String = "" private var sampleWithoutSpaces: String = "" @@ -40,6 +42,8 @@ private var samplesRepoPath: String = "" private var sampleNameUnderscore: String = "" private var sampleNameCamelCase: String = "" private var sampleCategory: String = "Maps" +private var sampleTemplateType = SampleTemplateTypes.Basic + fun run() { val scanner = Scanner(System.`in`) @@ -58,6 +62,11 @@ fun run() { print("Enter a number (1-11) to sample category: ") sampleCategory = getSampleCategory(scanner.nextLine().trim().toIntOrNull()) + // Get the sample template type + println("Choose the sample template type: \n1: Basic \n2: FAB with DialogOptions \n3: FAB with BottomSheetOptions") + print("Enter a number (1-3) to sample template type: ") + sampleTemplateType = getSampleTemplate(scanner.nextLine().trim().toIntOrNull()) + // Handles either if JAR file or source code is executed. samplesRepoPath = Paths.get("").toAbsolutePath().toString().replace("/NewModuleScript", "") samplesRepoPath = samplesRepoPath.replace("/tools", "") @@ -93,6 +102,18 @@ private fun getSampleCategory(i: Int?): String { return "" } +private fun getSampleTemplate(i: Int?): SampleTemplateTypes { + if (i == null || i > 3 || i < 1) { + exitProgram(Exception("Invalid category input")) + } + when (i) { + 1 -> return SampleTemplateTypes.Basic + 2 -> return SampleTemplateTypes.FAB_DialogOptions + 3 -> return SampleTemplateTypes.FAB_BottomSheetOptions + } + return SampleTemplateTypes.Basic +} + /** * This function cleans up unwanted files copied * when createFilesAndFolders() is called @@ -134,7 +155,11 @@ private fun createFilesAndFolders() { // Copy Kotlin template files to new sample val mainActivityTemplate = File("$samplesRepoPath/tools/NewModuleScript/MainActivityTemplate.kt") val mapViewModelTemplate = File("$samplesRepoPath/tools/NewModuleScript/MapViewModelTemplate.kt") - val mainScreenTemplate = File("$samplesRepoPath/tools/NewModuleScript/MainScreenTemplate.kt") + val mainScreenTemplate = when(sampleTemplateType){ + SampleTemplateTypes.Basic -> File("$samplesRepoPath/tools/NewModuleScript/MainScreenTemplate.kt") + SampleTemplateTypes.FAB_DialogOptions -> File("$samplesRepoPath/tools/NewModuleScript/MainScreenDialogTemplate.kt") + SampleTemplateTypes.FAB_BottomSheetOptions -> File("$samplesRepoPath/tools/NewModuleScript/MainScreenBottomSheetTemplate.kt") + } // Perform copy FileUtils.copyFileToDirectory(mainActivityTemplate, packageDirectory) @@ -156,7 +181,7 @@ private fun createFilesAndFolders() { componentsDir = File("$packageDirectory/screens") componentsDir.mkdirs() FileUtils.copyFileToDirectory(mainScreenTemplate, componentsDir) - source = Paths.get("$componentsDir/MainScreenTemplate.kt") + source = Paths.get("$componentsDir/${mainScreenTemplate.name}") Files.move(source, source.resolveSibling("${sampleNameCamelCase}Screen.kt")) } @@ -252,8 +277,13 @@ private fun updateSampleContent() { fileContent = fileContent.replace("sample.displaycomposablemapview", "sample.$sampleWithoutSpaces") fileContent = fileContent.replace("MapViewModel", "${sampleNameCamelCase}ViewModel") fileContent = fileContent.replace("MainScreen(", "${sampleNameCamelCase}Screen(") - fileContent = - fileContent.replace("display_composable_map_view_app_name", "${sampleNameUnderscore}_app_name") + fileContent = fileContent.replace("display_composable_map_view_app_name", "${sampleNameUnderscore}_app_name") + FileUtils.write(file, fileContent, StandardCharsets.UTF_8) + + //Update AndroidManifest.xml + file = File("$samplesRepoPath/samples/$sampleWithHyphen/src/main/AndroidManifest.xml") + fileContent = FileUtils.readFileToString(file, StandardCharsets.UTF_8) + fileContent = fileContent.replace("display_composable_map_view_app_name", "${sampleNameUnderscore}_app_name") FileUtils.write(file, fileContent, StandardCharsets.UTF_8) }