diff --git a/Maps3DSamples/advanced/app/build.gradle.kts b/Maps3DSamples/advanced/app/build.gradle.kts index 9d72f83..724ce32 100644 --- a/Maps3DSamples/advanced/app/build.gradle.kts +++ b/Maps3DSamples/advanced/app/build.gradle.kts @@ -6,6 +6,7 @@ plugins { alias(libs.plugins.ksp) alias(libs.plugins.hilt.android) alias(libs.plugins.secrets.gradle.plugin) + id("com.google.gms.google-services") } android { @@ -48,7 +49,6 @@ android { } dependencies { - implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.activity.compose) @@ -78,6 +78,9 @@ dependencies { implementation(libs.maps.utils.ktx) implementation(libs.androidx.material.icons.extended) + + implementation(platform(libs.firebase.bom)) + implementation(libs.firebase.ai) } secrets { diff --git a/Maps3DSamples/advanced/app/src/main/AndroidManifest.xml b/Maps3DSamples/advanced/app/src/main/AndroidManifest.xml index c10de60..07a3c16 100644 --- a/Maps3DSamples/advanced/app/src/main/AndroidManifest.xml +++ b/Maps3DSamples/advanced/app/src/main/AndroidManifest.xml @@ -51,6 +51,12 @@ android:name=".scenarios.ScenariosActivity" android:exported="true" /> + + + \ No newline at end of file diff --git a/Maps3DSamples/advanced/app/src/main/ic_launcher-playstore.png b/Maps3DSamples/advanced/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000..7e9f825 Binary files /dev/null and b/Maps3DSamples/advanced/app/src/main/ic_launcher-playstore.png differ diff --git a/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/MainActivity.kt b/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/MainActivity.kt index 29a5ba2..58f8ec0 100644 --- a/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/MainActivity.kt +++ b/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/MainActivity.kt @@ -39,6 +39,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import com.example.advancedmaps3dsamples.ainavigator.AiNavigatorActivity import com.example.advancedmaps3dsamples.scenarios.ScenariosActivity import com.example.advancedmaps3dsamples.ui.theme.AdvancedMaps3DSamplesTheme import dagger.hilt.android.AndroidEntryPoint @@ -48,6 +49,7 @@ data class MapSample(@StringRes val label: Int, val clazz: Class<*>) private val samples = listOf( MapSample(R.string.map_sample_scenarios, ScenariosActivity::class.java), + MapSample(R.string.map_sample_ai_navigator, AiNavigatorActivity::class.java), ) @OptIn(ExperimentalMaterial3Api::class) diff --git a/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/Maps3DAdvancedApplication.kt b/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/Maps3DAdvancedApplication.kt index 22bb6c1..8086853 100644 --- a/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/Maps3DAdvancedApplication.kt +++ b/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/Maps3DAdvancedApplication.kt @@ -18,6 +18,7 @@ import android.app.Application import android.content.pm.PackageManager import android.util.Log import android.widget.Toast +import com.google.firebase.FirebaseApp import dagger.hilt.android.HiltAndroidApp import java.util.Objects diff --git a/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/ainavigator/AiNavigatorActivity.kt b/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/ainavigator/AiNavigatorActivity.kt new file mode 100644 index 0000000..3985ba3 --- /dev/null +++ b/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/ainavigator/AiNavigatorActivity.kt @@ -0,0 +1,376 @@ +package com.example.advancedmaps3dsamples.ainavigator + +import android.os.Bundle +import android.view.WindowManager +import androidx.activity.ComponentActivity +import androidx.activity.SystemBarStyle +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.viewModels +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.tween +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.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.LayersClear +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Public +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Shuffle +import androidx.compose.material.icons.filled.Stop +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults.iconButtonColors +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.LaunchedEffect +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.alpha +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.layer.drawLayer +import androidx.compose.ui.graphics.rememberGraphicsLayer +import androidx.compose.ui.unit.dp +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.example.advancedmaps3dsamples.scenarios.ThreeDMap +import com.example.advancedmaps3dsamples.ui.theme.AdvancedMaps3DSamplesTheme +import com.example.advancedmaps3dsamples.utils.toCameraString +import com.google.android.gms.maps3d.Map3DOptions +import com.google.android.gms.maps3d.model.Map3DMode +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlin.time.Duration.Companion.seconds + +@AndroidEntryPoint +class AiNavigatorActivity : ComponentActivity() { + private val viewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge( + statusBarStyle = SystemBarStyle.light(android.graphics.Color.TRANSPARENT, android.graphics.Color.TRANSPARENT), + navigationBarStyle = SystemBarStyle.light(android.graphics.Color.TRANSPARENT, android.graphics.Color.TRANSPARENT) + ) + + hideSystemUI() + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + + val options = Map3DOptions( + defaultUiDisabled = true, + centerLat = 40.02196731315463, + centerLng = -105.25453645683653, + centerAlt = 1616.0, + heading = 0.0, + tilt = 0.0, + roll = 0.0, + range = 10_000_000.0, + minHeading = 0.0, + maxHeading = 360.0, + minTilt = 0.0, + maxTilt = 90.0, + bounds = null, + mapMode = Map3DMode.SATELLITE, + mapId = null, + ) + + setContent { + val scope = rememberCoroutineScope() // This scope can be used for actions tied to UI events + val graphicsLayer = rememberGraphicsLayer() + val snackbarHostState = remember { SnackbarHostState() } + val camera by viewModel.currentCamera.collectAsStateWithLifecycle() + val compassAlpha = remember { Animatable(0.55f) } + + LaunchedEffect(viewModel.userMessage) { + // Use a new coroutine scope for collecting user messages + // to avoid being cancelled by other LaunchedEffects. + // This scope is tied to this LaunchedEffect instance. + launch { + viewModel.userMessage.collect { message -> + if (message.length > 50) { + snackbarHostState.showSnackbar(message, duration = SnackbarDuration.Long) + } else { + snackbarHostState.showSnackbar(message) + } + } + } + } + + // This LaunchedEffect controls the compass alpha based on camera heading changes. + LaunchedEffect(camera.heading) { + // When camera.heading changes, this LaunchedEffect is cancelled and restarted. + // Any coroutine launched within its scope (like the one below) is also cancelled. + + // Reset alpha to initial state and stop any ongoing animation on compassAlpha. + compassAlpha.snapTo(0.55f) + + // Launch a new coroutine within this LaunchedEffect's scope. + // This coroutine will handle the delay and subsequent fade-out animation. + // If camera.heading changes again before this completes, this coroutine will be cancelled. + launch { + delay(2.seconds) // Wait for 2 seconds of stable heading + // If this point is reached, it means camera.heading was stable for 2 seconds. + compassAlpha.animateTo( + targetValue = 0.3f, + animationSpec = tween(durationMillis = 500, easing = LinearEasing) + ) + } + } + + + AdvancedMaps3DSamplesTheme { + Scaffold( + snackbarHost = { SnackbarHost(snackbarHostState) } + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + ) { + Box(modifier = Modifier.weight(1f)) { + ThreeDMap( + modifier = Modifier + .fillMaxSize() + .drawWithContent { + graphicsLayer.record { + this@drawWithContent.drawContent() + } + drawLayer(graphicsLayer) + }, + options = options, + onMap3dViewReady = { viewModel.setGoogleMap3D(it) }, + onReleaseMap = { viewModel.releaseGoogleMap3D() }, + ) + + WhiskeyCompass( + heading = camera.heading?.toFloat() ?: 0f, + modifier = Modifier + .fillMaxWidth() + .alpha(compassAlpha.value) + .safeDrawingPadding(), + stripHeight = 90.dp, + pixelsPerDegree = 7f, + degreeLabelInterval = 30, + ) + + Row( + modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + IconButton( + onClick = { viewModel.playAnimation() }, + colors = iconButtonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary + ) + ) { + Icon( + imageVector = Icons.Filled.PlayArrow, + contentDescription = "Play" + ) + } + IconButton( + onClick = { viewModel.stopAnimation() }, + colors = iconButtonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary + ) + ) { + Icon( + imageVector = Icons.Filled.Stop, + contentDescription = "Stop" + ) + } + IconButton( + onClick = { viewModel.restartAnimation() }, + colors = iconButtonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary + ) + ) { + Icon( + imageVector = Icons.Filled.Refresh, + contentDescription = "Restart" + ) + } + IconButton( + onClick = { + // Use the general 'scope' for UI triggered actions + scope.launch { + val bitmap = graphicsLayer.toImageBitmap() + viewModel.whatAmILookingAt(bitmap) + } + }, + colors = iconButtonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary + ) + ) { + Icon( + imageVector = Icons.Filled.Info, + contentDescription = "Describe View" + ) + } + + IconButton( + onClick = { viewModel.clearMapObjects() }, + colors = iconButtonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary + ) + ) { + Icon( + imageVector = Icons.Filled.LayersClear, + contentDescription = "Clear Map Objects" + ) + } + + var mapModeButtonEnabled by remember { mutableStateOf(true) } + IconButton( + onClick = { + viewModel.nextMapMode() + mapModeButtonEnabled = false + // Use the general 'scope' for UI triggered actions + scope.launch { + delay(2.seconds) + mapModeButtonEnabled = true + } + }, + colors = iconButtonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary + ), + enabled = mapModeButtonEnabled + ) { + Icon( + imageVector = Icons.Filled.Public, + contentDescription = "Change Map Type" + ) + } + } + } + + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .padding(bottom = 16.dp) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally + ) { + var userInput by rememberSaveable { mutableStateOf("") } + val requestIsActive by viewModel.isRequestInflight.collectAsStateWithLifecycle() // Recommended + + Box(modifier = Modifier + .fillMaxWidth() + .padding(top = 0.dp, bottom = 4.dp)) { + if (requestIsActive) { + LinearProgressIndicator( + modifier = Modifier.fillMaxWidth(), + ) + } + } + + OutlinedTextField( + value = userInput, + onValueChange = { userInput = it }, + label = { Text("Where would you like to go today?") }, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp), + singleLine = false, + maxLines = 3, + trailingIcon = { + if (requestIsActive) { + IconButton(onClick = { viewModel.cancelRequest() }) { + Icon(Icons.Filled.Stop, contentDescription = "Cancel") + } + } else { + IconButton(onClick = { userInput = "" }) { + Icon(Icons.Filled.Clear, contentDescription = "Clear") + } + } + } + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Button( + onClick = { userInput = viewModel.getRandomPrompt() }, + enabled = !requestIsActive + ) { + Text("I'm feeling lucky") + } + + Spacer(modifier = Modifier.weight(1f)) + + IconButton( + onClick = { + scope.launch { + snackbarHostState.showSnackbar("Generating new prompts...") + } + viewModel.generateNewPrompts() + }, + colors = iconButtonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary + ) + ) { + Icon(imageVector = Icons.Filled.Shuffle, contentDescription = "New Prompts") + } + + Spacer(modifier = Modifier.weight(1f)) + + Button( + onClick = { + viewModel.processUserRequest(userInput, camera.toCameraString()) + }, + enabled = !requestIsActive + ) { + Text("Submit") + } + } + } + } + } + } + } + } + + private fun hideSystemUI() { + WindowCompat.setDecorFitsSystemWindows(window, false) + WindowInsetsControllerCompat(window, window.decorView).let { controller -> + controller.hide(WindowInsetsCompat.Type.systemBars()) + controller.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + } + } +} diff --git a/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/ainavigator/AiNavigatorViewModel.kt b/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/ainavigator/AiNavigatorViewModel.kt new file mode 100644 index 0000000..eec9c0a --- /dev/null +++ b/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/ainavigator/AiNavigatorViewModel.kt @@ -0,0 +1,195 @@ +package com.example.advancedmaps3dsamples.ainavigator + +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.media.MediaScannerConnection +import android.net.Uri +import android.os.Environment +import android.util.Log +import androidx.compose.ui.graphics.ImageBitmap +import androidx.lifecycle.viewModelScope +import com.example.advancedmaps3dsamples.ainavigator.data.NavigatorService +import com.example.advancedmaps3dsamples.ainavigator.data.examplePrompts +import com.example.advancedmaps3dsamples.common.Map3dViewModel +import com.example.advancedmaps3dsamples.scenarios.AnimationStep +import com.example.advancedmaps3dsamples.scenarios.ScenarioBaseViewModel +import com.example.advancedmaps3dsamples.scenarios.toAnimation +import com.example.advancedmaps3dsamples.utils.toCameraString +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.suspendCancellableCoroutine +import java.io.File +import javax.inject.Inject +import kotlin.coroutines.resume + +@HiltViewModel +class AiNavigatorViewModel @Inject constructor( + private val navigatorService: NavigatorService +) : Map3dViewModel(), ScenarioBaseViewModel { + override val TAG = this::class.java.simpleName + private var animationJob: Job? = null + private var currentAnimation: List = emptyList() + private var isAnimating = false + + private val _isRequestInflight = MutableStateFlow(false) + val isRequestInflight: StateFlow = _isRequestInflight + + private val _userMessage = Channel() + val userMessage = _userMessage.receiveAsFlow() + + val allPrompts = examplePrompts.toMutableList() + + fun processUserRequest(userInput: String, cameraString: String) { + viewModelScope.launch { + _isRequestInflight.value = true + try { + val animationString = navigatorService.getAnimationString(userInput, cameraString) + Log.w(TAG, "Got animationString: $animationString") + + currentAnimation = animationString.toAnimation() + playAnimation() + } catch (e: Exception) { + Log.e(TAG, "Error processing user request", e) + _userMessage.send("Error processing request: ${e.localizedMessage}") + } finally { + _isRequestInflight.value = false + } + } + } + + fun playAnimation() { + animationJob?.cancel() + isAnimating = true + + animationJob = viewModelScope.launch { + for (step in currentAnimation) { + step(this@AiNavigatorViewModel) + } + isAnimating = false + } + } + + fun stopAnimation() { + if (isAnimating && animationJob?.isActive == true) { + animationJob?.cancel() + isAnimating = false + } + } + + fun restartAnimation() { + stopAnimation() + if (currentAnimation.isNotEmpty()) { + playAnimation() + } + } + + fun cancelRequest() { + stopAnimation() + _isRequestInflight.value = false + } + + override suspend fun showMessage(message: String) { + _userMessage.send(message) + } + + fun generateNewPrompts() { + viewModelScope.launch { + _isRequestInflight.value = true + try { + val newPrompts = navigatorService.getNewPrompts() + Log.w(TAG, "Got new prompts: $newPrompts") + if (newPrompts.isNotEmpty()) { + allPrompts.clear() + allPrompts.addAll(newPrompts) + } + } catch (e: Exception) { + Log.e(TAG, "Error generating new prompts", e) + _userMessage.send("Error generating new prompts: ${e.localizedMessage}") + } + _isRequestInflight.value = false + } + } + + fun getRandomPrompt(): String = allPrompts.random() + + fun whatAmILookingAt(bitmap: ImageBitmap) { + val cameraString = currentCamera.value.toCameraString() + Log.w(TAG, "What am I looking at? cameraString: $cameraString") + + viewModelScope.launch { + _isRequestInflight.value = true + try { + val whatAmILookingAt = navigatorService.whatAmILookingAt(cameraString, bitmap) + Log.w(TAG, "Got whatAmILookingAt: $whatAmILookingAt") + if (whatAmILookingAt.isNotEmpty()) { + _userMessage.send(whatAmILookingAt) + } + } catch (e: Exception) { + Log.e(TAG, "Error getting whatAmILookingAt", e) + _userMessage.send("Error getting whatAmILookingAt: ${e.localizedMessage}") + } + _isRequestInflight.value = false + } + } + + fun clearMapObjects() { + viewModelScope.launch { + // Stop any ongoing animation that might be adding objects + stopAnimation() + // Call the clearObjects method from the parent Map3dViewModel + // This removes objects from the map and clears internal tracking lists. + super.clearObjects() + // Optionally, send a message to the user + _userMessage.send("Map objects cleared.") + } + } +} + +suspend fun Bitmap.saveToDisk(context: Context): Uri { + val file = File( + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES), + "screenshot-${System.currentTimeMillis()}.png" + ) + + file.writeBitmap(this, Bitmap.CompressFormat.PNG, 100) + + return scanFilePath(context, file.path) ?: throw Exception("File could not be saved") +} + +private fun File.writeBitmap(bitmap: Bitmap, format: Bitmap.CompressFormat, quality: Int) { + outputStream().use { out -> + bitmap.compress(format, quality, out) + out.flush() + } +} + +private suspend fun scanFilePath(context: Context, filePath: String): Uri? { + return suspendCancellableCoroutine { continuation -> + MediaScannerConnection.scanFile( + context, + arrayOf(filePath), + arrayOf("image/png") + ) { _, scannedUri -> + if (scannedUri == null) { + continuation.cancel(Exception("File $filePath could not be scanned")) + } else { + continuation.resume(scannedUri) + } + } + } +} + +private fun shareImage(uri: Uri, context: Context) { + val intent = Intent(Intent.ACTION_SEND).apply { + type = "image/jpeg" + putExtra(Intent.EXTRA_STREAM, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + context.startActivity(Intent.createChooser(intent, "Share Image")) +} diff --git a/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/ainavigator/AviationCompass.kt b/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/ainavigator/AviationCompass.kt new file mode 100644 index 0000000..0828224 --- /dev/null +++ b/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/ainavigator/AviationCompass.kt @@ -0,0 +1,316 @@ +package com.example.advancedmaps3dsamples.ainavigator // Keep your package structure + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column // Keep for Preview if needed, not for main compass +import androidx.compose.foundation.layout.Spacer // Keep for Preview +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding // Keep for Preview +import androidx.compose.foundation.rememberScrollState // Keep for Preview +import androidx.compose.foundation.verticalScroll // Keep for Preview +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text // Keep for Preview +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.center +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.translate +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.TextMeasurer +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.drawText +import androidx.compose.ui.text.font.FontFamily // Keep for Preview +import androidx.compose.ui.text.font.FontWeight // Keep for Preview +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp // Keep for Preview +import kotlin.math.roundToInt + +/** + * A composable that displays a customizable aviation-style "whiskey" compass. + * This version renders a flat, scrollable strip with ticks, degree labels, and cardinal directions. + * + * @param heading The current heading in degrees (0-360). + * @param modifier The modifier to be applied to the compass. + * @param stripHeight The total visual height of the compass strip. + * @param backgroundColor The background color of the compass strip. + * @param tickColor The color of the tick marks on the rotating dial. + * @param lubberLineColor The color of the central indicator line. + * @param pixelsPerDegree Controls the horizontal spacing between degree markers on the dial. + * + * @param showDegreeLabels Whether to display numeric degree labels on the strip. + * @param degreeLabelInterval Interval for numeric degree labels (e.g., every 15 degrees). + * @param degreeLabelTextStyle TextStyle for the numeric degree labels on the strip. + * @param degreeLabelVerticalOffset Vertical offset of degree labels from the bottom of the ticks. + * + * @param showCardinalLabels Whether to display cardinal direction labels (N, NE, E, etc.) on the strip. + * @param cardinalLabelTextStyle TextStyle for the cardinal direction labels on the strip. + * @param cardinalLabelVerticalOffset Vertical offset of cardinal labels from the top of the ticks. + * + * @param majorTickHeight Height of the major tick marks. + * @param minorTickHeight Height of the minor tick marks. + * @param majorTickStrokeWidth Stroke width for major ticks. + * @param minorTickStrokeWidth Stroke width for minor ticks. + * @param lubberLineStrokeWidth Stroke width for the lubber line. + */ +@Composable +fun WhiskeyCompass( + heading: Float, + modifier: Modifier = Modifier, + stripHeight: Dp = 80.dp, + backgroundColor: Color = MaterialTheme.colorScheme.primaryContainer, + tickColor: Color = MaterialTheme.colorScheme.onPrimaryContainer, + lubberLineColor: Color = Color.Red, + pixelsPerDegree: Float = 10f, + + showDegreeLabels: Boolean = true, + degreeLabelInterval: Int = 15, + degreeLabelTextStyle: TextStyle = MaterialTheme.typography.labelSmall.copy(textAlign = TextAlign.Center), + degreeLabelVerticalOffset: Dp = 4.dp, + + showCardinalLabels: Boolean = true, + cardinalLabelTextStyle: TextStyle = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold, textAlign = TextAlign.Center), + cardinalLabelVerticalOffset: Dp = 4.dp, + + majorTickHeight: Dp = 25.dp, + minorTickHeight: Dp = 15.dp, + majorTickStrokeWidth: Dp = 2.dp, + minorTickStrokeWidth: Dp = 1.dp, + lubberLineStrokeWidth: Dp = 2.dp +) { + val textMeasurer = rememberTextMeasurer() + val density = LocalDensity.current + + // Memoize measured cardinal labels for performance + val measuredCardinalLabels = remember(cardinalLabelTextStyle, density) { + // Pass density to measure if text style uses Dp for size, though typically it's Sp. + // For safety or if a custom TextStyle using Dp might be passed. + with(density) { + mapOf( + 0 to textMeasurer.measure("N", style = cardinalLabelTextStyle), + 45 to textMeasurer.measure("NE", style = cardinalLabelTextStyle), + 90 to textMeasurer.measure("E", style = cardinalLabelTextStyle), + 135 to textMeasurer.measure("SE", style = cardinalLabelTextStyle), + 180 to textMeasurer.measure("S", style = cardinalLabelTextStyle), + 225 to textMeasurer.measure("SW", style = cardinalLabelTextStyle), + 270 to textMeasurer.measure("W", style = cardinalLabelTextStyle), + 315 to textMeasurer.measure("NW", style = cardinalLabelTextStyle) + ) + } + } + + + Box( + modifier = modifier + .height(stripHeight) + .background(backgroundColor), + contentAlignment = Alignment.Center + ) { + // Canvas 1: Scrolling Compass Strip (ticks, degree labels, cardinal labels) + Canvas(modifier = Modifier.matchParentSize()) { + // Convert Dp to Px here, inside DrawScope where density is implicitly available + val majorTickHeightPx = majorTickHeight.toPx() + val minorTickHeightPx = minorTickHeight.toPx() + val majorTickStrokeWidthPx = majorTickStrokeWidth.toPx() + val minorTickStrokeWidthPx = minorTickStrokeWidth.toPx() + val degreeLabelVerticalOffsetPx = degreeLabelVerticalOffset.toPx() + val cardinalLabelVerticalOffsetPx = cardinalLabelVerticalOffset.toPx() + + val canvasWidth = size.width + val canvasCenterY = center.y // Vertical center of the strip + + // xOffset determines how much the strip is shifted horizontally + val xOffset = center.x - (heading * pixelsPerDegree) + + // The Y position where the center line of the ticks should be. + // Labels will be drawn above/below this. + val tickCenterY = canvasCenterY + + translate(left = xOffset) { + // Loop through repetitions to ensure seamless wrapping + for (repetition in -1..1) { + val repetitionBaseDegree = repetition * 360 + for (degreeInRepetition in 0 until 360) { + val absoluteDegree = repetitionBaseDegree + degreeInRepetition + val xPos = absoluteDegree * pixelsPerDegree + + // Optimization: Only draw if potentially visible + val visibilityMargin = canvasWidth // Generous margin + if (xPos < -xOffset + canvasWidth + visibilityMargin && xPos > -xOffset - visibilityMargin) { + + val isMajorTickEquivalent = degreeInRepetition % 10 == 0 + val isMinorTickEquivalent = degreeInRepetition % 5 == 0 && !isMajorTickEquivalent + + // Draw Major Ticks (every 10 degrees) + if (isMajorTickEquivalent) { + val tickTopY = tickCenterY - majorTickHeightPx / 2f + val tickBottomY = tickCenterY + majorTickHeightPx / 2f + drawLine( + color = tickColor, + start = Offset(x = xPos, y = tickTopY), + end = Offset(x = xPos, y = tickBottomY), + strokeWidth = majorTickStrokeWidthPx + ) + + // Draw Cardinal Labels above major ticks + if (showCardinalLabels && measuredCardinalLabels.containsKey(degreeInRepetition)) { + val measuredText = measuredCardinalLabels.getValue(degreeInRepetition) + drawText( + textLayoutResult = measuredText, + topLeft = Offset( + x = xPos - measuredText.size.width / 2f, + y = tickTopY - measuredText.size.height - cardinalLabelVerticalOffsetPx + ) + ) + } + } + // Draw Minor Ticks (every 5 degrees, not overlapping major) + else if (isMinorTickEquivalent) { + val tickTopY = tickCenterY - minorTickHeightPx / 2f + val tickBottomY = tickCenterY + minorTickHeightPx / 2f + drawLine( + color = tickColor, + start = Offset(x = xPos, y = tickTopY), + end = Offset(x = xPos, y = tickBottomY), + strokeWidth = minorTickStrokeWidthPx + ) + } + + // Draw Degree Labels below ticks at specified intervals + if (showDegreeLabels && degreeInRepetition % degreeLabelInterval == 0) { + val tickBottomY = tickCenterY + (if (isMajorTickEquivalent) majorTickHeightPx else if (isMinorTickEquivalent) minorTickHeightPx else 0f) / 2f + val labelText = degreeInRepetition.toString() + // It's good practice to use density for text measurement if TextStyle might involve Dp. + // For standard Sp, it's usually handled, but explicit density in remember is safer. + val measuredText = textMeasurer.measure(labelText, style = degreeLabelTextStyle) + drawText( + textLayoutResult = measuredText, + topLeft = Offset( + x = xPos - measuredText.size.width / 2f, + y = tickBottomY + degreeLabelVerticalOffsetPx + ) + ) + } + } + } + } + } + } + + // Canvas 2: Fixed Lubber Line + Canvas(modifier = Modifier.matchParentSize()) { + // Draw the lubber line vertically centered, spanning a good portion of the strip height + val lubberLineVisualPadding = stripHeight.toPx() * 0.05f // Uses DrawScope.toPx() implicitly + val currentLubberLineStrokeWidthPx = lubberLineStrokeWidth.toPx() // Uses DrawScope.toPx() implicitly + drawLine( + color = lubberLineColor, + start = Offset(x = center.x, y = lubberLineVisualPadding), + end = Offset(x = center.x, y = size.height - lubberLineVisualPadding), + strokeWidth = currentLubberLineStrokeWidthPx + ) + } + } +} + +/** + * Converts a heading in degrees to its corresponding cardinal or intercardinal direction. + * (This is not used by the compass itself anymore but kept for previews/external use) + */ +fun Float.toCardinalDirection(): String { + val normalizedHeading = (this % 360 + 360) % 360 + val directions = listOf("N", "NE", "E", "SE", "S", "SW", "W", "NW", "N") + return directions[((normalizedHeading + 22.5f) / 45f).toInt() % 8] +} + +@Preview(showBackground = true, backgroundColor = 0xFF222222) +@Composable +private fun FlatWhiskeyCompassPreview() { + val exampleHeadings = listOf( + 0f, 10f, 22.5f, 45f, 168f, 270f, 358f + ) + + Column( + modifier = Modifier + .fillMaxWidth() + .background(Color.DarkGray) + .padding(16.dp) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text("Default Flat Compass Strip", color = Color.White, style = MaterialTheme.typography.titleMedium) + WhiskeyCompass( + heading = 45f, + modifier = Modifier.fillMaxWidth(), + stripHeight = 100.dp, + pixelsPerDegree = 8f // Make it a bit denser for preview + ) + + Spacer(modifier = Modifier.height(16.dp)) + Text("Customized Labels & Ticks", color = Color.White, style = MaterialTheme.typography.titleMedium) + WhiskeyCompass( + heading = 123f, + modifier = Modifier.fillMaxWidth(), + stripHeight = 120.dp, + backgroundColor = Color(0xFF1A237E), // Dark Blue + tickColor = Color(0xFFB0BEC5), // Blue Grey + lubberLineColor = Color(0xFFFFD600), // Amber + pixelsPerDegree = 12f, + degreeLabelInterval = 10, + degreeLabelTextStyle = MaterialTheme.typography.bodySmall.copy(color = Color(0xFF81D4FA)), // Light Blue + cardinalLabelTextStyle = MaterialTheme.typography.labelLarge.copy(color = Color.White, fontWeight = FontWeight.Bold), + majorTickHeight = 30.dp, + minorTickHeight = 18.dp, + degreeLabelVerticalOffset = 6.dp, + cardinalLabelVerticalOffset = 6.dp, + majorTickStrokeWidth = 2.5.dp + ) + + Spacer(modifier = Modifier.height(16.dp)) + Text("No Cardinal Labels", color = Color.White, style = MaterialTheme.typography.titleMedium) + WhiskeyCompass( + heading = 210f, + modifier = Modifier.fillMaxWidth(), + stripHeight = 70.dp, + showCardinalLabels = false, + pixelsPerDegree = 6f + ) + + Spacer(modifier = Modifier.height(16.dp)) + Text("No Degree Labels", color = Color.White, style = MaterialTheme.typography.titleMedium) + WhiskeyCompass( + heading = 300f, + modifier = Modifier.fillMaxWidth(), + stripHeight = 70.dp, + showDegreeLabels = false, + pixelsPerDegree = 6f + ) + + + exampleHeadings.forEach { currentHeading -> + Spacer(Modifier.height(12.dp)) + Text( + text = "Test Heading: ${currentHeading.roundToInt()}°", + color = Color.LightGray, + fontFamily = FontFamily.Monospace, + fontSize = 12.sp + ) + WhiskeyCompass( + heading = currentHeading, + modifier = Modifier.fillMaxWidth(), + stripHeight = 90.dp, + pixelsPerDegree = 7f, + degreeLabelInterval = 30 // Less frequent degree labels for clarity + ) + } + } +} diff --git a/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/ainavigator/data/NavigatorService.kt b/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/ainavigator/data/NavigatorService.kt new file mode 100644 index 0000000..54c8242 --- /dev/null +++ b/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/ainavigator/data/NavigatorService.kt @@ -0,0 +1,105 @@ +package com.example.advancedmaps3dsamples.ainavigator.data + +import android.util.Log +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asAndroidBitmap +import com.google.firebase.Firebase +import com.google.firebase.ai.ai +import com.google.firebase.ai.type.GenerativeBackend +import com.google.firebase.ai.type.content +import javax.inject.Inject +import javax.inject.Singleton +import androidx.core.graphics.scale + +@Singleton +class NavigatorService @Inject constructor( +) { + val TAG = this::class.java.simpleName + + val model by lazy { + Firebase.ai(backend = GenerativeBackend.googleAI()) + .generativeModel("gemini-2.5-flash-preview-05-20") + } + + suspend fun getAnimationString(userInput: String, cameraString: String): String { + Log.d(TAG, "Calling Firebase Vertex AI: Fetching animationString for user input: $userInput") + + try { + // --- Use Firebase SDK's generateContent --- + val response = model.generateContent(promptWithCamera + "\n" + userInput + "\n" + cameraString) + // ----------------------------------------- + Log.d(TAG, "Firebase Vertex AI raw response: ${response.text}") + // Clean potential markdown code blocks from riddle response as well + val cleanedText = response.text?.sanitize()?.removePrefix("animationString=")?.removeSurrounding("\"")?.replace("\\\"", "\"") + Log.d(TAG, "Firebase Vertex AI cleaned response: $cleanedText") + return cleanedText ?: "" // TODO: default animation? Do a barrel roll...? + } catch (e: Exception) { + // TODO: Handle specific Firebase/Vertex AI exceptions if needed + Log.e(TAG, "Error getting animation from Firebase Vertex AI for $userInput", e) + throw GameRepositoryException("Unable to get animation: ${e.message}", e) + } + } + + suspend fun getNewPrompts(): List { + Log.d(TAG, "Calling Firebase Vertex AI: Fetching new prompts") + try { + // --- Use Firebase SDK's generateContent --- + val response = model.generateContent(promptGeneratorPrompt) + // ----------------------------------------- + Log.d(TAG, "Firebase Vertex AI raw response: ${response.text}") + // Clean potential markdown code blocks from riddle response as well + val cleanedText = response.text?.sanitize() + Log.d(TAG, "Firebase Vertex AI cleaned response: $cleanedText") + return cleanedText?.split("\n") ?: emptyList() + } catch (e: Exception) { + // TODO: Handle specific Firebase/Vertex AI exceptions if needed + Log.e(TAG, "Error getting prompts from Firebase Vertex AI", e) + throw GameRepositoryException("Unable to get prompts: ${e.message}", e) + } + } + + suspend fun whatAmILookingAt(cameraParams: String, bitmap: ImageBitmap): String { + Log.d(TAG, "Calling Firebase Vertex AI: Fetching whatAmILookingAt for cameraParams: $cameraParams") + try { + // Convert the Jetpack Compose ImageBitmap to an Android Bitmap + val originalBitmap = bitmap.asAndroidBitmap() + + // --- Start: Image Scaling Logic --- + // Calculate the new dimensions (50% of the original) + val newWidth = originalBitmap.width / 2 + val newHeight = originalBitmap.height / 2 + + // Create a new bitmap scaled to the new dimensions. + // The 'true' flag enables filtering for better quality. + val scaledBitmap = originalBitmap.scale(newWidth, newHeight) + // --- End: Image Scaling Logic --- + + val inputContent = content { + // Use the newly created scaledBitmap in the request + image(scaledBitmap) + text(whatAmILookingAtPrompt.replace("", cameraParams) + "") + } + + val response = model.generateContent(inputContent) + Log.d(TAG, "Firebase Vertex AI raw response: ${response.text}") + val cleanedText = response.text?.sanitize() + Log.d(TAG, "Firebase Vertex AI cleaned response: $cleanedText") + return cleanedText ?: "" + } catch (e: Exception) { + Log.e(TAG, "Error getting whatAmILookingAt from Firebase Vertex AI for $cameraParams", e) + throw GameRepositoryException("Unable to get whatAmILookingAt: ${e.message}", e) + } + } +} + +// Define a custom exception class for clarity (Optional) +class GameRepositoryException(message: String, cause: Throwable? = null) : Exception(message, cause) + +private fun String.sanitize(): String { + return trim() + .removeSurrounding("```json", "```").trim() + .removeSurrounding("```javascript", "```").trim() + .removeSurrounding("```python", "```").trim() + .removeSurrounding("```", "```").trim() + .removeSurrounding("`") +} \ No newline at end of file diff --git a/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/ainavigator/data/Prompts.kt b/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/ainavigator/data/Prompts.kt new file mode 100644 index 0000000..9c05ce8 --- /dev/null +++ b/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/ainavigator/data/Prompts.kt @@ -0,0 +1,316 @@ +package com.example.advancedmaps3dsamples.ainavigator.data + +val promptWithCamera = """ + You are a specialized AI assistant expert in 3D map camera choreography. Your primary function is to take a user's natural language description of a desired 3D map camera tour or positioning and convert it into a precise `animationString`. The camera will be viewing Earth's surface and its features; **do not generate animations that focus on the sky, celestial events, weather phenomena (like storms or auroras), or imply specific times of day that would require different lighting (e.g., "sunset," "night lights") as these cannot be rendered.** + + **Input You Will Receive:** + 1. **User Request:** The user's natural language query. + 2. **Current Camera Parameters (if provided):** You *may* also receive the camera's current state, which represents what the user is looking at *before* your animation begins. If provided, it will be in this format: + ``` + currentCameraParams = camera { + center = latLngAltitude { + latitude = + longitude = + altitude = // Altitude of the camera's focal point in meters ASL + } + heading = // Degrees, 0 is North + tilt = // Degrees, 0 is straight down, 90 is horizon + range = // Meters from camera to focal point + // Roll is always 0 and may not be present + } + ``` + + The `animationString` is a sequence of camera manipulation commands separated by semicolons (`;`). + The available commands are: + + 1. **`flyTo`**: Smoothly animates the camera to a new target position and orientation. + * Format: `flyTo=lat=,lng=,alt=,hdg=,tilt=,range=,dur=` + + 2. **`flyAround`**: Smoothly animates the camera in an orbit around a central point. + * Format: `flyAround=lat=,lng=,alt=,hdg=,tilt=,range=,dur=,count=` + + 3. **`delay`**: Pauses the animation sequence. + * Format: `delay=dur=` + + 4. **`message`**: Displays a short text message to the user. + * Format: `message=""` + + 5. **`addMarker`**: Adds a visual marker to the map. + * Format: `addMarker=id=,lat=,lng=,alt=,label="",altMode=` + * `altMode`: `absolute`, `relativeToGround`, `relativeToMesh`, `clampToGround`. + + 6. **`addPolyline`**: Adds a line (route, path) to the map using a list of points. + * Format: `addPolyline=id=,points="",color="<#AARRGGBB_or_#RRGGBB>",width=,altMode=` + * `points`: String of "lat,lng" pairs separated by semicolons. Max 100 points. + * `altMode`: `absolute`, `relativeToGround`, `relativeToMesh`, `clampToGround`. + + 7. **`addPolygon`**: Adds a filled polygon area to the map. + * Format: `addPolygon=id=,outerPoints="",fillColor="<#AARRGGBB>",strokeColor="<#AARRGGBB>",strokeWidth=,altMode=` + * `outerPoints`: String of "lat,lng" pairs (min 3) separated by semicolons. Max 100 points. + * `altMode`: `absolute`, `relativeToGround`, `relativeToMesh`, `clampToGround`. + + **How to Use Current Camera Parameters (if provided):** + * **Relative User Requests:** If the user's request seems relative to their current view (e.g., "show me nearby castles," "explore this area more," "what's over that hill from here?"), you **MUST** use the `currentCameraParams` as the starting point or primary reference for your animation. + * The `lat`, `lng`, and `alt` from `currentCameraParams.center` should inform the `lat`, `lng`, `alt` of your first `flyTo` or the `center` of your `flyAround`. + * You might adjust `hdg`, `tilt`, and `range` for the new relative target, but start your calculations or perspective from what `currentCameraParams` describe. + * **Absolute User Requests:** If the user makes an absolute request (e.g., "fly me to Tokyo," "show me the Pyramids of Giza"), the `currentCameraParams` are less critical for the *final destination*. + * You should prioritize reaching the user's specified absolute location. + * However, you *can* use `currentCameraParams` to make the *beginning* of the journey feel like a departure from the current view, making the transition smoother, before flying to the distant absolute target. + * **Do not let `currentCameraParams` override a clear, absolute request to go to a specific, distant location.** + * **If `currentCameraParams` are not provided, or if the user's request is unequivocally absolute and a contextual start offers no benefit, generate the animation string based solely on the user's textual request as before.** + * Your primary goal is to fulfill the user's request. Use `currentCameraParams` to enhance the experience when it makes sense for contextual or relative queries. + + **Important Constraints & Guidelines (Always Apply):** + * **Order of Operations (Crucial New Guideline):** + * **Unless the user's prompt explicitly dictates a different order (e.g., "fly to Paris, *then* add a marker at the Eiffel Tower"), you should generally aim to add map objects (`addMarker`, `addPolyline`, `addPolygon`) *before* initiating camera movements (`flyTo`, `flyAround`) that focus on or relate to those objects.** This allows the objects to be visible as the camera arrives or tours the area. + * For example, if asked to "show the Golden Gate Bridge and mark its towers," it's better to `addMarker` for the towers first, then `flyTo` a viewpoint of the bridge. + * If a user requests a tour with multiple stops, and each stop should be marked, add the marker for a stop *before* the `flyTo` command for that stop. + * If the user prompt implies a sequence like "first show X, then draw Y", follow that sequence. + * **Focus on Earth's Surface:** The generated animations should focus on terrestrial features, landmarks, and geography. **Avoid requests that primarily involve looking at the sky, atmospheric effects (auroras, storms), or specific times of day that imply lighting changes (e.g., "sunset," "city at night") as these cannot be accurately represented.** + * The `roll` parameter for the camera is **always 0**. + * Validate parameter ranges: lat (-90 to 90), lng (-180 to 180), hdg (0-360), tilt (0-90), **range (0 to 63,170,000)**. + * **Scale-Appropriate Range and Altitude of Camera Focus (`alt` parameter for `flyTo`/`flyAround`):** + * Adjust `range` and `alt` based on the target's scale (vast areas/cities: larger range/alt, e.g., 5km-50km range, focal `alt` well above ground; individual buildings: smaller range/alt, e.g., 100m-2km range, focal `alt` could be mid-height or top of building). + * **Crucially, ensure the `alt` for the camera's focal point is not too low.** + * **Animation Simplicity & Pacing:** + * For simple requests ("fly me to [location]"), ideally use a `flyTo` -> `message` -> `delay` (for tile loading & viewing) sequence. + * Only generate multi-step animations if a tour or multiple viewpoints are explicitly implied. + * **Tile Loading & Viewing Delay (Crucial):** + * **After a `flyTo` command moves the camera to a *new, distinct, and geographically distant location*, and *after* any associated `message` for that location, insert a `delay=dur=5000` command.** + * **Optionally, after this 5000ms tile loading delay, consider an *additional* short pacing `delay=dur=1000` or `delay=dur=2000`** for user absorption before the next major camera movement. + * Do *not* add the 5000ms tile loading delay for minor adjustments at the *same general location* or if the animation starts from `currentCameraParams` and explores a *very nearby* feature without significant travel. + * **Messages:** + * Use the `message` command *after* a `flyTo` to a new location, or *before* a `flyAround`. Messages should be short and descriptive, appearing *before* subsequent delays at that location. + * **Markers (`addMarker`):** + * Ensure `id` is unique for each marker in an animation sequence. + * **Polylines (`addPolyline`):** + * The `points` value must be a string of "lat,lng" pairs separated by semicolons. + * **Strictly limit the number of points per polyline to a maximum of 100 points.** + * Ensure `id` is unique for each polyline. + * `altMode=clampToGround` is generally best for drawing routes on the map surface. Parser assumes 0 altitude for points, relying on `altMode`. + * **Polygons (`addPolygon`):** + * The `outerPoints` value must be a string of "lat,lng" pairs (minimum 3) separated by semicolons. + * **Strictly limit the number of points per polygon's outer boundary to a maximum of 100.** + * Ensure `id` is unique. + * `altMode=clampToGround` is generally best. Parser assumes 0 altitude for points. + * Use realistic `dur` values for camera movements (e.g., 2000-10000ms). + * If the user asks for specific locations, try to find reasonable geographic coordinates and appropriate viewing altitudes for the focal point. + + **Your output MUST be a single string assigned to the variable `animationString`, like this:** + `animationString="command1_params;command2_params;command3_params"` + + **Examples (Illustrating new order of operations where applicable):** + + User Request: "Show me the Eiffel Tower from above, add a marker, then draw a line from there to Arc de Triomphe." + (This prompt implies an order: show Eiffel, then marker, then line, then camera move to view line/Arc) + Expected Output: + `animationString="flyTo=lat=48.8584,lng=2.2945,alt=200,hdg=0,tilt=20,range=600,dur=3000;message=\"Eiffel Tower\";addMarker=id=eiffel_marker,lat=48.8584,lng=2.2945,alt=0,label=\"Eiffel Tower\",altMode=clampToGround;delay=dur=1000;addPolyline=id=eiffel_to_arc,points=\"48.8584,2.2945;48.8738,2.2950\",color=\"#FF0000FF\",width=5.0,altMode=clampToGround;delay=dur=1000;flyTo=lat=48.865,lng=2.295,alt=150,hdg=315,tilt=45,range=2000,dur=4000;delay=dur=4000"` + + User Request: "Mark the start and end of the Boston Marathon route, draw the route, then fly to the start." + Expected Output: + `animationString="addMarker=id=bm_start,lat=42.2464,-71.4606,alt=0,label=\"Start\",altMode=clampToGround;addMarker=id=bm_finish,lat=42.3663,-71.0572,alt=0,label=\"Finish\",altMode=clampToGround;addPolyline=id=boston_marathon,points=\"42.2464,-71.4606;42.2708,-71.3942;42.3248,-71.2653;42.3489,-71.1390;42.3525,-71.0839;42.3663,-71.0572\",color=\"#0000FF\",width=7.0,altMode=clampToGround;flyTo=lat=42.2464,lng=-71.4606,alt=150,hdg=45,tilt=50,range=3000,dur=5000;message=\"Boston Marathon Route\";delay=dur=5000;delay=dur=2000"` + + User Request: "Outline Central Park in NYC with a green polygon, then fly to an overview." + Expected Output: + `animationString="addPolygon=id=central_park_area,outerPoints=\"40.7960,-73.9580;40.7639,-73.9720;40.7675,-73.9820;40.8000,-73.9670\",fillColor=\"#8000FF00\",strokeColor=\"#FF008000\",strokeWidth=2.0,altMode=clampToGround;flyTo=lat=40.7829,lng=-73.9654,alt=100,hdg=0,tilt=30,range=5000,dur=5000;message=\"Central Park Area\";delay=dur=5000"` + + Now, process the following user request and generate the `animationString`: +""".trimIndent() + +val whatAmILookingAtPrompt = """ + You are an AI assistant with expertise in geography and interpreting 3D map views. Your task is to provide a concise and informative blurb (1-2 sentences) describing what the user is likely looking at. You will be given a **screenshot** of a photorealistic 3D map view and the **camera parameters** that correspond to that screenshot. + + **Inputs You Will Receive (Appended to this prompt):** + + 1. **Screenshot:** An image depicting the 3D map view from the user's perspective. This is the primary visual evidence. + 2. **Camera Parameters:** The camera's geospatial position, orientation, and zoom level that *produced the provided screenshot*. This data will be in the following structured format: + ``` + camera { + center = latLngAltitude { + latitude = + longitude = + altitude = // This is the altitude of the camera's focal point in meters ASL + } + heading = // Degrees, 0 is North + tilt = // Degrees, 0 is straight down, 90 is horizon + range = // Meters from camera to focal point + // Note: Roll is always 0 and may not be present. + } + ``` + + **Your Task:** + Based on **both the visual information in the screenshot and the provided camera parameters**, determine the most prominent or interesting landmark, geographical feature, city, or area. Then, generate a short, engaging blurb about it. + + **Guidelines for Your Blurb:** + + 1. **Prioritize the Screenshot:** The image is what the user is directly seeing. Analyze its visual content: + * Look for recognizable buildings, structures, or monuments. + * Identify natural features like mountains, rivers, coastlines, forests, deserts, etc. + * Observe urban patterns (street grids, density) or rural landscapes. + * Note any specific types of terrain or land use. + + 2. **Use Camera Parameters for Context and Confirmation:** + * The `latitude` and `longitude` from the `center` object confirm the geographic location shown in the screenshot. + * The `altitude` of the `center` (focal point altitude), `range`, and `tilt` help interpret the scale, perspective, and elevation of the view in the screenshot. + * A low `range` (e.g., < 2000m) and `tilt` > 45 degrees in the screenshot often means focusing on a specific building or street-level feature. + * A high `range` (e.g., > 10000m) and low `tilt` might indicate an overview of a city, region, or large natural feature as seen in the image. + * The `heading` indicates the direction the camera is pointing, which can help refine what's in the center of the screenshot. + + 3. **Correlate Image and Parameters:** The visual elements in the screenshot should be consistent with the location and perspective defined by the camera parameters. Use both to build a confident description. + + 4. **Conciseness:** The blurb should be 1-2 sentences maximum. Aim for informative but brief. + + 5. **Engaging Tone:** Make it sound interesting, like a mini-fact or a quick observation about what's visible. + + 6. **Specificity (if possible):** + * If a famous landmark is clearly identifiable in the screenshot (e.g., Eiffel Tower, Mount Everest), name it. + * If it's a general area, describe what's visually apparent (e.g., "the bustling downtown skyscrapers of [City] visible in the image," "the rugged, snow-capped peaks of the [Mountain Range] dominating the view," "a coastal scene showing the [Ocean/Sea] meeting the land"). + * If the view in the screenshot is very generic (e.g., a random patch of forest from high up), it's okay to be more general (e.g., "a forested region seen from above," "an aerial perspective of rolling hills"). + + 7. **No Technical Jargon:** Do not mention the camera parameters (`latitude`, `longitude`, `range`, etc.) or their values in your blurb. The user only cares about what they are seeing in the image. + + 8. **Focus on the Visual:** Describe what is *seen in the screenshot*. Brief, well-known tidbits that enhance visual understanding are okay (e.g., "The Colosseum, an ancient Roman amphitheater, clearly visible here."). + + **Output Format:** + A single, short blurb as plain text. + + **Example Scenarios:** + (Imagine each of these also has a clear screenshot corresponding to the parameters) + + * **If screenshot shows the Eiffel Tower and camera parameters are (example):** + ``` + camera { + center = latLngAltitude { latitude = 48.8584, longitude = 2.2945, altitude = 150.0 } + heading = 45.0, tilt = 60.0, range = 500.0 + } + ``` + `Output: You're looking at the iconic Eiffel Tower in Paris, a marvel of 19th-century engineering.` + + * **If screenshot shows a vast canyon and camera parameters are (example):** + ``` + camera { + center = latLngAltitude { latitude = 36.1069, longitude = -112.1124, altitude = 2100.0 } + heading = 0.0, tilt = 45.0, range = 25000.0 + } + ``` + `Output: This is an expansive aerial view of the Grand Canyon, showcasing its immense scale and layered rock formations as seen in the image.` + + --- + **See the accompanying Screenshot of the 3D Map View:** + + **Current Camera View Parameters (corresponding to the screenshot above):** + + --- + + Based on the screenshot and camera parameters above, what is the user likely looking at? +""".trimIndent() + +//val examplePrompts = listOf( +// "Create a polyline representing the freedom trail in Boston. Add markers for each important location. There should be no fewer than 10 location. Add the markers and the polyline first and only then start a tour in order of the stops.", +// "Add a marker showing Crater Lake and fly to it. Then do a slow orbit around the lake.", +// "build a tour of a few of the UNESCO world heritage sites. stay high above each and make a slow orbit before moving on", +// "Fly me to the Colosseum in Rome, and give me a slow 360-degree view from above.", +// "Start with a wide shot of the Golden Gate Bridge, then fly underneath it from the ocean side towards San Francisco.", +// "Show me Machu Picchu. Start far away to see the mountains, then zoom in to the main citadel.", +// "Take me on a scenic helicopter tour over the Na Pali Coast in Kauai, Hawaii, highlighting the cliffs and valleys.", +// "I want to explore the canals of Venice, Italy. Start at St. Mark's Square, then glide along the Grand Canal towards the Rialto Bridge.", +// "Imagine I'm a bird soaring over the Swiss Alps. Show me majestic peaks like the Matterhorn and some alpine villages.", +// "Let's go on an adventure through the Amazon rainforest. Show me a winding river and the dense canopy from just above the trees.", +// "Give me a dramatic reveal of Mount Everest, starting from a low angle looking up.", +// "Show me the world's great deserts. Start with the Sahara, then give me a glimpse of the Gobi. Then the Sahara. Finally, Antarctica", +// "I feel like seeing some ancient wonders. Maybe the Pyramids of Giza, then a quick hop to Stonehenge.", +// "Take me on a journey from the Earth's surface up into space, looking back at the blue marble.", +// "Position the camera for a nice view of Niagara Falls.", +// "Let's see the Sydney Opera House.", +// "Go to the top of Mount Kilimanjaro.", +// "Fly to the Burj Khalifa in Dubai.", +// "I want to see the Christ the Redeemer statue in Rio de Janeiro, with the city and Sugarloaf Mountain in the background.", +// "Take me to the Great Wall of China, and fly along a section of it, showing its scale winding through the mountains.", +// "Let's look down into the caldera of Mount St. Helens.", +// "Compare the views from the top of the Shard in London and then the top of One World Trade Center in New York.", +// "Show me a beautiful beach in the Maldives, then quickly jump to a rugged coastline in Big Sur, California.", +// "I'd like a quick tour of three famous European capitals: Paris (Eiffel Tower), Rome (Colosseum), and Berlin (Brandenburg Gate).", +// "Find a serene, hidden waterfall deep in a lush jungle.", +// "Show me the power of a large volcano, perhaps Kilauea in Hawaii during an eruption (simulated view if needed).", +// "Take me on a journey through time, from an ancient Roman forum to a futuristic cityscape.", +// "Show me a place that feels incredibly remote and untouched by humans.", +// "Start at a global view of Earth, then rapidly zoom into Central Park in New York City.", +// "Give me an ant's-eye view of a famous monument, like the Lincoln Memorial.", +// "Fly low and fast over the Bonneville Salt Flats.", +// "Circle around Neuschwanstein Castle in Germany, showing it from all sides, especially with the mountains behind it.", +// "What does Times Square look like from directly above, say 500 meters up?", +// "Let's visit the Hollywood Sign, then pan to show the view over Los Angeles.", +// "Show me an isolated lighthouse on a rocky coast during a storm." +//) + +val examplePrompts = listOf( + "Show me the Freedom Trail in Boston: draw the route as a red line, add markers for at least 5 key locations, then fly along the trail.", + "Fly to Crater Lake, add a marker at Wizard Island, then do a slow orbit around the lake showing its blue water.", + "Take me on a tour of 3 UNESCO World Heritage sites: first the Pyramids of Giza, then Machu Picchu, and finally the Taj Mahal. Show each from above with a marker. Add the markers before starting the tour.", + "Fly me to the Colosseum in Rome, mark it, and give me a slow 360-degree view from above.", + "Start with a wide shot of the Golden Gate Bridge, draw a blue line across it, then fly underneath it from the ocean side towards San Francisco.", + "Show me Mount Everest. Add a marker at the summit. Start from far away to see the Himalayas, then zoom in dramatically to the peak.", + "Give me a tour of the Grand Canyon: fly along the Colorado River for a bit, then mark and circle Bright Angel Trailhead.", + "Outline the Pentagon building with a semi-transparent blue polygon and give me an overhead view.", + "Fly to Times Square in New York, then draw a polygon around the main square area and label it 'Times Square'.", + "Show the approximate route of the Nile River through Egypt with a blue polyline, then fly from Aswan to Cairo along it.", + "Mark the location of the Mariana Trench, then fly to it, and look straight down.", + "Take me to the Great Wall of China, draw a section of it as a polyline, and fly along it showing its scale.", + "Highlight the Bermuda Triangle with a semi-transparent red polygon, then fly over its center.", + "Tour of European capitals: Paris (Eiffel Tower), Rome (Colosseum), Berlin (Brandenburg Gate). Mark each and fly between them.", + "Start at a global view, then rapidly zoom into Central Park in New York City and draw its boundary as a green polygon.", + "Fly low and fast over the Bonneville Salt Flats, then add a marker where land speed records are set.", + "Circle Neuschwanstein Castle in Germany, mark its location, and show it from all sides with the mountains behind it.", + "Show me an isolated lighthouse on a rocky coast, mark its position, and then fly around it.", + "Draw the border of Vatican City as a polygon, then fly to St. Peter's Square and add a marker.", + "Take me to the Amazon rainforest, draw a 10-point polyline representing a winding river, then fly just above the canopy along that line." +) + +val promptGeneratorPrompt = """ + You are an AI assistant specialized in crafting diverse and effective user prompts for a sophisticated 3D map camera animation system. Your task is to generate a list of example user prompts. These prompts will be shown to users of an application that takes their natural language input and, using another AI, converts it into a precise `animationString` for camera movements which can include `flyTo`, `flyAround`, `delay`, `message`, `addMarker`, `addPolyline`, and `addPolygon` commands. + + The example prompts you generate MUST adhere to the following rules, which are the same rules the animation-generating AI follows: + + **Rules for Example User Prompts You Generate:** + + 1. **Earth-Focused Content:** + * Prompts must request views of Earth's surface, specific landmarks, geographical features, or cities. + * Prompts **MUST NOT** request views primarily of the sky, celestial events (like auroras), specific weather phenomena (like storms), or imply specific times of day that would require different environmental lighting (e.g., "sunset," "city at night with bright lights"). The system cannot render these. + + 2. **Feature Usage Variety:** Generate prompts that naturally lead to the use of: + * Simple camera movements (`flyTo`, `flyAround`, `delay`). + * Adding markers (`addMarker`) to pinpoint locations. + * Drawing routes or paths (`addPolyline`). Remember polyline points are `lat,lng` pairs separated by semicolons. + * Highlighting areas (`addPolygon`). Remember polygon outer points are `lat,lng` pairs separated by semicolons. + * Combinations of these features (e.g., fly to a place, mark it, then draw a route from it). + + 3. **Variety in Request Type & Complexity:** + * Specific landmarks (e.g., "Show me the Space Needle, mark it, and circle it."). + * Requests for "tours," "exploration," or "scenic views" that might involve multiple steps and drawing elements (e.g., "Tour of National Mall: draw a line from Lincoln Memorial to Washington Monument to Capitol, marking each."). + * Simple, direct requests (e.g., "Fly to Mount Fuji."). + * Requests that imply drawing shapes (e.g., "Outline the area of Hyde Park, London with a polygon."). + + 4. **Geographic and Thematic Diversity:** + * **Aim for a WIDE VARIETY of unique locations.** Actively avoid repeating the most common landmarks unless specifically varying the *type* of request. + * **Seek out lesser-known but visually distinct and interesting places.** + * Cover different continents, countries, biomes, and types of human settlements. + * Include natural wonders, historical sites, urban areas, and routes. + + 5. **Natural Language:** Prompts should sound like something a real user would type or say. + + 6. **Implicit Scale & Detail:** + * Craft prompts that naturally suggest different scales of view. + * For polylines and polygons, prompts can imply a level of detail (e.g., "a simplified route" vs. "the detailed path"). The animation AI will try to provide a reasonable number of points (max 100). + + 7. **No Technical Camera Jargon:** Prompts should be purely natural language. Avoid terms like "latitude," "longitude," "altitude mode," etc. + + **Output Format (Strict):** + **Your output MUST be plain text. Each example user prompt MUST be on its own separate line. There should be NO additional formatting, NO numbering, NO bullet points, NO introductory or concluding text, and NO code block markers (like ```). Just the prompts, one per line.** + + **Example of *Correct Output Format* if asked for 3 prompts:** + Outline the island of Manhattan with a blue polygon, then fly over it. + Show me the route of the Orient Express from Paris to Istanbul with a polyline and mark both cities. + Fly to the summit of Denali and place a marker there. + + Now, please generate 20 example user prompts based on these guidelines. +""".trimIndent() diff --git a/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/scenarios/Animations.kt b/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/scenarios/Animations.kt index 0b20c32..666511b 100644 --- a/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/scenarios/Animations.kt +++ b/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/scenarios/Animations.kt @@ -16,26 +16,53 @@ package com.example.advancedmaps3dsamples.scenarios import com.google.android.gms.maps3d.model.FlyAroundOptions import com.google.android.gms.maps3d.model.FlyToOptions +import com.google.android.gms.maps3d.model.MarkerOptions +import com.google.android.gms.maps3d.model.PolygonOptions +import com.google.android.gms.maps3d.model.PolylineOptions import kotlinx.coroutines.delay sealed interface AnimationStep { - suspend operator fun invoke(viewModel: ScenariosViewModel) + suspend operator fun invoke(viewModel: ScenarioBaseViewModel) } data class DelayStep(val durationMillis: Long) : AnimationStep { - override suspend operator fun invoke(viewModel: ScenariosViewModel) { + override suspend operator fun invoke(viewModel: ScenarioBaseViewModel) { delay(durationMillis) } } data class FlyToStep(val flyToOptions: FlyToOptions) : AnimationStep { - override suspend operator fun invoke(viewModel: ScenariosViewModel) { + override suspend operator fun invoke(viewModel: ScenarioBaseViewModel) { viewModel.awaitFlyTo(flyToOptions) } } data class FlyAroundStep(val flyAroundOptions: FlyAroundOptions) : AnimationStep { - override suspend operator fun invoke(viewModel: ScenariosViewModel) { + override suspend operator fun invoke(viewModel: ScenarioBaseViewModel) { viewModel.awaitFlyAround(flyAroundOptions) } } + +data class MessageStep(val message: String) : AnimationStep { + override suspend operator fun invoke(viewModel: ScenarioBaseViewModel) { + viewModel.showMessage(message) + } +} + +data class AddMarkerStep(val options: MarkerOptions) : AnimationStep { + override suspend operator fun invoke(viewModel: ScenarioBaseViewModel) { + viewModel.addMarker(this.options) + } +} + +data class AddPolylineStep(val options: PolylineOptions) : AnimationStep { + override suspend operator fun invoke(viewModel: ScenarioBaseViewModel) { + viewModel.addPolyline(this.options) + } +} + +data class AddPolygonStep(val options: PolygonOptions) : AnimationStep { + override suspend operator fun invoke(viewModel: ScenarioBaseViewModel) { + viewModel.addPolygon(this.options) + } +} diff --git a/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/scenarios/ScenarioData.kt b/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/scenarios/ScenarioData.kt index 6842e9e..acf6353 100644 --- a/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/scenarios/ScenarioData.kt +++ b/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/scenarios/ScenarioData.kt @@ -126,12 +126,21 @@ val hawaiiRoute = """ 21.26286, -157.80343 21.26289, -157.80359 21.26401, -157.80579 -""".trimIndent().split("\n") - .map { +""".trimIndent().coordinatesToEncodedPolyline() + +val bostonPolyline = """ + 42.35628586524052, -71.06225781918089 + 42.35583081188131, -71.06130009037366 + 42.355455527402505, -71.06161679863297 + 42.355449621886734, -71.06161403790463 +""".trimIndent().coordinatesToEncodedPolyline() + +private fun String.coordinatesToEncodedPolyline(): String { + return this.split("\n").map { val (lat, lng) = it.split(",").map(String::toDouble) LatLng(lat, lng) - } - .latLngListEncode() + }.latLngListEncode() +} // Encoded polyline // qj`aCj}nb]BDlAuAp@gAlEwIpAaDp@gBlAw@p@YtBq@vB}@v@U~Ao@dAk@dF_ErAcAbAm@~EmDxAqAhAyAx@wAdAiCdCqH~CuIt@mCrAiDnEwMfNea@TkAFu@BgA@_BpF{B|LqFrH_EpDmBxAu@bG{ChHwCpG}CrCuB~P}RhAmAnCeCbAcANIlTcJ~@YNMj@W|B{Bn@s@rAwBj@{Kt@_RH_F@qHM{AyA_Hi@wN?aBB_BFe@PiDLoD@cJQoE?g@Ju@DSRi@Zc@v@s@l@w@jAgCz@mAz@aAl@}@fAr@H?NI`@]LIb@OhAKx@ClCY|@KNCb@[d@E`@@XLFFJRl@~BhCfKjAtE@d@E^_FvL @@ -370,6 +379,23 @@ val scenarios = "flyTo=lat=39.7498,lng=-104.9535,alt=2000,hdg=200,tilt=60,range=1500,dur=2500;" + "delay=dur=1000", polygon = denverPolygon, - ) + ), + createScenario( + name = "boston-lost-man", + titleId = R.string.scenarios_lost_in_boston, + initialState = "mode=satellite;camera=lat=42.35628586524052,lng=-71.06225781918089,alt=10,tilt=10,hdg=121,range=1000", + animationString = "delay=dur=5000;" + + "flyTo=lat=42.35628586524052,lng=-71.06225781918089,alt=15,tilt=67,hdg=121,range=135,dur=2000;"+ + "flyTo=lat=42.355819,lng=-71.061274,alt=10,tilt=45,hdg=121,range=119,dur=2000;"+ + "flyTo=lat=42.355858,lng=-71.061279,alt=10,tilt=0,hdg=211,range=64,dur=2000;"+ + "flyTo=lat=42.355455,lng=-71.061608,alt=12,tilt=35,hdg=211,range=56,dur=2000;"+ + "delay=dur=2000;" + + "flyTo=lat=42.355475,lng=-71.062052,alt=53.5,tilt=5,hdg=216,range=524,dur=2000;"+ + "", + polylines = bostonPolyline, + markers = + "lat=42.35628586524052,lng=-71.06225781918089,alt=10,altMode=clamp_to_ground;" + + "lat=42.355449621886734,lng=-71.06161403790463,alt=10,altMode=clamp_to_ground;" + ), ).associateBy { it.name } \ No newline at end of file diff --git a/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/scenarios/ScenarioMapper.kt b/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/scenarios/ScenarioMapper.kt index 3f2c601..9d16187 100644 --- a/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/scenarios/ScenarioMapper.kt +++ b/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/scenarios/ScenarioMapper.kt @@ -47,27 +47,100 @@ import com.google.android.gms.maps3d.model.vector3D import com.google.maps.android.ktx.utils.toLatLngList import java.util.UUID import androidx.core.graphics.toColorInt +import com.google.maps.android.PolyUtil private const val TAG = "ScenarioMapper" /** - * Parses a comma-separated string of "key=value" pairs into a map. Example: - * "lat=39.65,lng=-105.02,alt=550.2" -> {"lat": "39.65", "lng": "-105.02", "alt": "550.2"} + * Parses a comma-separated string of "key=value" pairs into a map. + * Handles quoted values that may contain commas or semicolons. + * Example: "id=foo,points="lat1,lng1;lat2,lng2",color=#FF0000" */ fun String.toAttributesMap(): Map { - // Trim whitespace around the string and commas/semicolons just in case - val trimmedString = this.trim().trimEnd(';').trimEnd(',') - if (trimmedString.isBlank()) { - return emptyMap() - } - return trimmedString - .split(",") - .map { it.trim() } - .filter { it.contains("=") } - .associate { part -> - val (key, value) = part.split("=", limit = 2) - key.trim() to value.trim() + val attributes = mutableMapOf() + var index = 0 + val length = this.length + + while (index < length) { + // Skip leading delimiters (comma or semicolon) for subsequent pairs + // and any leading whitespace for the key. + while (index < length && (this[index] == ',' || this[index] == ';' || this[index].isWhitespace())) { + index++ + } + if (index >= length) break + + // Parse key + val keyStart = index + while (index < length && this[index] != '=') { + index++ + } + if (index >= length || this[index] != '=') { + Log.w(TAG, "Malformed pair or end of string encountered parsing key at index $keyStart. Input: '$this'") + break // Malformed or end of string without a full pair + } + val key = this.substring(keyStart, index).trim() + index++ // Skip '=' + + if (index >= length) { + Log.w(TAG, "No value found for key '$key'. Input: '$this'") + if (key.isNotBlank()) attributes[key] = "" // Store empty value for key if key is valid + break + } + + // Skip whitespace before value + while (index < length && this[index].isWhitespace()) { + index++ + } + if (index >= length) { + Log.w(TAG, "No value found for key '$key' (after skipping whitespace). Input: '$this'") + if (key.isNotBlank()) attributes[key] = "" + break + } + + + // Parse value + val value: String + if (this[index] == '"') { // Quoted value + index++ // Skip opening quote + val valueStart = index + val sb = StringBuilder() + var escaped = false + var foundClosingQuote = false + while (index < length) { + val char = this[index] + if (escaped) { + sb.append(char) // Append the escaped character as is + escaped = false + } else if (char == '\\') { + escaped = true // Next character is escaped + } else if (char == '"') { + foundClosingQuote = true + index++ // Consume the closing quote + break // End of quoted value + } else { + sb.append(char) + } + index++ + } + value = sb.toString() // Content within quotes, escaped characters processed + if (!foundClosingQuote) { + Log.w(TAG, "Unclosed quote for key '$key'. Value parsed so far: '$value'. Input: '$this'") + } + } else { // Unquoted value + val valueStart = index + while (index < length && this[index] != ',' && this[index] != ';') { + index++ + } + value = this.substring(valueStart, index).trim() + } + + if (key.isNotBlank()) { + attributes[key] = value + } else { + Log.w(TAG, "Parsed a blank key. Value was '$value'. Input: '$this'") + } } + return attributes } /** Helper to safely get a Double value from the attributes map. */ @@ -155,27 +228,227 @@ fun String.toMap3DMode(): Int { } } +fun Map.toMarkerOptions(): MarkerOptions { + val labelText = getString("label", "").trim('"') + val altModeString = getString("altMode", "clampToGround") // Default altMode + + return markerOptions { + position = this@toMarkerOptions.toLatLngAltitude() // Uses the existing LatLngAltitude parser + label = labelText + altitudeMode = parseAltitudeMode(altModeString) // Uses existing helper + isExtruded = true // Default + isDrawnWhenOccluded = true // Default + collisionBehavior = CollisionBehavior.REQUIRED_AND_HIDES_OPTIONAL // Default + zIndex = 1 // Default + } +} + +fun Map.toPolylineOptionsFromAi(): PolylineOptions { + val id = getString("id", "polyline_${UUID.randomUUID()}") + val pointsStr = getString("points", "").trim('"') // Get the "lat1,lng1;lat2,lng2;..." string + val colorStr = getString("color", "#FF0000FF").trim('"') // Default to opaque blue + val width = getDouble("width", 5.0).toFloat() + val altModeString = getString("altMode", "clampToGround") + + val coordinates3D = mutableListOf() + if (pointsStr.isNotBlank()) { + val pointPairs = pointsStr.split(';') + for (pairStr in pointPairs) { + val latLngParts = pairStr.split(',') + if (latLngParts.size == 2) { + try { + val lat = latLngParts[0].trim().toDouble() + val lng = latLngParts[1].trim().toDouble() + // For simplicity, assume altitude 0 for points from AI string. + // `altMode` will determine how these are rendered. + coordinates3D.add(latLngAltitude { latitude = lat; longitude = lng; altitude = 0.0 }) + } catch (e: NumberFormatException) { + Log.w(TAG, "Invalid lat/lng in polyline points string: '$pairStr' for polyline $id") + } + } else { + Log.w(TAG, "Invalid point pair format in polyline points string: '$pairStr' for polyline $id") + } + } + } + + if (coordinates3D.size < 2) { + Log.w(TAG, "Polyline $id has fewer than 2 valid points. It might not render.") + } + + val parsedColor = try { + colorStr.toColorInt() + } catch (e: IllegalArgumentException) { + Log.w(TAG, "Invalid color string '$colorStr' for polyline $id, defaulting to blue.") + android.graphics.Color.BLUE + } + + return polylineOptions { + this.id = id + this.coordinates = coordinates3D + this.strokeColor = parsedColor + this.strokeWidth = width.toDouble() + this.altitudeMode = parseAltitudeMode(altModeString) + this.zIndex = 5 + this.drawsOccludedSegments = true + } +} + +fun Map.toPolygonOptionsFromAi(): PolygonOptions { + val id = getString("id", "polygon_${UUID.randomUUID()}") + val outerPointsStr = getString("outerPoints", "").trim('"') + // Inner holes are not supported by AI command yet, so default to empty list + // val innerHolesStr = getString("innerHoles", "").trim('"') + + val fillColorStr = getString("fillColor", "#800000FF").trim('"') // Default to semi-transparent blue + val strokeColorStr = getString("strokeColor", "#FF0000FF").trim('"') // Default to opaque blue + val strokeWidth = getDouble("strokeWidth", 3.0).toFloat() + val altModeString = getString("altMode", "clampToGround") + + val outerCoordinates3D = mutableListOf() + if (outerPointsStr.isNotBlank()) { + val pointPairs = outerPointsStr.split(';') + for (pairStr in pointPairs) { + val latLngParts = pairStr.split(',') + if (latLngParts.size == 2) { + try { + val lat = latLngParts[0].trim().toDouble() + val lng = latLngParts[1].trim().toDouble() + outerCoordinates3D.add(latLngAltitude { latitude = lat; longitude = lng; altitude = 0.0 }) + } catch (e: NumberFormatException) { + Log.w(TAG, "Invalid lat/lng in polygon outerPoints: '$pairStr' for polygon $id") + } + } else { + Log.w(TAG, "Invalid point pair format in polygon outerPoints: '$pairStr' for polygon $id") + } + } + } + + if (outerCoordinates3D.size < 3) { + Log.w(TAG, "Polygon $id has fewer than 3 valid outer points. It might not render. Original points: '$outerPointsStr'") + // Potentially return a default or throw an error, but for now, allow creation of an invalid polygon that won't render. + } + + val parsedFillColor = try { + fillColorStr.toColorInt() + } catch (e: IllegalArgumentException) { + Log.w(TAG, "Invalid fill color string '$fillColorStr' for polygon $id, defaulting to semi-transparent blue.") + "#800000FF".toColorInt() // Default + } + + val parsedStrokeColor = try { + strokeColorStr.toColorInt() + } catch (e: IllegalArgumentException) { + Log.w(TAG, "Invalid stroke color string '$strokeColorStr' for polygon $id, defaulting to opaque blue.") + Color.BLUE // Default + } + + return polygonOptions { + this.id = id + this.outerCoordinates = outerCoordinates3D + // this.innerCoordinates = emptyList() // No inner holes from AI for now + this.fillColor = parsedFillColor + this.strokeColor = parsedStrokeColor + this.strokeWidth = strokeWidth.toDouble() + this.altitudeMode = parseAltitudeMode(altModeString) + this.zIndex = 3 // Default zIndex for polygons, can be adjusted + this.geodesic = false // Default + } +} + fun String.toAnimation(): List { - val stepsString = this.trim().trimEnd(';') - if (stepsString.isBlank()) { + val animationString = this.trim() + if (animationString.isBlank()) { return emptyList() } - return buildList { - stepsString.split(";").forEach { step -> - val trimmedStep = step.trim() - if (trimmedStep.contains('=')) { - val (key, value) = trimmedStep.split("=", limit = 2) - when (key.trim().lowercase()) { - "flyto" -> add(FlyToStep(value.toFlyTo())) - "delay" -> add(DelayStep(value.toDelay())) - "flyaround" -> add(FlyAroundStep(value.toFlyAround())) - else -> Log.w(TAG, "Unsupported animation step type: $key") + + val steps = mutableListOf() + var currentIndex = 0 + val length = animationString.length + + while (currentIndex < length) { + // ... (logic for skipping delimiters and finding commandKey remains the same) ... + while (currentIndex < length && (animationString[currentIndex] == ';' || animationString[currentIndex].isWhitespace())) { + currentIndex++ + } + if (currentIndex >= length) break + + val commandKeyStart = currentIndex + while (currentIndex < length && animationString[currentIndex] != '=') { + currentIndex++ + } + + if (currentIndex >= length || animationString[currentIndex] != '=') { + Log.w(TAG, "Malformed command (missing '=') starting at index $commandKeyStart in animation string: '$animationString'") + break + } + val commandKey = animationString.substring(commandKeyStart, currentIndex).trim().lowercase() + currentIndex++ // Skip '=' + + if (currentIndex >= length) { + Log.w(TAG, "No value found for command '$commandKey' at end of animation string: '$animationString'") + break + } + + val commandValueStart = currentIndex + val valueBuilder = StringBuilder() + var inQuotes = false + var foundEndOfValue = false + + while (currentIndex < length) { + val char = animationString[currentIndex] + if (char == '"') { + inQuotes = !inQuotes + } + if (!inQuotes && char == ';') { + foundEndOfValue = true + break + } + valueBuilder.append(char) + currentIndex++ + } + + val commandValueString = valueBuilder.toString().trim() + + if (commandKey.isBlank()) { + Log.w(TAG, "Parsed a blank command key. Value string was '$commandValueString'. Full string: '$animationString'") + if (foundEndOfValue) currentIndex++ + continue + } + + Log.d(TAG, "Parsing command: $commandKey with value: $commandValueString") + + try { + when (commandKey) { + "flyto" -> steps.add(FlyToStep(commandValueString.toFlyTo())) + "delay" -> steps.add(DelayStep(commandValueString.toDelay())) + "flyaround" -> steps.add(FlyAroundStep(commandValueString.toFlyAround())) + "message" -> { + val messageContent = commandValueString.removeSurrounding("\"") + steps.add(MessageStep(messageContent)) } - } else { - Log.w(TAG, "Ignoring invalid animation step format: $step") + "addmarker" -> { + val attributes = commandValueString.toAttributesMap() + steps.add(AddMarkerStep(attributes.toMarkerOptions())) + } + "addpolyline" -> { + val attributes = commandValueString.toAttributesMap() + steps.add(AddPolylineStep(attributes.toPolylineOptionsFromAi())) + } + "addpolygon" -> { // Added new case + val attributes = commandValueString.toAttributesMap() + steps.add(AddPolygonStep(attributes.toPolygonOptionsFromAi())) + } + else -> Log.w(TAG, "Unsupported animation step type: $commandKey with value: $commandValueString") } + } catch (e: Exception) { + Log.e(TAG, "Error parsing command '$commandKey' with value '$commandValueString'. Error: ${e.message}", e) + } + + if (foundEndOfValue) { + // currentIndex is already at the semicolon } } + return steps } fun String.toMaps3DOptions(): Map3DOptions { @@ -495,22 +768,21 @@ fun String.toPolyline(idp: String? = null): List { } // --- Helper Functions --- - @AltitudeMode -private fun parseAltitudeMode(altModeString: String): Int { - return when (altModeString.lowercase()) { +internal fun parseAltitudeMode(altModeString: String?): Int { // Made internal + return when (altModeString?.trim()?.lowercase()) { "absolute" -> AltitudeMode.ABSOLUTE + "relativetoground" -> AltitudeMode.RELATIVE_TO_GROUND // common typo fix "relative_to_ground" -> AltitudeMode.RELATIVE_TO_GROUND + "relativetomesh" -> AltitudeMode.RELATIVE_TO_MESH // common typo fix "relative_to_mesh" -> AltitudeMode.RELATIVE_TO_MESH + "clamptoground" -> AltitudeMode.CLAMP_TO_GROUND // common typo fix "clamp_to_ground" -> AltitudeMode.CLAMP_TO_GROUND else -> { - if (altModeString.isNotEmpty()) { - Log.w( - TAG, - "Ignoring unrecognized altitude mode '$altModeString', defaulting to CLAMP_TO_GROUND", - ) + if (!altModeString.isNullOrEmpty()) { + Log.w(TAG, "Ignoring unrecognized altitude mode '$altModeString', defaulting to CLAMP_TO_GROUND.") } AltitudeMode.CLAMP_TO_GROUND } } -} +} \ No newline at end of file diff --git a/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/scenarios/ScenariosActivity.kt b/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/scenarios/ScenariosActivity.kt index 85c0dbd..d14c627 100644 --- a/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/scenarios/ScenariosActivity.kt +++ b/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/scenarios/ScenariosActivity.kt @@ -16,11 +16,16 @@ package com.example.advancedmaps3dsamples.scenarios import android.content.Intent import android.os.Bundle +import android.view.WindowManager import androidx.activity.ComponentActivity +import androidx.activity.SystemBarStyle import androidx.activity.compose.BackHandler import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.viewModels +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -32,6 +37,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons @@ -46,21 +52,30 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults.topAppBarColors import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.rememberGraphicsLayer import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.example.advancedmaps3dsamples.R +import com.example.advancedmaps3dsamples.ainavigator.WhiskeyCompass import com.example.advancedmaps3dsamples.ui.theme.AdvancedMaps3DSamplesTheme import com.example.advancedmaps3dsamples.utils.DEFAULT_ROLL import com.example.advancedmaps3dsamples.utils.toHeading @@ -69,7 +84,10 @@ import com.example.advancedmaps3dsamples.utils.toRoll import com.example.advancedmaps3dsamples.utils.toTilt import com.google.android.gms.maps3d.model.Camera import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlin.time.Duration.Companion.seconds @AndroidEntryPoint @OptIn(ExperimentalMaterial3Api::class) @@ -85,7 +103,14 @@ class ScenariosActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - enableEdgeToEdge() + enableEdgeToEdge( + statusBarStyle = SystemBarStyle.light(android.graphics.Color.TRANSPARENT, android.graphics.Color.TRANSPARENT), + navigationBarStyle = SystemBarStyle.light(android.graphics.Color.TRANSPARENT, android.graphics.Color.TRANSPARENT) + ) + + hideSystemUI() + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + setContent { val viewState by viewModel.viewState.collectAsStateWithLifecycle() val currentCamera by viewModel.currentCamera.collectAsStateWithLifecycle(Camera.DEFAULT_CAMERA) @@ -95,21 +120,47 @@ class ScenariosActivity : ComponentActivity() { val cameraAttribute by viewModel.trackedAttribute.collectAsStateWithLifecycle() + val camera by viewModel.currentCamera.collectAsStateWithLifecycle() + val compassAlpha = remember { Animatable(0.55f) } + + // This LaunchedEffect controls the compass alpha based on camera heading changes. + LaunchedEffect(camera.heading) { + // When camera.heading changes, this LaunchedEffect is cancelled and restarted. + // Any coroutine launched within its scope (like the one below) is also cancelled. + + // Reset alpha to initial state and stop any ongoing animation on compassAlpha. + compassAlpha.snapTo(0.55f) + + // Launch a new coroutine within this LaunchedEffect's scope. + // This coroutine will handle the delay and subsequent fade-out animation. + // If camera.heading changes again before this completes, this coroutine will be cancelled. + launch { + delay(2.seconds) // Wait for 2 seconds of stable heading + // If this point is reached, it means camera.heading was stable for 2 seconds. + compassAlpha.animateTo( + targetValue = 0.3f, + animationSpec = tween(durationMillis = 500, easing = LinearEasing) + ) + } + } + AdvancedMaps3DSamplesTheme( dynamicColor = false ) { Scaffold( modifier = Modifier.fillMaxSize(), topBar = { - CenterAlignedTopAppBar( - colors = topAppBarColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - titleContentColor = MaterialTheme.colorScheme.primary, - ), - title = { - Text(stringResource(viewState.scenario?.titleId ?: R.string.scenarios_none)) - } - ) + if (viewState.scenario == null) { + CenterAlignedTopAppBar( + colors = topAppBarColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + titleContentColor = MaterialTheme.colorScheme.primary, + ), + title = { + Text(stringResource(viewState.scenario?.titleId ?: R.string.scenarios_none)) + } + ) + } } ) { innerPadding -> val modifier = Modifier.padding(innerPadding) @@ -135,6 +186,17 @@ class ScenariosActivity : ComponentActivity() { onMap3dViewReady = { viewModel.setGoogleMap3D(it) }, onReleaseMap = { viewModel.releaseGoogleMap3D() }, ) + + WhiskeyCompass( + heading = camera.heading?.toFloat() ?: 0f, + modifier = Modifier + .fillMaxWidth() + .alpha(compassAlpha.value) + .safeDrawingPadding(), + stripHeight = 90.dp, + pixelsPerDegree = 7f, + degreeLabelInterval = 30, + ) } if (viewState.countDownVisible) { @@ -165,6 +227,14 @@ class ScenariosActivity : ComponentActivity() { } } } + + private fun hideSystemUI() { + WindowCompat.setDecorFitsSystemWindows(window, false) + WindowInsetsControllerCompat(window, window.decorView).let { controller -> + controller.hide(WindowInsetsCompat.Type.systemBars()) + controller.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + } + } } /** @@ -256,7 +326,10 @@ fun FinishedOverlay( ) { // Close Button at the top end corner OverlayButton( - modifier = modifier.align(Alignment.TopEnd).offset(x = (-16).dp, y = 16.dp).size(size), + modifier = modifier + .align(Alignment.TopEnd) + .offset(x = (-16).dp, y = 16.dp) + .size(size), onExitClick = onCloseClick, imageVector = Icons.Default.Close, contentDescription = stringResource(R.string.close) diff --git a/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/scenarios/ScenariosViewModel.kt b/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/scenarios/ScenariosViewModel.kt index fa8c50d..26c04eb 100644 --- a/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/scenarios/ScenariosViewModel.kt +++ b/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/scenarios/ScenariosViewModel.kt @@ -37,6 +37,12 @@ import com.example.advancedmaps3dsamples.utils.DEFAULT_ROLL import com.example.advancedmaps3dsamples.utils.toCameraString import com.example.advancedmaps3dsamples.R import com.example.advancedmaps3dsamples.utils.copy +import com.google.android.gms.maps3d.model.FlyAroundOptions +import com.google.android.gms.maps3d.model.FlyToOptions +import com.google.android.gms.maps3d.model.MarkerOptions +import com.google.android.gms.maps3d.model.PolygonOptions +import com.google.android.gms.maps3d.model.Polyline +import com.google.android.gms.maps3d.model.PolylineOptions import com.google.android.gms.maps3d.model.flyAroundOptions enum class CameraAttribute(val labelId: Int) { @@ -67,8 +73,17 @@ private val NEUSCHWANSTEIN_CAMERA = camera { roll = 0.0 } +interface ScenarioBaseViewModel { + suspend fun awaitFlyTo(flyToOptions: FlyToOptions) + suspend fun awaitFlyAround(flyAroundOptions: FlyAroundOptions) + suspend fun showMessage(message: String) + fun addMarker(options: MarkerOptions) + fun addPolyline(polylineOptions: PolylineOptions) + fun addPolygon(polygonOptions: PolygonOptions) +} + @HiltViewModel -class ScenariosViewModel @Inject constructor() : Map3dViewModel() { +class ScenariosViewModel @Inject constructor() : Map3dViewModel(), ScenarioBaseViewModel { override val TAG = this::class.java.simpleName private val _viewState = MutableStateFlow(ScenarioViewState()) val viewState = _viewState as StateFlow @@ -454,4 +469,8 @@ class ScenariosViewModel @Inject constructor() : Map3dViewModel() { fun closeOverlay() { _viewState.value = viewState.value.copy(showFinished = false) } + + override suspend fun showMessage(message: String) { + Log.w(TAG, message) + } } diff --git a/Maps3DSamples/advanced/app/src/main/res/drawable/ic_launcher_background.xml b/Maps3DSamples/advanced/app/src/main/res/drawable/ic_launcher_background.xml index bdf5ba8..ca3826a 100644 --- a/Maps3DSamples/advanced/app/src/main/res/drawable/ic_launcher_background.xml +++ b/Maps3DSamples/advanced/app/src/main/res/drawable/ic_launcher_background.xml @@ -1,186 +1,74 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + xmlns:android="http://schemas.android.com/apk/res/android"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Maps3DSamples/advanced/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/Maps3DSamples/advanced/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..c4a603d --- /dev/null +++ b/Maps3DSamples/advanced/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/Maps3DSamples/advanced/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/Maps3DSamples/advanced/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..c4a603d --- /dev/null +++ b/Maps3DSamples/advanced/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/Maps3DSamples/advanced/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/Maps3DSamples/advanced/app/src/main/res/mipmap-anydpi/ic_launcher.xml deleted file mode 100644 index 4293905..0000000 --- a/Maps3DSamples/advanced/app/src/main/res/mipmap-anydpi/ic_launcher.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/Maps3DSamples/advanced/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/Maps3DSamples/advanced/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml deleted file mode 100644 index 4293905..0000000 --- a/Maps3DSamples/advanced/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/Maps3DSamples/advanced/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/Maps3DSamples/advanced/app/src/main/res/mipmap-hdpi/ic_launcher.webp index c209e78..b8b1c03 100644 Binary files a/Maps3DSamples/advanced/app/src/main/res/mipmap-hdpi/ic_launcher.webp and b/Maps3DSamples/advanced/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/Maps3DSamples/advanced/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/Maps3DSamples/advanced/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..b7a98bf Binary files /dev/null and b/Maps3DSamples/advanced/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ diff --git a/Maps3DSamples/advanced/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/Maps3DSamples/advanced/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp index b2dfe3d..eb3b3f4 100644 Binary files a/Maps3DSamples/advanced/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp and b/Maps3DSamples/advanced/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/Maps3DSamples/advanced/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/Maps3DSamples/advanced/app/src/main/res/mipmap-mdpi/ic_launcher.webp index 4f0f1d6..2cef6a9 100644 Binary files a/Maps3DSamples/advanced/app/src/main/res/mipmap-mdpi/ic_launcher.webp and b/Maps3DSamples/advanced/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/Maps3DSamples/advanced/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/Maps3DSamples/advanced/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..a42d802 Binary files /dev/null and b/Maps3DSamples/advanced/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ diff --git a/Maps3DSamples/advanced/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/Maps3DSamples/advanced/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp index 62b611d..4b1f9b5 100644 Binary files a/Maps3DSamples/advanced/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp and b/Maps3DSamples/advanced/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/Maps3DSamples/advanced/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/Maps3DSamples/advanced/app/src/main/res/mipmap-xhdpi/ic_launcher.webp index 948a307..67961f2 100644 Binary files a/Maps3DSamples/advanced/app/src/main/res/mipmap-xhdpi/ic_launcher.webp and b/Maps3DSamples/advanced/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/Maps3DSamples/advanced/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/Maps3DSamples/advanced/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..f6ef561 Binary files /dev/null and b/Maps3DSamples/advanced/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ diff --git a/Maps3DSamples/advanced/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/Maps3DSamples/advanced/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp index 1b9a695..8485e54 100644 Binary files a/Maps3DSamples/advanced/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp and b/Maps3DSamples/advanced/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/Maps3DSamples/advanced/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/Maps3DSamples/advanced/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp index 28d4b77..257d17b 100644 Binary files a/Maps3DSamples/advanced/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp and b/Maps3DSamples/advanced/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/Maps3DSamples/advanced/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/Maps3DSamples/advanced/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..fba09a1 Binary files /dev/null and b/Maps3DSamples/advanced/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ diff --git a/Maps3DSamples/advanced/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/Maps3DSamples/advanced/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp index 9287f50..468f03e 100644 Binary files a/Maps3DSamples/advanced/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp and b/Maps3DSamples/advanced/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/Maps3DSamples/advanced/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/Maps3DSamples/advanced/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp index aa7d642..d7f0ad8 100644 Binary files a/Maps3DSamples/advanced/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp and b/Maps3DSamples/advanced/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/Maps3DSamples/advanced/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/Maps3DSamples/advanced/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..db5e095 Binary files /dev/null and b/Maps3DSamples/advanced/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ diff --git a/Maps3DSamples/advanced/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/Maps3DSamples/advanced/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp index 9126ae3..3a285f8 100644 Binary files a/Maps3DSamples/advanced/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp and b/Maps3DSamples/advanced/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/Maps3DSamples/advanced/app/src/main/res/values/strings.xml b/Maps3DSamples/advanced/app/src/main/res/values/strings.xml index 177aa55..95cb89e 100644 --- a/Maps3DSamples/advanced/app/src/main/res/values/strings.xml +++ b/Maps3DSamples/advanced/app/src/main/res/values/strings.xml @@ -19,6 +19,7 @@ 3D Map Samples Scenarios (video visuals) + AI Navigator