From 9fcd9b6ff9dd49fb0e2b40e8d57e872721c02180 Mon Sep 17 00:00:00 2001 From: Dale Hawkins <107309+dkhawk@users.noreply.github.com> Date: Mon, 14 Sep 2026 17:58:10 -0600 Subject: [PATCH 1/7] feat: implement Jetpack Compose sample catalog - Introduce :ApiDemos:common-ui with SampleCatalogRegistry and pure domain SampleEvaluation model - Implement CatalogScreen and CatalogActivity using Jetpack Compose with search, framework filtering, and sample details - Add syntax-highlighted CodeSnippetView and SampleCodeProvider - Wire CatalogActivity as the main launcher in kotlin-app and java-app manifests and MainActivity --- ApiDemos/project/common-ui/build.gradle.kts | 21 +- .../catalog/SampleCatalogRegistry.kt | 686 + .../common_ui/catalog/SampleEvaluation.kt | 34 + .../common_ui/catalog/SampleMetadata.kt | 179 + .../catalog/compose/CatalogActivity.kt | 72 + .../catalog/compose/CatalogScreen.kt | 1038 + .../common_ui/catalog/compose/CatalogTheme.kt | 70 + .../catalog/compose/CodeHighlighter.kt | 142 + .../catalog/compose/CodeSnippetView.kt | 268 + .../catalog/compose/SampleCodeProvider.kt | 3959 ++++ .../catalog/compose/SampleDetailContent.kt | 505 + .../common_ui/catalog/ui/SampleCardAdapter.kt | 173 + .../catalog/ui/UnifiedCatalogActivity.kt | 28 + .../src/main/res/drawable/ic_drag_pan.xml | 26 + .../src/main/res/drawable/ic_grievances.xml | 25 + .../src/main/res/drawable/ic_info_outline.xml | 25 + .../src/main/res/drawable/ic_skip_next.xml | 25 + .../main/res/drawable/ic_skip_previous.xml | 25 + .../res/drawable/ic_status_needs_work.xml | 25 + .../main/res/drawable/ic_status_passing.xml | 25 + .../main/res/drawable/ic_status_unchecked.xml | 25 + .../main/res/drawable/ic_swap_framework.xml | 25 + .../src/main/res/drawable/ic_thumb_up.xml | 25 + .../src/main/res/drawable/ic_undo.xml | 25 + .../src/main/res/drawable/ic_warning_bug.xml | 25 + .../main/res/layout/activity_sample_base.xml | 40 + .../res/layout/activity_unified_catalog.xml | 172 + .../bottom_sheet_sample_expectations.xml | 199 + .../res/layout/cloud_styling_basic_demo.xml | 17 +- .../main/res/layout/ground_overlay_demo.xml | 85 +- .../src/main/res/layout/item_sample_card.xml | 157 + .../main/res/layout/location_source_demo.xml | 105 + .../src/main/res/layout/marker_demo.xml | 64 +- .../src/main/res/layout/multimap_demo.xml | 24 +- .../src/main/res/layout/polyline_demo.xml | 107 +- .../src/main/res/layout/ui_settings_demo.xml | 29 +- .../main/res/layout/visible_region_demo.xml | 149 +- .../src/main/res/menu/visible_region_menu.xml | 34 + .../src/main/res/raw/fowler_rattlesnake.gpx | 19101 ++++++++++++++++ .../common-ui/src/main/res/values/strings.xml | 8 + .../common-ui/src/main/res/xml/file_paths.xml | 23 + .../java-app/src/main/AndroidManifest.xml | 5 + .../com/example/mapdemo/MainActivity.java | 77 +- .../kotlin-app/src/main/AndroidManifest.xml | 5 + .../com/example/kotlindemos/MainActivity.kt | 72 +- 45 files changed, 27646 insertions(+), 303 deletions(-) create mode 100644 ApiDemos/project/common-ui/src/main/java/com/example/common_ui/catalog/SampleCatalogRegistry.kt create mode 100644 ApiDemos/project/common-ui/src/main/java/com/example/common_ui/catalog/SampleEvaluation.kt create mode 100644 ApiDemos/project/common-ui/src/main/java/com/example/common_ui/catalog/SampleMetadata.kt create mode 100644 ApiDemos/project/common-ui/src/main/java/com/example/common_ui/catalog/compose/CatalogActivity.kt create mode 100644 ApiDemos/project/common-ui/src/main/java/com/example/common_ui/catalog/compose/CatalogScreen.kt create mode 100644 ApiDemos/project/common-ui/src/main/java/com/example/common_ui/catalog/compose/CatalogTheme.kt create mode 100644 ApiDemos/project/common-ui/src/main/java/com/example/common_ui/catalog/compose/CodeHighlighter.kt create mode 100644 ApiDemos/project/common-ui/src/main/java/com/example/common_ui/catalog/compose/CodeSnippetView.kt create mode 100644 ApiDemos/project/common-ui/src/main/java/com/example/common_ui/catalog/compose/SampleCodeProvider.kt create mode 100644 ApiDemos/project/common-ui/src/main/java/com/example/common_ui/catalog/compose/SampleDetailContent.kt create mode 100644 ApiDemos/project/common-ui/src/main/java/com/example/common_ui/catalog/ui/SampleCardAdapter.kt create mode 100644 ApiDemos/project/common-ui/src/main/java/com/example/common_ui/catalog/ui/UnifiedCatalogActivity.kt create mode 100644 ApiDemos/project/common-ui/src/main/res/drawable/ic_drag_pan.xml create mode 100644 ApiDemos/project/common-ui/src/main/res/drawable/ic_grievances.xml create mode 100644 ApiDemos/project/common-ui/src/main/res/drawable/ic_info_outline.xml create mode 100644 ApiDemos/project/common-ui/src/main/res/drawable/ic_skip_next.xml create mode 100644 ApiDemos/project/common-ui/src/main/res/drawable/ic_skip_previous.xml create mode 100644 ApiDemos/project/common-ui/src/main/res/drawable/ic_status_needs_work.xml create mode 100644 ApiDemos/project/common-ui/src/main/res/drawable/ic_status_passing.xml create mode 100644 ApiDemos/project/common-ui/src/main/res/drawable/ic_status_unchecked.xml create mode 100644 ApiDemos/project/common-ui/src/main/res/drawable/ic_swap_framework.xml create mode 100644 ApiDemos/project/common-ui/src/main/res/drawable/ic_thumb_up.xml create mode 100644 ApiDemos/project/common-ui/src/main/res/drawable/ic_undo.xml create mode 100644 ApiDemos/project/common-ui/src/main/res/drawable/ic_warning_bug.xml create mode 100644 ApiDemos/project/common-ui/src/main/res/layout/activity_sample_base.xml create mode 100644 ApiDemos/project/common-ui/src/main/res/layout/activity_unified_catalog.xml create mode 100644 ApiDemos/project/common-ui/src/main/res/layout/bottom_sheet_sample_expectations.xml create mode 100644 ApiDemos/project/common-ui/src/main/res/layout/item_sample_card.xml create mode 100644 ApiDemos/project/common-ui/src/main/res/layout/location_source_demo.xml create mode 100644 ApiDemos/project/common-ui/src/main/res/menu/visible_region_menu.xml create mode 100644 ApiDemos/project/common-ui/src/main/res/raw/fowler_rattlesnake.gpx create mode 100644 ApiDemos/project/common-ui/src/main/res/xml/file_paths.xml diff --git a/ApiDemos/project/common-ui/build.gradle.kts b/ApiDemos/project/common-ui/build.gradle.kts index 595680348..cbe6e74d1 100644 --- a/ApiDemos/project/common-ui/build.gradle.kts +++ b/ApiDemos/project/common-ui/build.gradle.kts @@ -1,7 +1,7 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget /* - * Copyright 2025 Google LLC + * Copyright 2026 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,6 +18,7 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.compose) } android { @@ -45,6 +46,7 @@ android { } buildFeatures { viewBinding = true + compose = true } compileOptions { sourceCompatibility = JavaVersion.VERSION_17 @@ -64,12 +66,25 @@ android { } dependencies { - implementation(libs.core.ktx) implementation(libs.appcompat) implementation(libs.material) implementation(libs.play.services.maps) + + // Jetpack Compose + implementation(platform(libs.compose.bom)) + implementation(libs.ui) + implementation(libs.ui.graphics) + implementation(libs.ui.tooling.preview) + implementation(libs.material3) + implementation(libs.material.icons.extended) + implementation(libs.activity.compose) + debugImplementation(libs.ui.tooling) + + // Lifecycle & Coroutines + implementation(libs.lifecycle.runtime.ktx) + testImplementation(libs.junit) androidTestImplementation(libs.junit) androidTestImplementation(libs.espresso.core) -} \ No newline at end of file +} diff --git a/ApiDemos/project/common-ui/src/main/java/com/example/common_ui/catalog/SampleCatalogRegistry.kt b/ApiDemos/project/common-ui/src/main/java/com/example/common_ui/catalog/SampleCatalogRegistry.kt new file mode 100644 index 000000000..6d9fd12ec --- /dev/null +++ b/ApiDemos/project/common-ui/src/main/java/com/example/common_ui/catalog/SampleCatalogRegistry.kt @@ -0,0 +1,686 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.common_ui.catalog + +/** + * Master catalog registry of all Google Maps Platform samples, snippets, and demos. + * + * Uses Fully Qualified Class Names (FQCN) as evaluation identifiers. + * Organizes samples by Category, Complexity, Framework (Kotlin & Java), and Hashtags with rich HTML expectations. + */ +object SampleCatalogRegistry { + + val SAMPLES: List = listOf( + // ========================================== + // πŸ—ΊοΈ MAP INITIALIZATION & LIFECYCLE + // ========================================== + SampleItem( + id = "com.example.kotlindemos.BasicMapDemoActivity", + title = "Basic Map", + description = "Fundamental map instantiation, lifecycle binding, and default camera centering.", + category = "Map Initialization", + complexity = Complexity.SNIPPET, + tags = listOf("#map", "#init", "#lifecycle", "#quickstart"), + apiCalls = listOf( + "SupportMapFragment.getMapAsync(OnMapReadyCallback)", + "GoogleMap.addMarker(MarkerOptions)", + "GoogleMap.moveCamera(CameraUpdate)" + ), + purpose = "Demonstrates clean, minimal map instantiation using SupportMapFragment.", + successCriteria = "The map loads default vector tiles cleanly centered at the initial coordinates with working gestures.", + failureIndicators = "Grey tiles (missing API key or auth mismatch), crash on back navigation, or map failing to unpause.", + kotlinActivity = "com.example.kotlindemos.BasicMapDemoActivity", + javaActivity = "com.example.mapdemo.BasicMapDemoActivity" + ), + SampleItem( + id = "com.example.kotlindemos.ProgrammaticDemoActivity", + title = "Programmatic Map", + description = "Instantiating and attaching a SupportMapFragment entirely in code without XML layout.", + category = "Map Initialization", + complexity = Complexity.SNIPPET, + tags = listOf("#programmatic", "#fragment", "#dynamic", "#init"), + apiCalls = listOf( + "SupportMapFragment.newInstance()", + "FragmentManager.beginTransaction().add(...)", + "SupportMapFragment.getMapAsync(OnMapReadyCallback)" + ), + purpose = "Shows how to dynamically instantiate and attach SupportMapFragment using FragmentManager transactions.", + successCriteria = "Map attaches dynamically to the container layout and renders correctly on launch.", + failureIndicators = "Blank screen, fragment transaction exception, or duplicate map fragments on orientation change.", + kotlinActivity = "com.example.kotlindemos.ProgrammaticDemoActivity", + javaActivity = "com.example.mapdemo.ProgrammaticDemoActivity" + ), + SampleItem( + id = "com.example.kotlindemos.RawMapViewDemoActivity", + title = "Raw MapView", + description = "Direct MapView embedding with explicit Activity lifecycle forwarding.", + category = "Map Initialization", + complexity = Complexity.SIMPLE, + tags = listOf("#mapview", "#lifecycle", "#embedding"), + apiCalls = listOf( + "MapView.onCreate(Bundle)", + "MapView.getMapAsync(OnMapReadyCallback)", + "MapView.onStart() / onResume() / onPause()" + ), + purpose = "Shows how to embed MapView directly in a layout and forward all Activity lifecycle callbacks.", + successCriteria = "MapView loads tiles and pauses/resumes correctly when backgrounded and foregrounded.", + failureIndicators = "Black rendering surface, memory leaks on orientation change, or crash when onLowMemory is triggered.", + kotlinActivity = "com.example.kotlindemos.RawMapViewDemoActivity", + javaActivity = "com.example.mapdemo.RawMapViewDemoActivity" + ), + SampleItem( + id = "com.example.kotlindemos.RetainMapDemoActivity", + title = "Retained Map", + description = "Retaining map state across runtime configuration changes (screen rotations).", + category = "Map Initialization", + complexity = Complexity.SIMPLE, + tags = listOf("#retain", "#configuration", "#rotation", "#lifecycle"), + apiCalls = listOf( + "SupportMapFragment.retainInstance = true", + "SupportMapFragment.getMapAsync(OnMapReadyCallback)" + ), + purpose = "Demonstrates retaining map instance state across orientation changes without reloading tiles.", + successCriteria = "Rotating device does not flash or re-initialize map state; markers and camera remain intact.", + failureIndicators = "Map resets to initial position or flashes white/black on rotation.", + kotlinActivity = "com.example.kotlindemos.RetainMapDemoActivity", + javaActivity = "com.example.mapdemo.RetainMapDemoActivity" + ), + SampleItem( + id = "com.example.kotlindemos.MultiMapDemoActivity", + title = "Multi-Map View", + description = "Rendering multiple independent GoogleMap instances in a single activity layout.", + category = "Map Initialization", + complexity = Complexity.ADVANCED, + tags = listOf("#multimap", "#multiple", "#layout", "#rendering"), + apiCalls = listOf( + "SupportMapFragment.getMapAsync(OnMapReadyCallback)", + "GoogleMap.animateCamera(CameraUpdate, int, CancelableCallback)", + "GoogleMap.addMarker(MarkerOptions)" + ), + purpose = "Shows how to render and control multiple independent GoogleMap instances concurrently in one screen.", + successCriteria = "All 4 map fragments render distinct geographic locations simultaneously with smooth scrolling.", + failureIndicators = "GL context collision, thread locking, or tile stuttering when dragging multiple maps.", + kotlinActivity = "com.example.kotlindemos.MultiMapDemoActivity", + javaActivity = "com.example.mapdemo.MultiMapDemoActivity" + ), + SampleItem( + id = "com.example.kotlindemos.MapInPagerDemoActivity", + title = "Map in ViewPager", + description = "Hosting MapView instances inside a swipeable ViewPager2 structure.", + category = "Map Initialization", + complexity = Complexity.ADVANCED, + tags = listOf("#viewpager", "#swiping", "#touchinterception", "#fragments"), + apiCalls = listOf( + "ViewPager2.adapter", + "MapView.getMapAsync(OnMapReadyCallback)", + "ViewParent.requestDisallowInterceptTouchEvent(true)" + ), + purpose = "Demonstrates embedding maps inside ViewPager tabs with proper touch disallow interception.", + successCriteria = "Panning map does not accidentally trigger ViewPager page swipe.", + failureIndicators = "Swiping horizontally pans the ViewPager instead of the map camera.", + kotlinActivity = "com.example.kotlindemos.MapInPagerDemoActivity", + javaActivity = "com.example.mapdemo.MapInPagerDemoActivity" + ), + + // ========================================== + // πŸ“· CAMERA & VIEWPORT CONTROLS + // ========================================== + SampleItem( + id = "com.example.kotlindemos.CameraDemoActivity", + title = "Camera Controls & Animation", + description = "Programmatic camera panning, zooming, tilt, bearing, and smooth animations.", + category = "Camera Controls", + complexity = Complexity.SIMPLE, + tags = listOf("#camera", "#animation", "#bearing", "#tilt", "#zoom", "#pan"), + apiCalls = listOf( + "GoogleMap.animateCamera(CameraUpdate, Int, CancelableCallback)", + "CameraPosition.Builder().target(...).zoom(...).bearing(...).tilt(...).build()", + "CameraUpdateFactory.newCameraPosition(CameraPosition)" + ), + purpose = "Demonstrates programmatic camera movements, animated transitions, tilt angles, and bearing rotations.", + successCriteria = "Buttons animate camera smoothly with custom durations, stops, and rotation angles.", + failureIndicators = "Jerky animations, unexpected camera jumps, or tilt angle exceeding platform constraints.", + kotlinActivity = "com.example.kotlindemos.CameraDemoActivity", + javaActivity = "com.example.mapdemo.CameraDemoActivity" + ), + SampleItem( + id = "com.example.kotlindemos.CameraClampingDemoActivity", + title = "Camera Clamping & Bounds", + description = "Constraining camera viewport to LatLngBounds and dynamic min/max zoom limits.", + category = "Camera Controls", + complexity = Complexity.SIMPLE, + tags = listOf("#camera", "#clamping", "#bounds", "#zoomlimits", "#latlngbounds"), + apiCalls = listOf( + "GoogleMap.setLatLngBoundsForCameraTarget(LatLngBounds)", + "GoogleMap.setMinZoomPreference(Float)", + "GoogleMap.setMaxZoomPreference(Float)" + ), + purpose = "Demonstrates restricting camera panning to a specific bounding box (Adelaide/Pacific) and zoom slider limits.", + successCriteria = "User cannot pan the camera outside the clamped region; zoom sliders enforce min/max bounds immediately.", + failureIndicators = "Camera pans outside bounding box or resetting bounds fails when selecting 'Reset Bounds'.", + kotlinActivity = "com.example.kotlindemos.CameraClampingDemoActivity", + javaActivity = "com.example.mapdemo.CameraClampingDemoActivity" + ), + SampleItem( + id = "com.example.kotlindemos.VisibleRegionDemoActivity", + title = "Visible Region & Projection", + description = "Querying current viewport bounding coordinates via GoogleMap.projection.", + category = "Camera Controls", + complexity = Complexity.SIMPLE, + tags = listOf("#camera", "#projection", "#visibleregion", "#latlngbounds"), + apiCalls = listOf( + "GoogleMap.setPadding(int, int, int, int)", + "GoogleMap.moveCamera(CameraUpdate)", + "GoogleMap.cameraPosition", + "GoogleMap.setOnCameraIdleListener(OnCameraIdleListener)" + ), + purpose = "Demonstrates reading GoogleMap.projection.visibleRegion and calculating viewport bounds dynamically.", + successCriteria = "Bounding coordinates update live in the UI as the camera pans and zooms.", + failureIndicators = "Projection returns null or stale LatLng bounds after camera idle.", + kotlinActivity = "com.example.kotlindemos.VisibleRegionDemoActivity", + javaActivity = "com.example.mapdemo.VisibleRegionDemoActivity" + ), + + // ========================================== + // πŸ“ MARKERS & INFO WINDOWS + // ========================================== + SampleItem( + id = "com.example.kotlindemos.AdvancedMarkersDemoActivity", + title = "Advanced Markers & Pins", + description = "Modern PinConfig pins, custom glyphs, badge icon views, and collision behavior.", + category = "Markers & Overlays", + complexity = Complexity.ADVANCED, + tags = listOf("#markers", "#advancedmarkers", "#pinconfig", "#collision", "#badges", "#mapid"), + apiCalls = listOf( + "AdvancedMarkerOptions.position(LatLng)", + "PinConfig.builder().setBackgroundColor(...).setGlyph(...).build()", + "AdvancedMarkerOptions.icon(BitmapDescriptorFactory.fromPinConfig(...))", + "AdvancedMarkerOptions.collisionBehavior(Int)", + "GoogleMap.addMarker(AdvancedMarkerOptions)" + ), + purpose = "Demonstrates Cloud-backed Advanced Markers with custom colors, pin glyphs, collision behaviors, and custom View icons.", + successCriteria = "Custom colored pins and badge icon views render sharply at correct anchor points with collision handling.", + failureIndicators = "Pins render as default red markers (missing Map ID), collision behavior ignored, or badge text blurry.", + kotlinActivity = "com.example.kotlindemos.AdvancedMarkersDemoActivity", + javaActivity = "com.example.mapdemo.AdvancedMarkersDemoActivity" + ), + SampleItem( + id = "com.example.kotlindemos.MarkerDemoActivity", + title = "Standard Markers & Info Windows", + description = "Placing markers, custom icons, draggable pins, and custom info window layouts.", + category = "Markers & Overlays", + complexity = Complexity.SIMPLE, + tags = listOf("#markers", "#infowindow", "#draggable", "#icons", "#anchor"), + apiCalls = listOf( + "GoogleMap.addMarker(MarkerOptions)", + "MarkerOptions.position(LatLng).title(String).draggable(Boolean)", + "GoogleMap.setInfoWindowAdapter(InfoWindowAdapter)", + "GoogleMap.setOnMarkerClickListener(OnMarkerClickListener)", + "GoogleMap.setOnMarkerDragListener(OnMarkerDragListener)" + ), + purpose = "Demonstrates adding standard markers with alpha, rotation, draggable pins, and custom InfoWindowAdapter views.", + successCriteria = "Tapping markers displays custom info windows with formatted content; dragging pins updates position.", + failureIndicators = "Info window clicks not detected or custom snippet styling not applied.", + kotlinActivity = "com.example.kotlindemos.MarkerDemoActivity", + javaActivity = "com.example.mapdemo.MarkerDemoActivity" + ), + SampleItem( + id = "com.example.kotlindemos.MarkerCloseInfoWindowOnRetapDemoActivity", + title = "Marker InfoWindow Re-tap Toggle", + description = "Toggling InfoWindow dismiss when re-tapping an active marker.", + category = "Markers & Overlays", + complexity = Complexity.SNIPPET, + tags = listOf("#markers", "#infowindow", "#toggle", "#gestures"), + apiCalls = listOf( + "GoogleMap.setOnMarkerClickListener(OnMarkerClickListener)", + "Marker.showInfoWindow()", + "Marker.hideInfoWindow()", + "Marker.isInfoWindowShown" + ), + purpose = "Shows how to implement re-tap to dismiss toggle behavior for active marker info windows.", + successCriteria = "First tap opens info window; second tap on the same marker closes it cleanly.", + failureIndicators = "Info window stays stuck open or re-tap triggers unnecessary camera repositioning.", + kotlinActivity = "com.example.kotlindemos.MarkerCloseInfoWindowOnRetapDemoActivity", + javaActivity = "com.example.mapdemo.MarkerCloseInfoWindowOnRetapDemoActivity" + ), + + // ========================================== + // πŸ“ SHAPES & GEOMETRY + // ========================================== + SampleItem( + id = "com.example.kotlindemos.PolygonDemoActivity", + title = "Polygons & Holes", + description = "Drawing geodesic polygons with fill colors, stroke patterns, click events, and interior holes.", + category = "Shapes & Geometry", + complexity = Complexity.SIMPLE, + tags = listOf("#shapes", "#polygons", "#holes", "#geometry", "#stroke", "#fill"), + apiCalls = listOf( + "GoogleMap.addPolygon(PolygonOptions)", + "PolygonOptions.addAll(Iterable)", + "PolygonOptions.addHole(Iterable)", + "PolygonOptions.fillColor(Int).strokeColor(Int).strokeWidth(Float)", + "Polygon.isClickable = true" + ), + purpose = "Demonstrates drawing styled polygons with interior holes (donut polygons), click listeners, and stroke caps.", + successCriteria = "Polygons render with specified fill opacity and interior cutout holes properly subtracted.", + failureIndicators = "Holes not rendering as transparent cutouts or stroke color incorrect.", + kotlinActivity = "com.example.kotlindemos.PolygonDemoActivity", + javaActivity = "com.example.mapdemo.PolygonDemoActivity" + ), + SampleItem( + id = "com.example.kotlindemos.PolylineDemoActivity", + title = "Polylines & Patterns", + description = "Drawing polylines with joint types, dash/dot stroke patterns, joint styles, and spans.", + category = "Shapes & Geometry", + complexity = Complexity.SIMPLE, + tags = listOf("#shapes", "#polylines", "#patterns", "#dashes", "#stroke", "#routes"), + apiCalls = listOf( + "GoogleMap.addPolyline(PolylineOptions)", + "PolylineOptions.addAll(Iterable)", + "PolylineOptions.pattern(List)", + "PolylineOptions.jointType(JointType).startCap(Cap).endCap(Cap)", + "Polyline.isClickable = true" + ), + purpose = "Demonstrates drawing customizable polylines with dash/gap patterns, round end caps, and bevel joints.", + successCriteria = "Polylines render crisp dashed and dotted stroke lines along coordinate vertices.", + failureIndicators = "Line caps distorted or custom pattern ignored on high-DPI screens.", + kotlinActivity = "com.example.kotlindemos.PolylineDemoActivity", + javaActivity = "com.example.mapdemo.PolylineDemoActivity" + ), + SampleItem( + id = "com.example.kotlindemos.CircleDemoActivity", + title = "Circles & Geodesic Radii", + description = "Drawing geographic circles with dynamic center drag, radius sliders, and stroke styling.", + category = "Shapes & Geometry", + complexity = Complexity.SIMPLE, + tags = listOf("#shapes", "#circles", "#radius", "#geodesic"), + apiCalls = listOf( + "GoogleMap.addCircle(CircleOptions)", + "CircleOptions.center(LatLng).radius(Double)", + "CircleOptions.fillColor(Int).strokeColor(Int).strokeWidth(Float)", + "Circle.center = LatLng / Circle.radius = Double" + ), + purpose = "Demonstrates drawing circles with radius defined in meters and dynamic updates via seekbars.", + successCriteria = "Adjusting radius slider dynamically updates circle boundary in real-time.", + failureIndicators = "Circle distorted or radius math inaccurate across high latitudes.", + kotlinActivity = "com.example.kotlindemos.CircleDemoActivity", + javaActivity = "com.example.mapdemo.CircleDemoActivity" + ), + + // ========================================== + // πŸ—ΊοΈ DATA-DRIVEN STYLING (CLOUD MAPS) + // ========================================== + SampleItem( + id = "com.example.kotlindemos.DataDrivenBoundariesActivity", + title = "Data-Driven Boundaries", + description = "Dynamic styling and click handlers for administrative boundaries (Localities, States, Countries).", + category = "Data-Driven Styling", + complexity = Complexity.ADVANCED, + tags = listOf("#boundaries", "#datadriven", "#featurelayer", "#locality", "#choropleth"), + apiCalls = listOf( + "GoogleMap.getFeatureLayer(FeatureLayerOptions)", + "FeatureLayer.setFeatureStyle(FeatureStyleFunction)", + "FeatureStyle.Builder().fillColor(Int).strokeColor(Int).build()", + "FeatureLayer.addOnFeatureClickListener(OnFeatureClickListener)" + ), + purpose = "Demonstrates styling administrative boundaries dynamically via FeatureLayer and capturing boundary clicks.", + successCriteria = "Boundaries render with custom stroke and fill colors; tapping a region highlights its polygon.", + failureIndicators = "Boundary layer is null (requires vector map / Map ID) or click listener not firing.", + kotlinActivity = "com.example.kotlindemos.DataDrivenBoundariesActivity", + javaActivity = "com.example.mapdemo.DataDrivenBoundariesActivity" + ), + SampleItem( + id = "com.example.kotlindemos.DataDrivenDatasetStylingActivity", + title = "Data-Driven Dataset Styling", + description = "Styling custom geospatial datasets uploaded to Google Cloud Platform based on attributes.", + category = "Data-Driven Styling", + complexity = Complexity.ADVANCED, + tags = listOf("#datasets", "#datadriven", "#clouddata", "#attributes", "#filtering"), + apiCalls = listOf( + "GoogleMap.getDatasetFeatureLayer(datasetId)", + "FeatureLayer.setFeatureStyle(FeatureStyleFunction)", + "DatasetFeature.datasetAttributes[attributeKey]" + ), + purpose = "Demonstrates loading a Cloud Dataset FeatureLayer and applying dynamic style rules based on feature properties.", + successCriteria = "Dataset points and polygons display distinct styling according to attribute values.", + failureIndicators = "Dataset ID invalid or attributes fail to filter correctly.", + kotlinActivity = "com.example.kotlindemos.DataDrivenDatasetStylingActivity", + javaActivity = "com.example.mapdemo.DataDrivenDatasetStylingActivity" + ), + + // ========================================== + // 🎨 STYLING & CLOUD THEMES + // ========================================== + SampleItem( + id = "com.example.kotlindemos.CloudBasedMapStylingDemoActivity", + title = "Cloud-Based Map Styling", + description = "Using Cloud Map IDs for server-side JSON styling and feature management.", + category = "Styling & Cloud", + complexity = Complexity.SIMPLE, + tags = listOf("#cloudstyling", "#mapid", "#vector", "#theming"), + apiCalls = listOf( + "SupportMapFragment.newInstance(GoogleMapOptions().mapId(String))", + "GoogleMap.mapType = GoogleMap.MAP_TYPE_NORMAL" + ), + purpose = "Demonstrates linking a map to a Cloud-managed Map ID for instant over-the-air style updates.", + successCriteria = "Map renders with the customized cloud style colors without local JSON parsing.", + failureIndicators = "Default styling rendered (Map ID unlinked or network error during initial style fetch).", + kotlinActivity = "com.example.kotlindemos.CloudBasedMapStylingDemoActivity", + javaActivity = "com.example.mapdemo.CloudBasedMapStylingDemoActivity" + ), + SampleItem( + id = "com.example.kotlindemos.StyledMapDemoActivity", + title = "JSON Map Styling (Retro / Dark)", + description = "Applying raw JSON styling rules locally for Retro, Grayscale, and Night mode aesthetics.", + category = "Styling & Cloud", + complexity = Complexity.SIMPLE, + tags = listOf("#styling", "#json", "#darkmode", "#night", "#retro"), + apiCalls = listOf( + "GoogleMap.setMapStyle(MapStyleOptions.loadRawResourceStyle(Context, Int))", + "MapStyleOptions(jsonStyleString)" + ), + purpose = "Demonstrates applying local JSON MapStyleOptions to change base map theme dynamically.", + successCriteria = "Selecting style options in the toolbar instantly restyles the map (Night / Retro / Standard).", + failureIndicators = "Invalid JSON causes silent fallback or parsing exception.", + kotlinActivity = "com.example.kotlindemos.StyledMapDemoActivity", + javaActivity = "com.example.mapdemo.StyledMapDemoActivity" + ), + SampleItem( + id = "com.example.kotlindemos.MapColorSchemeActivity", + title = "Map Color Scheme (System / Light / Dark)", + description = "Configuring automatic system dark mode following via MapColorScheme.", + category = "Styling & Cloud", + complexity = Complexity.SNIPPET, + tags = listOf("#colorscheme", "#darkmode", "#systemtheme", "#followsystem"), + apiCalls = listOf( + "GoogleMapOptions.mapColorScheme(MapColorScheme.FOLLOW_SYSTEM)", + "GoogleMapOptions.mapColorScheme(MapColorScheme.DARK)", + "GoogleMapOptions.mapColorScheme(MapColorScheme.LIGHT)" + ), + purpose = "Shows how to set GoogleMapOptions.mapColorScheme to follow system night mode automatically.", + successCriteria = "Toggling device dark mode flips map styling between light and dark palettes seamlessly.", + failureIndicators = "Map remains stuck in light theme when system dark mode is enabled.", + kotlinActivity = "com.example.kotlindemos.MapColorSchemeActivity", + javaActivity = "com.example.mapdemo.MapColorSchemeActivity" + ), + + // ========================================== + // πŸ™οΈ STREET VIEW & PANORAMAS + // ========================================== + SampleItem( + id = "com.example.kotlindemos.SplitStreetViewPanoramaAndMapDemoActivity", + title = "Split Street View & Map Sync", + description = "Dual synchronized view: draggable 2D Pegman marker synchronized with 3D Street View panorama.", + category = "Street View", + complexity = Complexity.ADVANCED, + tags = listOf("#streetview", "#panorama", "#pegman", "#sync", "#bidirectional"), + apiCalls = listOf( + "StreetViewPanoramaView.getStreetViewPanoramaAsync(OnStreetViewPanoramaReadyCallback)", + "StreetViewPanorama.setPosition(LatLng)", + "StreetViewPanorama.setOnStreetViewPanoramaChangeListener(...)", + "StreetViewPanorama.animateTo(StreetViewPanoramaCamera, Long)", + "GoogleMap.addMarker(MarkerOptions)" + ), + purpose = "Demonstrates bidirectional synchronization: dragging map Pegman updates panorama; walking Street View moves map marker.", + successCriteria = "Moving Pegman on map instantly loads new 360 panorama; street navigation rotates Pegman bearing.", + failureIndicators = "Infinite update feedback loops, Pegman desyncing from panorama, or FAB jump failing.", + kotlinActivity = "com.example.kotlindemos.SplitStreetViewPanoramaAndMapDemoActivity", + javaActivity = "com.example.mapdemo.SplitStreetViewPanoramaAndMapDemoActivity" + ), + SampleItem( + id = "com.example.kotlindemos.StreetViewPanoramaBasicDemoActivity", + title = "Basic Street View Panorama", + description = "Instantiating a StreetViewPanoramaFragment and loading coordinates.", + category = "Street View", + complexity = Complexity.SNIPPET, + tags = listOf("#streetview", "#panorama", "#init", "#sydney"), + apiCalls = listOf( + "StreetViewPanoramaFragment.getStreetViewPanoramaAsync(OnStreetViewPanoramaReadyCallback)", + "StreetViewPanorama.setPosition(LatLng)" + ), + purpose = "Demonstrates embedding StreetViewPanoramaFragment and setting initial position by LatLng.", + successCriteria = "360-degree panorama loads smoothly with working touch gestures.", + failureIndicators = "Black panorama canvas, missing imagery at coordinates, or gesture freeze.", + kotlinActivity = "com.example.kotlindemos.StreetViewPanoramaBasicDemoActivity", + javaActivity = "com.example.mapdemo.StreetViewPanoramaBasicDemoActivity" + ), + + // ========================================== + // ⚑ LISTS & RECYCLERVIEW PERFORMANCE + // ========================================== + SampleItem( + id = "com.example.kotlindemos.LiteListDemoActivity", + title = "Lite Mode in RecyclerView", + description = "High-performance Lite Mode map instances inside smooth scrolling RecyclerView list rows.", + category = "Lists & Performance", + complexity = Complexity.ADVANCED, + tags = listOf("#litemode", "#recyclerview", "#lists", "#viewholder", "#lifecycle"), + apiCalls = listOf( + "GoogleMapOptions.liteMode(true)", + "MapView.onCreate(null)", + "MapView.getMapAsync(OnMapReadyCallback)", + "RecyclerView.Adapter.onBindViewHolder(...)" + ), + purpose = "Demonstrates embedding MapView lite mode instances inside RecyclerView rows with proper lifecycle management.", + successCriteria = "List scrolls at 60/120fps without stutter; map snapshots display accurate markers per row.", + failureIndicators = "RecyclerView scrolling stutters or recycled MapViews display stale map markers.", + kotlinActivity = "com.example.kotlindemos.LiteListDemoActivity", + javaActivity = "com.example.mapdemo.LiteListDemoActivity" + ), + SampleItem( + id = "com.example.kotlindemos.LiteDemoActivity", + title = "Lite Mode Basics", + description = "Non-interactive raster map with programmatic camera jumps, markers, and polygons.", + category = "Lists & Performance", + complexity = Complexity.SIMPLE, + tags = listOf("#litemode", "#static", "#raster", "#markers", "#polygons"), + apiCalls = listOf( + "GoogleMapOptions.liteMode(true)", + "GoogleMap.moveCamera(CameraUpdate)", + "GoogleMap.addMarker(MarkerOptions)", + "GoogleMap.addPolygon(PolygonOptions)" + ), + purpose = "Demonstrates Lite Mode features: static raster rendering, markers launching Google Maps intent, and programmatic camera jumps.", + successCriteria = "Map renders lightweight static raster view; Darwin/Adelaide buttons immediately reposition camera.", + failureIndicators = "Full vector GL map loaded instead of lite mode, or buttons fail to move camera.", + kotlinActivity = "com.example.kotlindemos.LiteDemoActivity", + javaActivity = "com.example.mapdemo.LiteDemoActivity" + ), + + // ========================================== + // πŸ“Έ SNAPSHOTS & SHARING + // ========================================== + SampleItem( + id = "com.example.kotlindemos.SnapshotDemoActivity", + title = "Map Snapshot & Image Capture", + description = "Asynchronous bitmap frame capture using GoogleMap.snapshot() rendered in Material 3 preview cards.", + category = "Snapshots & Sharing", + complexity = Complexity.SIMPLE, + tags = listOf("#snapshot", "#bitmap", "#export", "#material3", "#capture"), + apiCalls = listOf( + "GoogleMap.snapshot(SnapshotReadyCallback)", + "GoogleMap.snapshot(SnapshotReadyCallback, Bitmap)" + ), + purpose = "Demonstrates taking asynchronous high-resolution bitmap snapshots of the map with snapshot ready callbacks.", + successCriteria = "Tapping 'Take Snapshot' captures the current map frame and displays it in the Material 3 preview card.", + failureIndicators = "Snapshot returns blank bitmap or blocks UI thread during GL readback.", + kotlinActivity = "com.example.kotlindemos.SnapshotDemoActivity", + javaActivity = "com.example.mapdemo.SnapshotDemoActivity" + ), + + // ========================================== + // πŸ“ LOCATION & SENSORS + // ========================================== + SampleItem( + id = "com.example.kotlindemos.MyLocationDemoActivity", + title = "My Location Layer", + description = "Enabling blue dot location indicator and My Location button with runtime permissions.", + category = "Location & Sensors", + complexity = Complexity.SIMPLE, + tags = listOf("#location", "#mylocation", "#permissions", "#bluedot"), + apiCalls = listOf( + "GoogleMap.isMyLocationEnabled = true", + "GoogleMap.uiSettings.isMyLocationButtonEnabled = true", + "ActivityCompat.requestPermissions(..., ACCESS_FINE_LOCATION)" + ), + purpose = "Demonstrates requesting ACCESS_FINE_LOCATION permissions and enabling the blue dot location layer.", + successCriteria = "Tapping My Location button centers camera on user's current GPS position.", + failureIndicators = "Permission denial causes unhandled crash or location button missing.", + kotlinActivity = "com.example.kotlindemos.MyLocationDemoActivity", + javaActivity = "com.example.mapdemo.MyLocationDemoActivity" + ), + SampleItem( + id = "com.example.kotlindemos.LocationSourceDemoActivity", + title = "Custom LocationSource", + description = "Providing a custom mock LocationSource for simulated GPS navigation playback along a trail.", + category = "Location & Sensors", + complexity = Complexity.ADVANCED, + tags = listOf("#location", "#locationsource", "#mock", "#simulation", "#gpx", "#navigation"), + apiCalls = listOf( + "GoogleMap.setLocationSource(LocationSource)", + "LocationSource.activate(OnLocationChangedListener)", + "LocationSource.deactivate()", + "GoogleMap.setMyLocationEnabled(Boolean)", + "GoogleMap.addPolyline(PolylineOptions)", + "CameraUpdateFactory.newLatLngBounds(LatLngBounds, Int)" + ), + purpose = "Shows how to feed programmatic coordinates from a GPX track into the GoogleMap location layer using a custom LocationSource.", + successCriteria = "The map bounds to the trail, draws a polyline, and the blue dot animates smoothly along the route.", + failureIndicators = "Blue dot fails to move or location updates cause memory leaks.", + kotlinActivity = "com.example.kotlindemos.LocationSourceDemoActivity", + javaActivity = "com.example.mapdemo.LocationSourceDemoActivity" + ), + + // ========================================== + // πŸ”² OVERLAYS & CUSTOM TILES + // ========================================== + SampleItem( + id = "com.example.kotlindemos.GroundOverlayDemoActivity", + title = "Ground Overlays", + description = "Anchoring raster bitmap images to geographic LatLngBounds on the map surface.", + category = "Overlays & Tiles", + complexity = Complexity.SIMPLE, + tags = listOf("#overlays", "#groundoverlay", "#images", "#bounds", "#transparency"), + apiCalls = listOf( + "GoogleMap.addGroundOverlay(GroundOverlayOptions)", + "GroundOverlayOptions.image(BitmapDescriptor).position(LatLng, Float, Float)", + "GroundOverlayOptions.positionFromBounds(LatLngBounds).transparency(Float)" + ), + purpose = "Demonstrates overlaying historical or custom aerial images onto the map with transparency sliders.", + successCriteria = "Historical Newark map image appears pinned to geographic coordinates with adjustable transparency.", + failureIndicators = "Overlay image stretched/misaligned or opacity slider unresponsive.", + kotlinActivity = "com.example.kotlindemos.GroundOverlayDemoActivity", + javaActivity = "com.example.mapdemo.GroundOverlayDemoActivity" + ), + SampleItem( + id = "com.example.kotlindemos.TileOverlayDemoActivity", + title = "Tile Overlays & TileProvider", + description = "Custom TileProvider rendering coordinate grid tiles and custom imagery.", + category = "Overlays & Tiles", + complexity = Complexity.SIMPLE, + tags = listOf("#overlays", "#tiles", "#tileprovider", "#customtiles"), + apiCalls = listOf( + "GoogleMap.addTileOverlay(TileOverlayOptions)", + "TileOverlayOptions.tileProvider(TileProvider)", + "TileProvider.getTile(x, y, zoom)" + ), + purpose = "Demonstrates generating custom raster tiles on the fly using a custom TileProvider (coordinate overlays).", + successCriteria = "Tile grid numbers (x, y, zoom) render cleanly over the base map.", + failureIndicators = "Tile rendering blocks UI thread or tiles fail to fetch on pan.", + kotlinActivity = "com.example.kotlindemos.TileOverlayDemoActivity", + javaActivity = "com.example.mapdemo.TileOverlayDemoActivity" + ), + + // ========================================== + // πŸ‘† EVENTS & GESTURES + // ========================================== + SampleItem( + id = "com.example.kotlindemos.EventsDemoActivity", + title = "Events & Gestures", + description = "Handling map taps, long clicks, camera change events, and POI selections.", + category = "Events & Gestures", + complexity = Complexity.SNIPPET, + tags = listOf("#events", "#gestures", "#clicks", "#poi", "#listeners"), + apiCalls = listOf( + "GoogleMap.setOnMapClickListener(OnMapClickListener)", + "GoogleMap.setOnMapLongClickListener(OnMapLongClickListener)", + "GoogleMap.setOnCameraMoveListener(OnCameraMoveListener)", + "GoogleMap.setOnPoiClickListener(OnPoiClickListener)" + ), + purpose = "Demonstrates registering listeners for map clicks, long presses, camera moves, and POI selections.", + successCriteria = "Event log text updates with coordinates and POI names upon user interaction.", + failureIndicators = "Click events swallowed or POI name unresolved.", + kotlinActivity = "com.example.kotlindemos.EventsDemoActivity", + javaActivity = "com.example.mapdemo.EventsDemoActivity" + ), + SampleItem( + id = "com.example.kotlindemos.UiSettingsDemoActivity", + title = "UI Settings & Map Controls", + description = "Configuring zoom buttons, compass, my location button, and gesture toggles.", + category = "Events & Gestures", + complexity = Complexity.SIMPLE, + tags = listOf("#uisettings", "#controls", "#gestures", "#compass", "#zoombuttons"), + apiCalls = listOf( + "GoogleMap.uiSettings.isZoomControlsEnabled = Boolean", + "GoogleMap.uiSettings.isCompassEnabled = Boolean", + "GoogleMap.uiSettings.isMyLocationButtonEnabled = Boolean", + "GoogleMap.uiSettings.isScrollGesturesEnabled = Boolean", + "GoogleMap.uiSettings.isTiltGesturesEnabled = Boolean", + "GoogleMap.uiSettings.isRotateGesturesEnabled = Boolean" + ), + purpose = "Shows how to toggle GoogleMap.uiSettings controls (compass, zoom buttons, scroll/tilt gestures).", + successCriteria = "Toggling checkboxes in the drawer instantly enables/disables corresponding map gestures and UI controls.", + failureIndicators = "Gesture toggles ignored or UI control icons clipped by safe area.", + kotlinActivity = "com.example.kotlindemos.UiSettingsDemoActivity", + javaActivity = "com.example.mapdemo.UiSettingsDemoActivity" + ) + ) + + fun getAllTags(): List { + return SAMPLES.flatMap { it.tags }.distinct().sorted() + } + + fun getCategories(): List { + return SAMPLES.map { it.category }.distinct().sorted() + } + + fun filter( + framework: Framework = Framework.KOTLIN_VIEWS, + complexity: Complexity? = null, + selectedTags: Set = emptySet(), + searchQuery: String = "" + ): List { + return SAMPLES.filter { sample -> + val matchesFramework = sample.getActivityForFramework(framework) != null + val matchesComplexity = complexity == null || sample.complexity == complexity + val matchesTags = selectedTags.isEmpty() || sample.tags.any { selectedTags.contains(it) } + val matchesSearch = searchQuery.isBlank() || + sample.title.contains(searchQuery, ignoreCase = true) || + sample.description.contains(searchQuery, ignoreCase = true) || + sample.category.contains(searchQuery, ignoreCase = true) || + sample.tags.any { it.contains(searchQuery, ignoreCase = true) } || + sample.id.contains(searchQuery, ignoreCase = true) + + matchesFramework && matchesComplexity && matchesTags && matchesSearch + } + } + + fun findById(id: String?): SampleItem? { + if (id == null) return null + return SAMPLES.find { it.id == id || it.kotlinActivity == id || it.javaActivity == id } + } +} diff --git a/ApiDemos/project/common-ui/src/main/java/com/example/common_ui/catalog/SampleEvaluation.kt b/ApiDemos/project/common-ui/src/main/java/com/example/common_ui/catalog/SampleEvaluation.kt new file mode 100644 index 000000000..7a5760355 --- /dev/null +++ b/ApiDemos/project/common-ui/src/main/java/com/example/common_ui/catalog/SampleEvaluation.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.common_ui.catalog + +import java.io.Serializable + +/** + * Domain data model representing a sample manual review evaluation. + */ +data class SampleEvaluation( + val sampleId: String, + val sampleTitle: String = "", + val activityName: String = "", + val category: String = "", + val framework: String = "", + val status: String = ReviewStatus.UNCHECKED.name, + val notes: String = "", + val screenshotPath: String? = null, + val lastUpdated: Long = System.currentTimeMillis() +) : Serializable diff --git a/ApiDemos/project/common-ui/src/main/java/com/example/common_ui/catalog/SampleMetadata.kt b/ApiDemos/project/common-ui/src/main/java/com/example/common_ui/catalog/SampleMetadata.kt new file mode 100644 index 000000000..b3b9edfd5 --- /dev/null +++ b/ApiDemos/project/common-ui/src/main/java/com/example/common_ui/catalog/SampleMetadata.kt @@ -0,0 +1,179 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.common_ui.catalog + +import java.io.Serializable + +/** + * Annotation for Google Maps Platform sample activities and snippet entry points. + * + * Provides metadata consumed by the dynamic catalog builder and the on-device reviewer mode. + */ +@Target(AnnotationTarget.CLASS) +@Retention(AnnotationRetention.RUNTIME) +annotation class Sample( + val id: String, + val title: String, + val description: String, + val category: String, + val complexity: Complexity = Complexity.SIMPLE, + val tags: Array = [], + val apiCalls: Array = [], + val purpose: String = "", + val successCriteria: String = "", + val failureIndicators: String = "", + val helpHtml: String = "", + val framework: Framework = Framework.KOTLIN_VIEWS +) + +/** + * Complexity classification for samples. + */ +enum class Complexity(val displayName: String, val badge: String, val order: Int) : Serializable { + SNIPPET("Snippet", "πŸ”Ή", 1), + SIMPLE("Simple", "🟒", 2), + ADVANCED("Advanced", "πŸ”΄", 3); + + companion object { + fun fromString(value: String?): Complexity { + return entries.find { it.name.equals(value, ignoreCase = true) } ?: SIMPLE + } + } +} + +/** + * Supported development frameworks in this repository. + */ +enum class Framework( + val id: String, + val displayName: String, + val badge: String, + val iconEmoji: String, + val accentColorHex: Long +) : Serializable { + KOTLIN_VIEWS( + id = "kotlin", + displayName = "Kotlin Views", + badge = "Kotlin", + iconEmoji = "πŸ’œ", + accentColorHex = 0xFF7F52FF + ), + JAVA_VIEWS( + id = "java", + displayName = "Java Views", + badge = "Java", + iconEmoji = "β˜•", + accentColorHex = 0xFFE76F51 + ); + + companion object { + fun fromId(id: String?): Framework { + return entries.find { it.id.equals(id, ignoreCase = true) } ?: KOTLIN_VIEWS + } + } +} + +/** + * Manual review evaluation status for a sample. + */ +enum class ReviewStatus( + val displayName: String, + val badge: String, + val iconEmoji: String, + val colorHex: Long +) : Serializable { + UNCHECKED("Unchecked", "UNCHECKED", "βšͺ", 0xFF9E9E9E), + NEEDS_WORK("Needs Work", "NEEDS_WORK", "πŸ”΄", 0xFFF44336), + PASSING("Passing", "PASSING", "🟒", 0xFF4CAF50); + + companion object { + fun fromString(value: String?): ReviewStatus { + return entries.find { it.name.equals(value, ignoreCase = true) } ?: UNCHECKED + } + } +} + +/** + * Immutable domain model representing a sample entry across Kotlin and Java frameworks. + */ +data class SampleItem( + val id: String, + val title: String, + val description: String, + val category: String, + val complexity: Complexity = Complexity.SIMPLE, + val tags: List = emptyList(), + val apiCalls: List = emptyList(), + val purpose: String = "", + val successCriteria: String = "", + val failureIndicators: String = "", + val helpHtml: String = "", + val kotlinActivity: String? = null, + val javaActivity: String? = null +) : Serializable { + + /** + * Builds an HTML formatted help box for reviewer and developer guidance. + * When [isReviewerMode] is false (Developer/Learner mode), evaluation criteria + * (Success Criteria and Failure Indicators) are omitted to keep the UI clean. + */ + fun getFormattedHelpHtml(isReviewerMode: Boolean = true): String { + if (helpHtml.isNotBlank()) { + return helpHtml + } + val builder = StringBuilder() + builder.append("

${title}

") + builder.append("

${description}

") + builder.append("
") + + if (purpose.isNotBlank()) { + builder.append("

🎯 Purpose:
${purpose}

") + } + if (isReviewerMode) { + if (successCriteria.isNotBlank()) { + builder.append("

βœ… Success Criteria:
${successCriteria}

") + } + if (failureIndicators.isNotBlank()) { + builder.append("

⚠️ Failure / Broken Indicators:
${failureIndicators}

") + } + } + + if (tags.isNotEmpty()) { + builder.append("

🏷️ Tags: ") + builder.append(tags.joinToString(" ")) + builder.append("

") + } + return builder.toString() + } + + /** + * Resolves the activity class name for a given target framework. + */ + fun getActivityForFramework(framework: Framework): String? { + return when (framework) { + Framework.KOTLIN_VIEWS -> kotlinActivity ?: javaActivity + Framework.JAVA_VIEWS -> javaActivity ?: kotlinActivity + } + } + + /** + * Returns the Fully Qualified Class Name (FQCN) identifier for the given framework. + */ + fun getTargetFqcn(framework: Framework): String { + return getActivityForFramework(framework) ?: id + } +} diff --git a/ApiDemos/project/common-ui/src/main/java/com/example/common_ui/catalog/compose/CatalogActivity.kt b/ApiDemos/project/common-ui/src/main/java/com/example/common_ui/catalog/compose/CatalogActivity.kt new file mode 100644 index 000000000..c2b16e09d --- /dev/null +++ b/ApiDemos/project/common-ui/src/main/java/com/example/common_ui/catalog/compose/CatalogActivity.kt @@ -0,0 +1,72 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.common_ui.catalog.compose + +import android.content.Intent +import android.os.Bundle +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import com.example.common_ui.catalog.Framework +import com.example.common_ui.catalog.SampleItem + +/** + * Clean, modern Jetpack Compose Catalog application for end-user developers. + * + * Provides multi-framework browsing, instant search, complexity filters, and sample expectation guides. + */ +open class CatalogActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + setContent { + CatalogTheme { + CatalogScreen( + isReviewerMode = false, + onLaunchSample = { sample, framework -> + launchSample(sample, framework) + }, + onSwitchMode = { + val intent = Intent().setClassName(packageName, "com.example.common_ui.catalog.compose.ReviewerActivity") + startActivity(intent) + } + ) + } + } + } + + protected fun launchSample(sample: SampleItem, framework: Framework) { + val className = sample.getActivityForFramework(framework) + if (className.isNullOrBlank()) { + Toast.makeText(this, "No ${framework.displayName} implementation available for ${sample.title}", Toast.LENGTH_SHORT).show() + return + } + + try { + val intent = Intent().setClassName(packageName, className).apply { + putExtra("extra_sample_id", sample.id) + putExtra("extra_is_reviewer_mode", false) + } + startActivity(intent) + } catch (e: Exception) { + Toast.makeText(this, "Could not launch ${sample.title}: ${e.message}", Toast.LENGTH_LONG).show() + } + } +} diff --git a/ApiDemos/project/common-ui/src/main/java/com/example/common_ui/catalog/compose/CatalogScreen.kt b/ApiDemos/project/common-ui/src/main/java/com/example/common_ui/catalog/compose/CatalogScreen.kt new file mode 100644 index 000000000..907ccfb00 --- /dev/null +++ b/ApiDemos/project/common-ui/src/main/java/com/example/common_ui/catalog/compose/CatalogScreen.kt @@ -0,0 +1,1038 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.common_ui.catalog.compose + +import android.text.Html +import android.widget.TextView +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Assessment +import androidx.compose.material.icons.filled.CheckCircleOutline +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.FastForward +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.RadioButtonUnchecked +import androidx.compose.material.icons.filled.RestartAlt +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Badge +import androidx.compose.material3.BadgedBox +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.PrimaryTabRow +import androidx.compose.material3.Scaffold +import androidx.compose.material3.ScrollableTabRow +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SuggestionChip +import androidx.compose.material3.Surface +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.graphics.asImageBitmap +import android.graphics.BitmapFactory +import androidx.compose.foundation.Image +import androidx.compose.ui.draw.clip +import androidx.compose.foundation.border +import java.io.File +import androidx.compose.ui.viewinterop.AndroidView +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.example.common_ui.catalog.Complexity +import com.example.common_ui.catalog.Framework +import com.example.common_ui.catalog.ReviewStatus +import com.example.common_ui.catalog.SampleCatalogRegistry +import com.example.common_ui.catalog.SampleEvaluation +import com.example.common_ui.catalog.SampleItem +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CatalogScreen( + isReviewerMode: Boolean = false, + evaluations: Map = emptyMap(), + onSaveEvaluation: ((targetFqcn: String, status: ReviewStatus, notes: String, sample: SampleItem) -> Unit)? = null, + onLaunchSample: (SampleItem, Framework) -> Unit, + onExportGrievances: (() -> Unit)? = null, + onClearEvaluations: (() -> Unit)? = null, + onSwitchMode: (() -> Unit)? = null +) { + var selectedFramework by rememberSaveable { mutableStateOf(Framework.KOTLIN_VIEWS) } + var selectedComplexity by rememberSaveable { mutableStateOf(null) } + var selectedStatusFilter by rememberSaveable { mutableStateOf(null) } + var selectedTags by rememberSaveable { mutableStateOf(emptySet()) } + var searchQuery by rememberSaveable { mutableStateOf("") } + var activeSampleDetailId by rememberSaveable { mutableStateOf(null) } + val activeSampleForDetail: SampleItem? = remember(activeSampleDetailId) { + SampleCatalogRegistry.findById(activeSampleDetailId) + } + var activeQuickGradingSampleId by rememberSaveable { mutableStateOf(null) } + var activeQuickGradingStatus by rememberSaveable { mutableStateOf(null) } + val activeQuickGrading: Pair? = remember(activeQuickGradingSampleId, activeQuickGradingStatus) { + val sId = activeQuickGradingSampleId + val st = activeQuickGradingStatus + val sample = SampleCatalogRegistry.findById(sId) + if (sample != null && st != null) Pair(sample, st) else null + } + var showClearConfirmDialog by rememberSaveable { mutableStateOf(false) } + var showMoreMenu by remember { mutableStateOf(false) } + + val lazyListState = rememberLazyListState() + val coroutineScope = rememberCoroutineScope() + + val frameworkSamples = remember(selectedFramework) { + SampleCatalogRegistry.filter(framework = selectedFramework) + } + + // Dynamic review status counts for active framework + val statusCounts = remember(selectedFramework, evaluations, frameworkSamples) { + var unchecked = 0 + var passing = 0 + var needsWork = 0 + for (s in frameworkSamples) { + val targetFqcn = s.getTargetFqcn(selectedFramework) + val eval = evaluations[targetFqcn] ?: evaluations[s.id] + when (ReviewStatus.fromString(eval?.status)) { + ReviewStatus.UNCHECKED -> unchecked++ + ReviewStatus.PASSING -> passing++ + ReviewStatus.NEEDS_WORK -> needsWork++ + } + } + Triple(unchecked, passing, needsWork) + } + val (uncheckedCount, passingCount, needsWorkCount) = statusCounts + + val filteredSamples = remember( + selectedFramework, + selectedComplexity, + selectedStatusFilter, + selectedTags, + searchQuery, + evaluations + ) { + SampleCatalogRegistry.filter( + framework = selectedFramework, + complexity = selectedComplexity, + selectedTags = selectedTags, + searchQuery = searchQuery + ).filter { sample -> + if (selectedStatusFilter == null) true + else { + val targetFqcn = sample.getTargetFqcn(selectedFramework) + val eval = evaluations[targetFqcn] ?: evaluations[sample.id] + val status = ReviewStatus.fromString(eval?.status) + status == selectedStatusFilter + } + } + } + + val grievancesCount = remember(evaluations) { + evaluations.values.count { it.status == "NEEDS_WORK" || it.notes.isNotBlank() } + } + val snackbarHostState = remember { SnackbarHostState() } + + Scaffold( + topBar = { + TopAppBar( + title = { + Column { + Text( + text = if (isReviewerMode) "GMP Sample Reviewer" else "Google Maps Platform Samples", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + Text( + text = if (isReviewerMode) { + val filterSummary = if (selectedStatusFilter == ReviewStatus.UNCHECKED) " β€’ βšͺ Unchecked Only" else "" + "${selectedFramework.displayName} β€’ ${filteredSamples.size} samples$filterSummary" + } else { + "Unified Multi-Framework Catalog β€’ ${filteredSamples.size} samples" + }, + style = MaterialTheme.typography.labelSmall, + color = if (isReviewerMode) Color(0xFFD93025) else MaterialTheme.colorScheme.onSurfaceVariant + ) + } + }, + actions = { + // Quick toggle button for Unchecked Only in Reviewer Mode + if (isReviewerMode) { + IconButton( + onClick = { + selectedStatusFilter = if (selectedStatusFilter == ReviewStatus.UNCHECKED) null else ReviewStatus.UNCHECKED + } + ) { + BadgedBox( + badge = { + if (uncheckedCount > 0) { + Badge { Text("$uncheckedCount") } + } + } + ) { + Icon( + imageVector = if (selectedStatusFilter == ReviewStatus.UNCHECKED) Icons.Default.CheckCircleOutline else Icons.Default.RadioButtonUnchecked, + contentDescription = "Filter Unchecked Only", + tint = if (selectedStatusFilter == ReviewStatus.UNCHECKED) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + // Next Unchecked Action Button + if (uncheckedCount > 0) { + IconButton(onClick = { + val nextUnchecked = filteredSamples.firstOrNull { sample -> + val eval = evaluations[sample.getTargetFqcn(selectedFramework)] + eval == null || eval.status == ReviewStatus.UNCHECKED.name + } ?: frameworkSamples.firstOrNull { sample -> + val eval = evaluations[sample.getTargetFqcn(selectedFramework)] + eval == null || eval.status == ReviewStatus.UNCHECKED.name + } + if (nextUnchecked != null) { + onLaunchSample(nextUnchecked, selectedFramework) + } + }) { + Icon( + imageVector = Icons.Default.FastForward, + contentDescription = "Launch Next Unchecked Sample", + tint = MaterialTheme.colorScheme.primary + ) + } + } + + // Direct Reset / Clear Reviews Button (Always Available) + if (onClearEvaluations != null) { + IconButton(onClick = { showClearConfirmDialog = true }) { + Icon( + imageVector = Icons.Default.RestartAlt, + contentDescription = "Reset All Evaluations", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + // Jump to Search & Filters Button + IconButton(onClick = { + coroutineScope.launch { + lazyListState.animateScrollToItem(0) + } + }) { + Icon( + imageVector = Icons.Default.Search, + contentDescription = "Jump to Search & Filters", + tint = MaterialTheme.colorScheme.primary + ) + } + + // More Options Overflow Menu + Box { + IconButton(onClick = { showMoreMenu = true }) { + Icon(Icons.Default.MoreVert, contentDescription = "More Options") + } + + DropdownMenu( + expanded = showMoreMenu, + onDismissRequest = { showMoreMenu = false } + ) { + if (isReviewerMode) { + DropdownMenuItem( + text = { Text("πŸ”„ Reset All Evaluations", color = MaterialTheme.colorScheme.error) }, + onClick = { + showMoreMenu = false + showClearConfirmDialog = true + } + ) + DropdownMenuItem( + text = { Text("πŸ“Š Generate Evaluation Report") }, + onClick = { + showMoreMenu = false + onExportGrievances?.invoke() + } + ) + DropdownMenuItem( + text = { + Text(if (selectedStatusFilter == ReviewStatus.UNCHECKED) "Show All Samples" else "βšͺ Show Unchecked Only") + }, + onClick = { + showMoreMenu = false + selectedStatusFilter = if (selectedStatusFilter == ReviewStatus.UNCHECKED) null else ReviewStatus.UNCHECKED + } + ) + HorizontalDivider() + } + if (onSwitchMode != null) { + DropdownMenuItem( + text = { + Text(if (isReviewerMode) "πŸ“± Switch to Developer Mode" else "πŸ› οΈ Switch to Reviewer Mode") + }, + onClick = { + showMoreMenu = false + onSwitchMode() + } + ) + HorizontalDivider() + } + DropdownMenuItem( + text = { Text("Scroll to Top") }, + onClick = { + showMoreMenu = false + coroutineScope.launch { lazyListState.animateScrollToItem(0) } + } + ) + } + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + floatingActionButton = { + if (isReviewerMode) { + Row( + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (uncheckedCount > 0) { + ExtendedFloatingActionButton( + onClick = { + val nextUnchecked = filteredSamples.firstOrNull { sample -> + val eval = evaluations[sample.getTargetFqcn(selectedFramework)] + eval == null || eval.status == ReviewStatus.UNCHECKED.name + } ?: frameworkSamples.firstOrNull { sample -> + val eval = evaluations[sample.getTargetFqcn(selectedFramework)] + eval == null || eval.status == ReviewStatus.UNCHECKED.name + } + if (nextUnchecked != null) { + onLaunchSample(nextUnchecked, selectedFramework) + } + }, + icon = { Icon(Icons.Default.PlayArrow, contentDescription = null) }, + text = { Text("Review Next ($uncheckedCount)") }, + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary + ) + } + + if (onExportGrievances != null) { + FloatingActionButton( + onClick = onExportGrievances, + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer + ) { + BadgedBox( + badge = { + if (grievancesCount > 0) { + Badge { Text("$grievancesCount") } + } + } + ) { + Icon( + imageVector = Icons.Default.Assessment, + contentDescription = "Generate Report" + ) + } + } + } + } + } + } + ) { paddingValues -> + LazyColumn( + state = lazyListState, + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentPadding = PaddingValues(bottom = 80.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + // Header Item 1: Framework Tabs + item(key = "header_framework_tabs") { + Column( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface) + .padding(bottom = 6.dp) + ) { + PrimaryTabRow( + selectedTabIndex = when (selectedFramework) { + Framework.KOTLIN_VIEWS -> 0 + Framework.JAVA_VIEWS -> 1 + } + ) { + Tab( + selected = selectedFramework == Framework.KOTLIN_VIEWS, + onClick = { selectedFramework = Framework.KOTLIN_VIEWS }, + text = { Text("πŸ’œ Kotlin Views", fontWeight = FontWeight.Bold) } + ) + Tab( + selected = selectedFramework == Framework.JAVA_VIEWS, + onClick = { selectedFramework = Framework.JAVA_VIEWS }, + text = { Text("β˜• Java Views", fontWeight = FontWeight.Bold) } + ) + } + + // Search Bar + OutlinedTextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 6.dp), + placeholder = { Text("Search samples, tags, or categories...") }, + leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, + trailingIcon = { + if (searchQuery.isNotEmpty()) { + IconButton(onClick = { searchQuery = "" }) { + Icon(Icons.Default.Clear, contentDescription = "Clear") + } + } + }, + singleLine = true, + shape = RoundedCornerShape(12.dp), + colors = OutlinedTextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) + ) + ) + + // Review Status Filter Chips (Reviewer Mode Only) + if (isReviewerMode) { + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 12.dp, vertical = 2.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + FilterChip( + selected = selectedStatusFilter == null, + onClick = { selectedStatusFilter = null }, + label = { Text("All Status") } + ) + FilterChip( + selected = selectedStatusFilter == ReviewStatus.UNCHECKED, + onClick = { + selectedStatusFilter = if (selectedStatusFilter == ReviewStatus.UNCHECKED) null else ReviewStatus.UNCHECKED + }, + label = { + Text( + "βšͺ Unchecked ($uncheckedCount)", + fontWeight = if (selectedStatusFilter == ReviewStatus.UNCHECKED) FontWeight.Bold else FontWeight.Normal + ) + } + ) + FilterChip( + selected = selectedStatusFilter == ReviewStatus.NEEDS_WORK, + onClick = { + selectedStatusFilter = if (selectedStatusFilter == ReviewStatus.NEEDS_WORK) null else ReviewStatus.NEEDS_WORK + }, + label = { Text("πŸ”΄ Needs Work ($needsWorkCount)") } + ) + FilterChip( + selected = selectedStatusFilter == ReviewStatus.PASSING, + onClick = { + selectedStatusFilter = if (selectedStatusFilter == ReviewStatus.PASSING) null else ReviewStatus.PASSING + }, + label = { Text("🟒 Passing ($passingCount)") } + ) + } + } + + // Complexity Filter Chips + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 12.dp, vertical = 2.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + FilterChip( + selected = selectedComplexity == null, + onClick = { selectedComplexity = null }, + label = { Text("All Complexity") } + ) + FilterChip( + selected = selectedComplexity == Complexity.SNIPPET, + onClick = { selectedComplexity = if (selectedComplexity == Complexity.SNIPPET) null else Complexity.SNIPPET }, + label = { Text("πŸ”Ή Snippet") } + ) + FilterChip( + selected = selectedComplexity == Complexity.SIMPLE, + onClick = { selectedComplexity = if (selectedComplexity == Complexity.SIMPLE) null else Complexity.SIMPLE }, + label = { Text("🟒 Simple") } + ) + FilterChip( + selected = selectedComplexity == Complexity.ADVANCED, + onClick = { selectedComplexity = if (selectedComplexity == Complexity.ADVANCED) null else Complexity.ADVANCED }, + label = { Text("πŸ”΄ Advanced") } + ) + } + + // Dynamic Hashtags Row + val allTags = remember { SampleCatalogRegistry.getAllTags() } + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 12.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + allTags.forEach { tag -> + val isSelected = selectedTags.contains(tag) + FilterChip( + selected = isSelected, + onClick = { + selectedTags = if (isSelected) selectedTags - tag else selectedTags + tag + }, + label = { Text(tag, fontSize = 12.sp) } + ) + } + } + } + } + + // Empty State Handling + if (filteredSamples.isEmpty()) { + item(key = "empty_samples_state") { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 48.dp), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = if (selectedStatusFilter == ReviewStatus.UNCHECKED) "πŸŽ‰ All samples in this framework have been evaluated!" else "No matching samples found.", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + if (selectedStatusFilter != null) { + Spacer(modifier = Modifier.height(8.dp)) + TextButton(onClick = { selectedStatusFilter = null }) { + Text("Clear Status Filter") + } + } + } + } + } + } else { + // Sample Cards + items(filteredSamples, key = { it.id }) { sample -> + val targetFqcn = sample.getTargetFqcn(selectedFramework) + val eval = evaluations[targetFqcn] ?: evaluations[sample.id] + val status = ReviewStatus.fromString(eval?.status) + Box(modifier = Modifier.padding(horizontal = 12.dp)) { + SampleComposeCard( + sample = sample, + targetFqcn = targetFqcn, + framework = selectedFramework, + isReviewerMode = isReviewerMode, + evaluation = eval, + status = status, + onSampleClick = { onLaunchSample(sample, selectedFramework) }, + onInfoClick = { activeSampleDetailId = sample.id }, + onQuickGrade = { gradeStatus -> + activeQuickGradingSampleId = sample.id + activeQuickGradingStatus = gradeStatus + } + ) + } + } + } + } + } + + // Confirmation Dialog for Clearing / Resetting All Evaluations + if (showClearConfirmDialog) { + AlertDialog( + onDismissRequest = { showClearConfirmDialog = false }, + icon = { Icon(Icons.Default.RestartAlt, contentDescription = null, tint = MaterialTheme.colorScheme.error) }, + title = { Text("Reset All Review Evaluations?") }, + text = { + Text("This will reset all ratings, status marks, and reviewer notes across all Kotlin and Java samples back to Unchecked.") + }, + confirmButton = { + Button( + onClick = { + onClearEvaluations?.invoke() + showClearConfirmDialog = false + }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError + ) + ) { + Text("Reset All") + } + }, + dismissButton = { + TextButton(onClick = { showClearConfirmDialog = false }) { + Text("Cancel") + } + } + ) + } + + // Full-Screen Sample Detail & Code Viewer Dialog + activeSampleForDetail?.let { sample -> + val targetFqcn = sample.getTargetFqcn(selectedFramework) + val existingEval = evaluations[targetFqcn] ?: evaluations[sample.id] + SampleDetailFullScreenDialog( + sample = sample, + targetFqcn = targetFqcn, + framework = selectedFramework, + isReviewerMode = isReviewerMode, + existingEvaluation = existingEval, + onDismiss = { activeSampleDetailId = null }, + onSaveEvaluation = { status, notes -> + onSaveEvaluation?.invoke(targetFqcn, status, notes, sample) + activeSampleDetailId = null + }, + onLaunch = { fw -> + activeSampleDetailId = null + onLaunchSample(sample, fw) + } + ) + } + + // Quick Grading Dialog from List Card (Allows adding notes before saving) + activeQuickGrading?.let { (sample, gradeStatus) -> + val targetFqcn = sample.getTargetFqcn(selectedFramework) + val existingEval = evaluations[targetFqcn] ?: evaluations[sample.id] + var notes by rememberSaveable { mutableStateOf(existingEval?.notes.orEmpty()) } + + AlertDialog( + onDismissRequest = { activeQuickGradingSampleId = null; activeQuickGradingStatus = null }, + title = { + Text( + text = if (gradeStatus == ReviewStatus.PASSING) "πŸ‘ Good Job: ${sample.title}" else "⚠️ Something's Wrong: ${sample.title}", + fontWeight = FontWeight.Bold, + fontSize = 17.sp + ) + }, + text = { + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = "Target: ${targetFqcn.substringAfterLast('.')}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.outline + ) + Spacer(modifier = Modifier.height(10.dp)) + OutlinedTextField( + value = notes, + onValueChange = { notes = it }, + label = { Text("Notes (optional for pass, describe issues if broken)") }, + modifier = Modifier.fillMaxWidth(), + minLines = 3, + maxLines = 5, + shape = RoundedCornerShape(10.dp) + ) + } + }, + confirmButton = { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton( + onClick = { + onSaveEvaluation?.invoke(targetFqcn, gradeStatus, notes, sample) + activeQuickGradingSampleId = null + activeQuickGradingStatus = null + } + ) { + Text(if (gradeStatus == ReviewStatus.PASSING) "Save Pass πŸ‘" else "Save Issue ⚠️") + } + Button( + onClick = { + onSaveEvaluation?.invoke(targetFqcn, gradeStatus, notes, sample) + activeQuickGradingSampleId = null + activeQuickGradingStatus = null + val allFw = SampleCatalogRegistry.filter(framework = selectedFramework) + val currIdx = allFw.indexOfFirst { it.id == sample.id } + val nextUnchecked = if (currIdx >= 0) { + (allFw.drop(currIdx + 1) + allFw.take(currIdx)).firstOrNull { s -> + val fqcn = s.getTargetFqcn(selectedFramework) + val ev = evaluations[fqcn] ?: evaluations[s.id] + ReviewStatus.fromString(ev?.status) == ReviewStatus.UNCHECKED + } + } else null + if (nextUnchecked != null) { + onLaunchSample(nextUnchecked, selectedFramework) + } + }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary + ) + ) { + Text("Save & Next ⏭️", fontWeight = FontWeight.Bold) + } + } + }, + dismissButton = { + TextButton(onClick = { + activeQuickGradingSampleId = null + activeQuickGradingStatus = null + }) { + Text("Cancel") + } + } + ) + } +} + +@Composable +fun SampleComposeCard( + sample: SampleItem, + targetFqcn: String, + framework: Framework, + isReviewerMode: Boolean, + evaluation: SampleEvaluation?, + status: ReviewStatus, + onSampleClick: () -> Unit, + onInfoClick: () -> Unit, + onQuickGrade: (ReviewStatus) -> Unit +) { + val hasActivity = sample.getActivityForFramework(framework) != null + val isReviewed = isReviewerMode && (status == ReviewStatus.PASSING || status == ReviewStatus.NEEDS_WORK) + var isExpandedManually by remember(sample.id, status) { mutableStateOf(null) } + val isCardExpanded = isExpandedManually ?: (!isReviewed) + + val (statusText, statusBg, statusFg) = when (status) { + ReviewStatus.PASSING -> Triple("🟒 Pass", Color(0xFFE8F5E9), Color(0xFF2E7D32)) + ReviewStatus.NEEDS_WORK -> Triple("πŸ”΄ Needs Work", Color(0xFFFFEBEE), Color(0xFFC62828)) + ReviewStatus.UNCHECKED -> Triple("βšͺ Unchecked", Color(0xFFEEEEEE), Color(0xFF616161)) + } + + ElevatedCard( + modifier = Modifier + .fillMaxWidth() + .clickable { isExpandedManually = !isCardExpanded }, + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.surface + ), + elevation = CardDefaults.elevatedCardElevation(defaultElevation = if (isCardExpanded) 2.dp else 1.dp) + ) { + if (!isCardExpanded) { + // === CLEAN COMPACT COLLAPSED ROW (Title + Status + Expand Arrow) === + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Text( + text = sample.title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1 + ) + + Surface( + shape = RoundedCornerShape(8.dp), + color = statusBg + ) { + Text( + text = statusText, + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + color = statusFg, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp) + ) + } + + if (!evaluation?.notes.isNullOrBlank()) { + Text( + text = "πŸ“", + fontSize = 12.sp + ) + } + } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + if (hasActivity) { + IconButton( + onClick = onSampleClick, + modifier = Modifier.size(32.dp) + ) { + Icon( + Icons.Default.PlayArrow, + contentDescription = "Launch Sample", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + } + } + + Icon( + imageVector = Icons.Default.ExpandMore, + contentDescription = "Expand", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(24.dp) + ) + } + } + } else { + // === FULL DETAILED EXPANDED CARD === + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + // Top Header: Category, Complexity Chip, and Collapse Chevron + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = sample.category, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold + ) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + SuggestionChip( + onClick = {}, + label = { Text("${sample.complexity.badge} ${sample.complexity.displayName}", fontSize = 11.sp) } + ) + + IconButton( + onClick = { isExpandedManually = false }, + modifier = Modifier.size(28.dp) + ) { + Icon( + Icons.Default.ExpandLess, + contentDescription = "Collapse", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + // Title + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = sample.title, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + + // FQCN Target Identifier + Text( + text = targetFqcn.substringAfterLast('.'), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.outline + ) + + // Description + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = sample.description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + // Review Status Badge & Notes (Reviewer Mode Only) + if (isReviewerMode) { + Spacer(modifier = Modifier.height(10.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Surface( + shape = RoundedCornerShape(8.dp), + color = statusBg + ) { + Text( + text = statusText, + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + color = statusFg, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp) + ) + } + + if (!evaluation?.notes.isNullOrBlank()) { + Text( + text = "πŸ“ ${evaluation.notes}", + style = MaterialTheme.typography.bodySmall, + color = Color(0xFFE65100), + maxLines = 1, + modifier = Modifier.weight(1f) + ) + } + } + + // In-Card Quick Grading Buttons + Spacer(modifier = Modifier.height(10.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + FilledTonalButton( + onClick = { onQuickGrade(ReviewStatus.PASSING) }, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(10.dp), + colors = ButtonDefaults.filledTonalButtonColors( + containerColor = Color(0xFFE8F5E9), + contentColor = Color(0xFF2E7D32) + ), + contentPadding = PaddingValues(vertical = 6.dp) + ) { + Text("πŸ‘ Good Job", fontSize = 12.sp, fontWeight = FontWeight.Bold) + } + + FilledTonalButton( + onClick = { onQuickGrade(ReviewStatus.NEEDS_WORK) }, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(10.dp), + colors = ButtonDefaults.filledTonalButtonColors( + containerColor = Color(0xFFFFEBEE), + contentColor = Color(0xFFC62828) + ), + contentPadding = PaddingValues(vertical = 6.dp) + ) { + Text("⚠️ Issue", fontSize = 12.sp, fontWeight = FontWeight.Bold) + } + } + } + + // Hashtags + if (sample.tags.isNotEmpty()) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = sample.tags.joinToString(" "), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary + ) + } + + // Action Row + Spacer(modifier = Modifier.height(12.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + OutlinedButton( + onClick = onInfoClick, + shape = RoundedCornerShape(10.dp), + modifier = Modifier.weight(1f) + ) { + Icon( + Icons.Outlined.Info, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text("About & APIs", fontSize = 12.sp) + } + + Button( + onClick = onSampleClick, + enabled = hasActivity, + shape = RoundedCornerShape(10.dp), + modifier = Modifier.weight(1f) + ) { + Text( + if (hasActivity) "Launch Sample" else "No ${framework.badge} Impl", + fontSize = 12.sp + ) + } + } + } + } + } +} + diff --git a/ApiDemos/project/common-ui/src/main/java/com/example/common_ui/catalog/compose/CatalogTheme.kt b/ApiDemos/project/common-ui/src/main/java/com/example/common_ui/catalog/compose/CatalogTheme.kt new file mode 100644 index 000000000..3f001fa08 --- /dev/null +++ b/ApiDemos/project/common-ui/src/main/java/com/example/common_ui/catalog/compose/CatalogTheme.kt @@ -0,0 +1,70 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.common_ui.catalog.compose + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +private val LightColors = lightColorScheme( + primary = Color(0xFF1A73E8), + onPrimary = Color.White, + primaryContainer = Color(0xFFD2E3FC), + onPrimaryContainer = Color(0xFF041E49), + secondary = Color(0xFF5F6368), + onSecondary = Color.White, + secondaryContainer = Color(0xFFE8EAED), + onSecondaryContainer = Color(0xFF202124), + surface = Color(0xFFFFFFFF), + onSurface = Color(0xFF202124), + surfaceVariant = Color(0xFFF1F3F4), + onSurfaceVariant = Color(0xFF5F6368), + outline = Color(0xFFDADCE0), + outlineVariant = Color(0xFFE8EAED) +) + +private val DarkColors = darkColorScheme( + primary = Color(0xFF8AB4F8), + onPrimary = Color(0xFF041E49), + primaryContainer = Color(0xFF174EA6), + onPrimaryContainer = Color(0xFFD2E3FC), + secondary = Color(0xFFBDC1C6), + onSecondary = Color(0xFF202124), + secondaryContainer = Color(0xFF3C4043), + onSecondaryContainer = Color(0xFFE8EAED), + surface = Color(0xFF202124), + onSurface = Color(0xFFE8EAED), + surfaceVariant = Color(0xFF303134), + onSurfaceVariant = Color(0xFFBDC1C6), + outline = Color(0xFF5F6368), + outlineVariant = Color(0xFF3C4043) +) + +@Composable +fun CatalogTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit +) { + val colors = if (darkTheme) DarkColors else LightColors + MaterialTheme( + colorScheme = colors, + content = content + ) +} diff --git a/ApiDemos/project/common-ui/src/main/java/com/example/common_ui/catalog/compose/CodeHighlighter.kt b/ApiDemos/project/common-ui/src/main/java/com/example/common_ui/catalog/compose/CodeHighlighter.kt new file mode 100644 index 000000000..3f045256f --- /dev/null +++ b/ApiDemos/project/common-ui/src/main/java/com/example/common_ui/catalog/compose/CodeHighlighter.kt @@ -0,0 +1,142 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.common_ui.catalog.compose + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import java.util.regex.Pattern + +/** + * Lightweight, pure-Compose syntax highlighter for Kotlin and Java source code. + * + * Converts raw source code into an [AnnotatedString] styled with theme-aware syntax tokens. + */ +object CodeHighlighter { + + // Kotlin & Java Keywords + private val KEYWORDS = setOf( + "abstract", "actual", "annotation", "as", "break", "by", "byte", "case", "catch", + "char", "class", "companion", "const", "constructor", "continue", "crossinline", + "data", "default", "delegate", "do", "double", "dynamic", "else", "enum", "expect", + "extends", "external", "false", "field", "file", "final", "finally", "float", "for", + "fun", "get", "if", "implements", "import", "in", "infix", "init", "inline", + "inner", "instanceof", "int", "interface", "internal", "is", "it", "lateinit", + "long", "native", "new", "noinline", "null", "object", "open", "operator", "out", + "override", "package", "param", "private", "property", "protected", "public", + "reified", "return", "sealed", "set", "short", "static", "strictfp", "super", + "suspend", "switch", "synchronized", "tailrec", "this", "throw", "throws", + "transient", "true", "try", "typealias", "typeof", "val", "value", "var", + "vararg", "void", "volatile", "when", "where", "while", "yield" + ) + + private val COMMENT_REGEX = Pattern.compile("(//.*?$|/\\*.*?\\*/)", Pattern.MULTILINE or Pattern.DOTALL) + private val STRING_REGEX = Pattern.compile("(\"(\\\\.|[^\"\\\\])*\"|'(\\\\.|[^'\\\\])*')", Pattern.MULTILINE) + private val ANNOTATION_REGEX = Pattern.compile("@[A-Za-z0-9_]+") + private val NUMBER_REGEX = Pattern.compile("\\b(\\d+(\\.\\d+)?[fFL]?|0x[0-9a-fA-F]+)\\b") + private val WORD_REGEX = Pattern.compile("\\b[A-Za-z_][A-Za-z0-9_]*\\b") + + /** + * Highlights code and returns an [AnnotatedString]. + */ + fun highlight(code: String, isDark: Boolean = true): AnnotatedString { + val keywordColor = if (isDark) Color(0xFFFF79C6) else Color(0xFF9C27B0) + val annotationColor = if (isDark) Color(0xFFFFB86C) else Color(0xFFEF6C00) + val stringColor = if (isDark) Color(0xFF50FA7B) else Color(0xFF2E7D32) + val commentColor = if (isDark) Color(0xFF6272A4) else Color(0xFF757575) + val numberColor = if (isDark) Color(0xFFBD93F9) else Color(0xFF1565C0) + val typeColor = if (isDark) Color(0xFF8BE9FD) else Color(0xFF00838F) + val plainColor = if (isDark) Color(0xFFF8F8F2) else Color(0xFF212121) + + val fullText = code.trimIndent() + val textLength = fullText.length + + val stringBuilder = buildAnnotatedString { + append(fullText) + + // Base style + addStyle(SpanStyle(color = plainColor), 0, textLength) + + // 1. Types / Classes and Keywords + val wordMatcher = WORD_REGEX.matcher(fullText) + while (wordMatcher.find()) { + val start = wordMatcher.start() + val end = wordMatcher.end() + val word = fullText.substring(start, end) + + if (KEYWORDS.contains(word)) { + addStyle( + SpanStyle(color = keywordColor, fontWeight = FontWeight.Bold), + start, + end + ) + } else if (word.first().isUpperCase()) { + addStyle( + SpanStyle(color = typeColor, fontWeight = FontWeight.SemiBold), + start, + end + ) + } + } + + // 2. Numbers + val numberMatcher = NUMBER_REGEX.matcher(fullText) + while (numberMatcher.find()) { + addStyle( + SpanStyle(color = numberColor), + numberMatcher.start(), + numberMatcher.end() + ) + } + + // 3. Annotations + val annotationMatcher = ANNOTATION_REGEX.matcher(fullText) + while (annotationMatcher.find()) { + addStyle( + SpanStyle(color = annotationColor, fontWeight = FontWeight.Medium), + annotationMatcher.start(), + annotationMatcher.end() + ) + } + + // 4. Strings (overrides previous styles) + val stringMatcher = STRING_REGEX.matcher(fullText) + while (stringMatcher.find()) { + addStyle( + SpanStyle(color = stringColor), + stringMatcher.start(), + stringMatcher.end() + ) + } + + // 5. Comments (highest precedence) + val commentMatcher = COMMENT_REGEX.matcher(fullText) + while (commentMatcher.find()) { + addStyle( + SpanStyle(color = commentColor, fontStyle = FontStyle.Italic), + commentMatcher.start(), + commentMatcher.end() + ) + } + } + + return stringBuilder + } +} diff --git a/ApiDemos/project/common-ui/src/main/java/com/example/common_ui/catalog/compose/CodeSnippetView.kt b/ApiDemos/project/common-ui/src/main/java/com/example/common_ui/catalog/compose/CodeSnippetView.kt new file mode 100644 index 000000000..539bffce7 --- /dev/null +++ b/ApiDemos/project/common-ui/src/main/java/com/example/common_ui/catalog/compose/CodeSnippetView.kt @@ -0,0 +1,268 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.common_ui.catalog.compose + +import android.widget.Toast +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Code +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.example.common_ui.catalog.Framework +import com.example.common_ui.catalog.SampleItem + +import androidx.compose.runtime.saveable.rememberSaveable + +/** + * Collapsible, syntax-highlighted code viewer composable. + * + * Renders Kotlin and Java source snippets with line numbers, theme-adaptive coloring, and one-tap clipboard copy. + */ +@Composable +fun CodeSnippetView( + sample: SampleItem, + currentFramework: Framework = Framework.KOTLIN_VIEWS, + initiallyExpanded: Boolean = true, + isCollapsible: Boolean = true, + modifier: Modifier = Modifier +) { + var isExpanded by rememberSaveable { mutableStateOf(initiallyExpanded) } + var selectedFramework by rememberSaveable { mutableStateOf(currentFramework) } + val isDark = isSystemInDarkTheme() + val context = LocalContext.current + val clipboardManager = LocalClipboardManager.current + + val rawCode = remember(sample.id, selectedFramework) { + SampleCodeProvider.getCode(sample.id, selectedFramework) + } + + if (rawCode.isBlank()) { + return + } + + val regionTag = remember(sample.id) { + SampleCodeProvider.getRegionTag(sample.id) + } + + val highlightedCode = remember(rawCode, isDark) { + CodeHighlighter.highlight(rawCode, isDark = isDark) + } + + val codeLines = remember(rawCode) { + rawCode.lines() + } + + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = if (isDark) Color(0xFF181825) else Color(0xFFF1F3F4) + ), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Column(modifier = Modifier.fillMaxWidth()) { + // Header Bar + Row( + modifier = Modifier + .fillMaxWidth() + .then(if (isCollapsible) Modifier.clickable { isExpanded = !isExpanded } else Modifier) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = Icons.Default.Code, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(22.dp) + ) + Column { + Text( + text = "Source Code Snippet", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + if (regionTag != null) { + Text( + text = "[$regionTag]", + style = MaterialTheme.typography.labelSmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.primary + ) + } + } + } + + if (isCollapsible) { + IconButton(onClick = { isExpanded = !isExpanded }, modifier = Modifier.size(28.dp)) { + Icon( + imageVector = if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = if (isExpanded) "Collapse" else "Expand", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + // Expandable Content Body + AnimatedVisibility( + visible = isExpanded || !isCollapsible, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp) + .padding(bottom = 14.dp) + ) { + // Toolbar: Language Switcher and Copy Button + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + // Language Tab Selector + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilterChip( + selected = selectedFramework == Framework.KOTLIN_VIEWS, + onClick = { selectedFramework = Framework.KOTLIN_VIEWS }, + label = { Text("πŸ’œ Kotlin", fontSize = 12.sp, fontWeight = FontWeight.SemiBold) } + ) + FilterChip( + selected = selectedFramework == Framework.JAVA_VIEWS, + onClick = { selectedFramework = Framework.JAVA_VIEWS }, + label = { Text("β˜• Java", fontSize = 12.sp, fontWeight = FontWeight.SemiBold) } + ) + } + + // Copy Button + FilledTonalButton( + onClick = { + clipboardManager.setText(AnnotatedString(rawCode)) + Toast.makeText(context, "Code copied to clipboard!", Toast.LENGTH_SHORT).show() + }, + contentPadding = PaddingValues(horizontal = 10.dp, vertical = 4.dp), + shape = RoundedCornerShape(8.dp), + modifier = Modifier.height(32.dp) + ) { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = "Copy code", + modifier = Modifier.size(15.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text("Copy", fontSize = 12.sp) + } + } + + // Code Editor Box with Line Numbers & Monospace Font + Surface( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)), + color = if (isDark) Color(0xFF11111B) else Color(0xFFE8EAED) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp) + .horizontalScroll(rememberScrollState()) + ) { + // Line numbers gutter + Column( + modifier = Modifier.padding(start = 12.dp, end = 14.dp), + horizontalAlignment = Alignment.End + ) { + codeLines.indices.forEach { index -> + Text( + text = (index + 1).toString().padStart(2, '0'), + fontFamily = FontFamily.Monospace, + fontSize = 12.5.sp, + color = if (isDark) Color(0xFF6C7086) else Color(0xFF9AA0A6), + lineHeight = 19.sp + ) + } + } + + // Highlighted code text + Text( + text = highlightedCode, + fontFamily = FontFamily.Monospace, + fontSize = 12.5.sp, + lineHeight = 19.sp, + modifier = Modifier.padding(end = 20.dp) + ) + } + } + } + } + } + } +} diff --git a/ApiDemos/project/common-ui/src/main/java/com/example/common_ui/catalog/compose/SampleCodeProvider.kt b/ApiDemos/project/common-ui/src/main/java/com/example/common_ui/catalog/compose/SampleCodeProvider.kt new file mode 100644 index 000000000..0ab1c5ee6 --- /dev/null +++ b/ApiDemos/project/common-ui/src/main/java/com/example/common_ui/catalog/compose/SampleCodeProvider.kt @@ -0,0 +1,3959 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.common_ui.catalog.compose + +import com.example.common_ui.catalog.Framework + +/** + * Single Source of Truth Code Provider. + * + * STRICT RULE: Only quotes code surrounded with official region tags + * (// [START ] ... // [END ]). + * + * This guarantees complete consistency between the source code, samples, + * in-app catalog reviewer, and Google Maps Platform documentation. + * If a sample does not have official region tags, no snippet is quoted. + */ +object SampleCodeProvider { + + data class SnippetPair( + val regionTag: String, + val kotlinCode: String, + val javaCode: String + ) + + fun hasCode(sampleId: String): Boolean { + return findSnippet(sampleId) != null + } + + fun getRegionTag(sampleId: String): String? { + return findSnippet(sampleId)?.regionTag + } + + fun getCode(sampleId: String, framework: Framework): String { + val snippet = findSnippet(sampleId) ?: return "" + return when (framework) { + Framework.KOTLIN_VIEWS -> snippet.kotlinCode + Framework.JAVA_VIEWS -> snippet.javaCode + } + } + + private fun findSnippet(sampleId: String): SnippetPair? { + return SNIPPETS[sampleId] + ?: SNIPPETS.entries.firstOrNull { sampleId.endsWith(it.key.substringAfterLast('.')) }?.value + } + + private val SNIPPETS = mapOf( + "com.example.kotlindemos.BasicMapDemoActivity" to SnippetPair( + regionTag = "maps_android_sample_basic_map", + kotlinCode = """ +@Sample( + id = "basic_map", + title = "Basic Map", + description = "Fundamental map instantiation, lifecycle binding, and default camera centering.", + category = "Map Initialization", + complexity = Complexity.SNIPPET, + tags = ["#map", "#init", "#lifecycle", "#quickstart"], + purpose = "Demonstrates clean, minimal map instantiation using SupportMapFragment.", + successCriteria = "The map loads default vector tiles cleanly centered at the initial coordinates with working gestures.", + failureIndicators = "Grey tiles (missing API key or auth mismatch), crash on back navigation, or map failing to unpause.", + framework = Framework.KOTLIN_VIEWS +) +class BasicMapDemoActivity : SamplesBaseActivity(), OnMapReadyCallback { + + val SYDNEY = LatLng(-33.862, 151.21) + val ZOOM_LEVEL = 13f + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(com.example.common_ui.R.layout.basic_demo) + val mapFragment : SupportMapFragment? = + supportFragmentManager.findFragmentById(com.example.common_ui.R.id.map) as? SupportMapFragment + mapFragment?.getMapAsync(this) + } + + /** + * This is where we can add markers or lines, add listeners or move the camera. In this case, + * we just move the camera to Sydney and add a marker in Sydney. + */ + override fun onMapReady(googleMap: GoogleMap) { + with(googleMap) { + moveCamera(CameraUpdateFactory.newLatLngZoom(SYDNEY, ZOOM_LEVEL)) + addMarker(MarkerOptions().position(SYDNEY)) + } + } +} +""".trimIndent(), + javaCode = """ +@Sample( + id = "basic_map", + title = "Basic Map", + description = "Fundamental map instantiation, lifecycle binding, and default camera centering.", + category = "Map Initialization", + complexity = Complexity.SNIPPET, + tags = {"#map", "#init", "#lifecycle", "#quickstart"}, + purpose = "Demonstrates clean, minimal map instantiation using SupportMapFragment.", + successCriteria = "The map loads default vector tiles cleanly centered at the initial coordinates with working gestures.", + failureIndicators = "Grey tiles (missing API key or auth mismatch), crash on back navigation, or map failing to unpause.", + framework = Framework.JAVA_VIEWS +) +public class BasicMapDemoActivity extends SamplesBaseActivity implements OnMapReadyCallback { + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(com.example.common_ui.R.layout.basic_demo); + + SupportMapFragment mapFragment = + (SupportMapFragment) getSupportFragmentManager().findFragmentById(com.example.common_ui.R.id.map); + mapFragment.getMapAsync(this); + applyInsets(findViewById(com.example.common_ui.R.id.map_container)); + } + + /** + * This is where we can add markers or lines, add listeners or move the camera. In this case, + * we + * just add a marker near Africa. + */ + @Override + public void onMapReady(GoogleMap map) { + map.addMarker(new MarkerOptions().position(new LatLng(0, 0)).title("Marker")); + } +} +""".trimIndent() + ), + + "com.example.kotlindemos.UiSettingsDemoActivity" to SnippetPair( + regionTag = "maps_android_sample_ui_settings", + kotlinCode = """ +override fun onMapReady(googleMap: GoogleMap) { + map = googleMap + uiSettings = map.uiSettings + + // Keep the UI Settings state in sync with the checkboxes. + uiSettings.isZoomControlsEnabled = binding.zoomButtonsToggle.isChecked + uiSettings.isCompassEnabled = binding.compassToggle.isChecked + uiSettings.isMyLocationButtonEnabled = binding.mylocationbuttonToggle.isChecked + if (ActivityCompat.checkSelfPermission( + this, + Manifest.permission.ACCESS_FINE_LOCATION + ) != PackageManager.PERMISSION_GRANTED && ActivityCompat.checkSelfPermission( + this, + Manifest.permission.ACCESS_COARSE_LOCATION + ) != PackageManager.PERMISSION_GRANTED + ) { + return + } + map.isMyLocationEnabled = binding.mylocationlayerToggle.isChecked + uiSettings.isScrollGesturesEnabled = binding.scrollToggle.isChecked + uiSettings.isZoomGesturesEnabled = binding.zoomGesturesToggle.isChecked + uiSettings.isTiltGesturesEnabled = binding.tiltToggle.isChecked + uiSettings.isRotateGesturesEnabled = binding.rotateToggle.isChecked + } +""".trimIndent(), + javaCode = """ +@SuppressLint("MissingPermission") + @Override + public void onMapReady(GoogleMap map) { + mMap = map; + + mUiSettings = mMap.getUiSettings(); + + // Keep the UI Settings state in sync with the checkboxes. + mUiSettings.setZoomControlsEnabled(binding.zoomButtonsToggle.isChecked()); + mUiSettings.setCompassEnabled(binding.compassToggle.isChecked()); + mUiSettings.setMyLocationButtonEnabled(binding.mylocationbuttonToggle.isChecked()); + mUiSettings.setScrollGesturesEnabled(binding.scrollToggle.isChecked()); + mUiSettings.setZoomGesturesEnabled(binding.zoomGesturesToggle.isChecked()); + mUiSettings.setTiltGesturesEnabled(binding.tiltToggle.isChecked()); + mUiSettings.setRotateGesturesEnabled(binding.rotateToggle.isChecked()); + + if (ActivityCompat.checkSelfPermission(this, permission.ACCESS_FINE_LOCATION) + != PackageManager.PERMISSION_GRANTED + && ActivityCompat.checkSelfPermission(this, permission.ACCESS_COARSE_LOCATION) + != PackageManager.PERMISSION_GRANTED) { + return; + } + mMap.setMyLocationEnabled(binding.mylocationlayerToggle.isChecked()); + } +""".trimIndent() + ), + + "com.example.kotlindemos.PolylineDemoActivity" to SnippetPair( + regionTag = "maps_android_sample_polylines", + kotlinCode = """ +override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(com.example.common_ui.R.layout.polyline_demo) + + hueBar = findViewById(com.example.common_ui.R.id.hueSeekBar).apply { + max = MAX_HUE_DEGREES + progress = 0 + } + + alphaBar = findViewById(com.example.common_ui.R.id.alphaSeekBar).apply { + max = MAX_ALPHA + progress = MAX_ALPHA + } + + widthBar = findViewById(com.example.common_ui.R.id.widthSeekBar).apply { + max = MAX_WIDTH_PX + progress = MAX_WIDTH_PX / 2 + } + + startCapSpinner = findViewById(com.example.common_ui.R.id.startCapSpinner).apply { + adapter = ArrayAdapter(this@PolylineDemoActivity, + android.R.layout.simple_spinner_item, + getResourceStrings(capTypeNameResourceIds)) + } + + endCapSpinner = findViewById(com.example.common_ui.R.id.endCapSpinner).apply { + adapter = ArrayAdapter(this@PolylineDemoActivity, + android.R.layout.simple_spinner_item, + getResourceStrings(capTypeNameResourceIds)) + } + + jointTypeSpinner = findViewById(com.example.common_ui.R.id.jointTypeSpinner).apply { + adapter = ArrayAdapter(this@PolylineDemoActivity, + android.R.layout.simple_spinner_item, + getResourceStrings(jointTypeNameResourceIds)) + } + + patternSpinner = findViewById(com.example.common_ui.R.id.patternSpinner).apply { + adapter = ArrayAdapter( + this@PolylineDemoActivity, android.R.layout.simple_spinner_item, + getResourceStrings(patternTypeNameResourceIds)) + } + + clickabilityCheckbox = findViewById(com.example.common_ui.R.id.toggleClickability) + + val mapFragment = supportFragmentManager.findFragmentById(com.example.common_ui.R.id.map) as SupportMapFragment + mapFragment.getMapAsync(this) + applyInsets(findViewById(com.example.common_ui.R.id.map_container)) + } + + + override fun onMapReady(googleMap: GoogleMap) { + with(googleMap) { + // Override the default content description on the view, for accessibility mode. + setContentDescription(getString(com.example.common_ui.R.string.polyline_demo_description)) + + // A geodesic polyline that goes around the world. + addPolyline(PolylineOptions().apply { + add(lhrLatLng, aklLatLng, laxLatLng, jfkLatLng, lhrLatLng) + width(INITIAL_STROKE_WIDTH_PX.toFloat()) + color(Color.BLUE) + geodesic(true) + clickable(clickabilityCheckbox.isChecked) + }) + + // Move the googleMap so that it is centered on the mutable polyline. + moveCamera(CameraUpdateFactory.newLatLngZoom(melbourneLatLng, 3f)) + + // Add a listener for polyline clicks that changes the clicked polyline's color. + setOnPolylineClickListener { polyline -> + // Flip the values of the red, green and blue components of the polyline's color. + polyline.color = polyline.color xor 0x00ffffff + } + } + + // A simple polyline across Australia. This polyline will be mutable. + mutablePolyline = googleMap.addPolyline(PolylineOptions().apply{ + color(Color.HSVToColor( + alphaBar.progress, floatArrayOf(hueBar.progress.toFloat(), 1f, 1f))) + width(widthBar.progress.toFloat()) + clickable(clickabilityCheckbox.isChecked) + add(melbourneLatLng, adelaideLatLng, perthLatLng, darwinLatLng) + }) + + arrayOf(hueBar, alphaBar, widthBar).map { + it.setOnSeekBarChangeListener(this) + } + + arrayOf(startCapSpinner, endCapSpinner, jointTypeSpinner, patternSpinner).map { + it.onItemSelectedListener = this + } + + with(mutablePolyline) { + startCap = getSelectedCap(startCapSpinner.selectedItemPosition) ?: ButtCap() + endCap = getSelectedCap(endCapSpinner.selectedItemPosition) ?: ButtCap() + jointType = getSelectedJointType(jointTypeSpinner.selectedItemPosition) + pattern = getSelectedPattern(patternSpinner.selectedItemPosition) + } + + clickabilityCheckbox.setOnClickListener { + view -> mutablePolyline.isClickable = (view as CheckBox).isChecked + } + } +""".trimIndent(), + javaCode = """ +@Sample( + id = "polylines", + title = "Polylines & Patterns", + description = "Drawing polylines with joint types, dash/dot stroke patterns, joint styles, and spans.", + category = "Shapes & Geometry", + complexity = Complexity.SIMPLE, + tags = {"#shapes", "#polylines", "#patterns", "#dashes", "#stroke", "#routes"}, + purpose = "Demonstrates drawing customizable polylines with dash/gap patterns, round end caps, and bevel joints.", + successCriteria = "Polylines render crisp dashed and dotted stroke lines along coordinate vertices.", + failureIndicators = "Line caps distorted or custom pattern ignored on high-DPI screens.", + framework = Framework.JAVA_VIEWS +) +public class PolylineDemoActivity extends SamplesBaseActivity + implements OnSeekBarChangeListener, OnItemSelectedListener, OnMapReadyCallback { + + // City locations for mutable polyline. + private static final LatLng ADELAIDE = new LatLng(-34.92873, 138.59995); + private static final LatLng DARWIN = new LatLng(-12.4258647, 130.7932231); + private static final LatLng MELBOURNE = new LatLng(-37.81319, 144.96298); + private static final LatLng PERTH = new LatLng(-31.95285, 115.85734); + + // Airport locations for geodesic polyline. + private static final LatLng AKL = new LatLng(-37.006254, 174.783018); + private static final LatLng JFK = new LatLng(40.641051, -73.777485); + private static final LatLng LAX = new LatLng(33.936524, -118.377686); + private static final LatLng LHR = new LatLng(51.471547, -0.460052); + + private static final int MAX_WIDTH_PX = 100; + private static final int MAX_HUE_DEGREES = 360; + private static final int MAX_ALPHA = 255; + private static final int CUSTOM_CAP_IMAGE_REF_WIDTH_PX = 50; + private static final int INITIAL_STROKE_WIDTH_PX = 5; + + private static final int PATTERN_DASH_LENGTH_PX = 50; + private static final int PATTERN_GAP_LENGTH_PX = 20; + private static final Dot DOT = new Dot(); + private static final Dash DASH = new Dash(PATTERN_DASH_LENGTH_PX); + private static final Gap GAP = new Gap(PATTERN_GAP_LENGTH_PX); + private static final List PATTERN_DOTTED = Arrays.asList(DOT, GAP); + private static final List PATTERN_DASHED = Arrays.asList(DASH, GAP); + private static final List PATTERN_MIXED = Arrays.asList(DOT, GAP, DOT, DASH, GAP); + + private Polyline mutablePolyline; + private SeekBar hueBar; + private SeekBar alphaBar; + private SeekBar widthBar; + private Spinner startCapSpinner; + private Spinner endCapSpinner; + private Spinner jointTypeSpinner; + private Spinner patternSpinner; + private CheckBox clickabilityCheckbox; + + // These are the options for polyline caps, joints and patterns. We use their + // string resource IDs as identifiers. + + private static final int[] CAP_TYPE_NAME_RESOURCE_IDS = { + com.example.common_ui.R.string.cap_butt, // Default + com.example.common_ui.R.string.cap_round, + com.example.common_ui.R.string.cap_square, + com.example.common_ui.R.string.cap_image, + }; + + private static final int[] JOINT_TYPE_NAME_RESOURCE_IDS = { + com.example.common_ui.R.string.joint_type_default, // Default + com.example.common_ui.R.string.joint_type_bevel, + com.example.common_ui.R.string.joint_type_round, + }; + + private static final int[] PATTERN_TYPE_NAME_RESOURCE_IDS = { + com.example.common_ui.R.string.pattern_solid, // Default + com.example.common_ui.R.string.pattern_dashed, + com.example.common_ui.R.string.pattern_dotted, + com.example.common_ui.R.string.pattern_mixed, + }; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(com.example.common_ui.R.layout.polyline_demo); + + hueBar = findViewById(com.example.common_ui.R.id.hueSeekBar); + hueBar.setMax(MAX_HUE_DEGREES); + hueBar.setProgress(0); + + alphaBar = findViewById(com.example.common_ui.R.id.alphaSeekBar); + alphaBar.setMax(MAX_ALPHA); + alphaBar.setProgress(MAX_ALPHA); + + widthBar = findViewById(com.example.common_ui.R.id.widthSeekBar); + widthBar.setMax(MAX_WIDTH_PX); + widthBar.setProgress(MAX_WIDTH_PX / 2); + + startCapSpinner = findViewById(com.example.common_ui.R.id.startCapSpinner); + startCapSpinner.setAdapter(new ArrayAdapter<>( + this, android.R.layout.simple_spinner_item, + getResourceStrings(CAP_TYPE_NAME_RESOURCE_IDS))); + + endCapSpinner = findViewById(com.example.common_ui.R.id.endCapSpinner); + endCapSpinner.setAdapter(new ArrayAdapter<>( + this, android.R.layout.simple_spinner_item, + getResourceStrings(CAP_TYPE_NAME_RESOURCE_IDS))); + + jointTypeSpinner = findViewById(com.example.common_ui.R.id.jointTypeSpinner); + jointTypeSpinner.setAdapter(new ArrayAdapter<>( + this, android.R.layout.simple_spinner_item, + getResourceStrings(JOINT_TYPE_NAME_RESOURCE_IDS))); + + patternSpinner = findViewById(com.example.common_ui.R.id.patternSpinner); + patternSpinner.setAdapter(new ArrayAdapter<>( + this, android.R.layout.simple_spinner_item, + getResourceStrings(PATTERN_TYPE_NAME_RESOURCE_IDS))); + + clickabilityCheckbox = findViewById(com.example.common_ui.R.id.toggleClickability); + + SupportMapFragment mapFragment = + (SupportMapFragment) getSupportFragmentManager().findFragmentById(com.example.common_ui.R.id.map); + mapFragment.getMapAsync(this); + + applyInsets(findViewById(com.example.common_ui.R.id.map_container)); + } + + + @Override + public void onMapReady(GoogleMap map) { + + // Override the default content description on the view, for accessibility mode. + map.setContentDescription(getString(com.example.common_ui.R.string.polyline_demo_description)); + + // A geodesic polyline that goes around the world. + map.addPolyline(new PolylineOptions() + .add(LHR, AKL, LAX, JFK, LHR) + .width(INITIAL_STROKE_WIDTH_PX) + .color(Color.BLUE) + .geodesic(true) + .clickable(clickabilityCheckbox.isChecked())); + + // A simple polyline across Australia. This polyline will be mutable. + int color = Color.HSVToColor( + alphaBar.getProgress(), new float[]{hueBar.getProgress(), 1, 1}); + mutablePolyline = map.addPolyline(new PolylineOptions() + .color(color) + .width(widthBar.getProgress()) + .clickable(clickabilityCheckbox.isChecked()) + .add(MELBOURNE, ADELAIDE, PERTH, DARWIN)); + + hueBar.setOnSeekBarChangeListener(this); + alphaBar.setOnSeekBarChangeListener(this); + widthBar.setOnSeekBarChangeListener(this); + + startCapSpinner.setOnItemSelectedListener(this); + endCapSpinner.setOnItemSelectedListener(this); + jointTypeSpinner.setOnItemSelectedListener(this); + patternSpinner.setOnItemSelectedListener(this); + + mutablePolyline.setStartCap(getSelectedCap(startCapSpinner.getSelectedItemPosition())); + mutablePolyline.setEndCap(getSelectedCap(endCapSpinner.getSelectedItemPosition())); + mutablePolyline.setJointType(getSelectedJointType(jointTypeSpinner.getSelectedItemPosition())); + mutablePolyline.setPattern(getSelectedPattern(patternSpinner.getSelectedItemPosition())); + + // Move the map so that it is centered on the mutable polyline. + map.moveCamera(CameraUpdateFactory.newLatLngZoom(MELBOURNE, 3)); + + // Add a listener for polyline clicks that changes the clicked polyline's color. + map.setOnPolylineClickListener(new GoogleMap.OnPolylineClickListener() { + @Override + public void onPolylineClick(Polyline polyline) { + // Flip the values of the red, green and blue components of the polyline's color. + polyline.setColor(polyline.getColor() ^ 0x00ffffff); + } + }); + } + + +} +""".trimIndent() + ), + + "com.example.kotlindemos.PolygonDemoActivity" to SnippetPair( + regionTag = "maps_android_sample_polygons", + kotlinCode = """ +override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.polygon_demo) + + fillHueBar = findViewById(R.id.fillHueSeekBar).apply { + max = MAX_HUE_DEGREES + progress = MAX_HUE_DEGREES / 2 + } + + fillAlphaBar = findViewById(R.id.fillAlphaSeekBar).apply { + max = MAX_ALPHA + progress = MAX_ALPHA / 2 + } + + strokeWidthBar = findViewById(R.id.strokeWidthSeekBar).apply { + max = MAX_WIDTH_PX + progress = MAX_WIDTH_PX / 3 + } + + strokeHueBar = findViewById(R.id.strokeHueSeekBar).apply { + max = MAX_HUE_DEGREES + progress = 0 + } + + strokeAlphaBar = findViewById(R.id.strokeAlphaSeekBar).apply { + max = MAX_ALPHA + progress = MAX_ALPHA + } + + strokeJointTypeSpinner = findViewById(R.id.strokeJointTypeSpinner).apply { + adapter = ArrayAdapter( + this@PolygonDemoActivity, android.R.layout.simple_spinner_item, + getResourceStrings(jointTypeNameResourceIds)) + } + + strokePatternSpinner = findViewById(R.id.strokePatternSpinner).apply { + adapter = ArrayAdapter( + this@PolygonDemoActivity, android.R.layout.simple_spinner_item, + getResourceStrings(patternTypeNameResourceIds)) + } + + clickabilityCheckbox = findViewById(R.id.toggleClickability) + + val mapFragment = supportFragmentManager.findFragmentById(R.id.map) as SupportMapFragment + mapFragment.getMapAsync(this) + applyInsets(findViewById(R.id.map_container)) + } + + + override fun onMapReady(googleMap: GoogleMap) { + val fillColorArgb = Color.HSVToColor( + fillAlphaBar.progress, floatArrayOf(fillHueBar.progress.toFloat(), 1f, 1f)) + val strokeColorArgb = Color.HSVToColor( + strokeAlphaBar.progress, floatArrayOf(strokeHueBar.progress.toFloat(), 1f, 1f)) + + with(googleMap) { + // Override the default content description on the view, for accessibility mode. + setContentDescription(getString(R.string.polygon_demo_description)) + // Move the googleMap so that it is centered on the mutable polygon. + moveCamera(CameraUpdateFactory.newLatLngZoom(center, 4f)) + + // Create a rectangle with two rectangular holes. + mutablePolygon = addPolygon(PolygonOptions().apply { + addAll(createRectangle(center, 5.0, 5.0)) + addHole(createRectangle(LatLng(-22.0, 128.0), 1.0, 1.0)) + addHole(createRectangle(LatLng(-18.0, 133.0), 0.5, 1.5)) + fillColor(fillColorArgb) + strokeColor(strokeColorArgb) + strokeWidth(strokeWidthBar.progress.toFloat()) + clickable(clickabilityCheckbox.isChecked) + }) + + // Add a listener for polygon clicks that changes the clicked polygon's stroke color. + setOnPolygonClickListener { polygon -> + // Flip the red, green and blue components of the polygon's stroke color. + polygon.strokeColor = polygon.strokeColor xor 0x00ffffff + } + } + + // set listeners on seekBars + arrayOf(fillHueBar, fillAlphaBar, strokeWidthBar, strokeHueBar, strokeAlphaBar).map { + it.setOnSeekBarChangeListener(this) + } + + // set listeners on spinners + arrayOf(strokeJointTypeSpinner, strokePatternSpinner).map { + it.onItemSelectedListener = this + } + + // set line pattern and joint type based on current spinner position + with(mutablePolygon) { + strokeJointType = getSelectedJointType(strokeJointTypeSpinner.selectedItemPosition) + strokePattern = getSelectedPattern(strokePatternSpinner.selectedItemPosition) + } + + } +""".trimIndent(), + javaCode = """ +@Sample( + id = "polygons", + title = "Polygons & Holes", + description = "Drawing geodesic polygons with fill colors, stroke patterns, click events, and interior holes.", + category = "Shapes & Geometry", + complexity = Complexity.SIMPLE, + tags = {"#shapes", "#polygons", "#holes", "#geometry", "#stroke", "#fill"}, + purpose = "Demonstrates drawing styled polygons with interior holes (donut polygons), click listeners, and stroke caps.", + successCriteria = "Polygons render with specified fill opacity and interior cutout holes properly subtracted.", + failureIndicators = "Holes not rendering as transparent cutouts or stroke color incorrect.", + framework = Framework.JAVA_VIEWS +) +public class PolygonDemoActivity extends SamplesBaseActivity + implements OnSeekBarChangeListener, OnItemSelectedListener, OnMapReadyCallback { + + private static final LatLng CENTER = new LatLng(-20, 130); + private static final int MAX_WIDTH_PX = 100; + private static final int MAX_HUE_DEGREES = 360; + private static final int MAX_ALPHA = 255; + + private static final int PATTERN_DASH_LENGTH_PX = 50; + private static final int PATTERN_GAP_LENGTH_PX = 10; + private static final Dot DOT = new Dot(); + private static final Dash DASH = new Dash(PATTERN_DASH_LENGTH_PX); + private static final Gap GAP = new Gap(PATTERN_GAP_LENGTH_PX); + private static final List PATTERN_DOTTED = Arrays.asList(DOT, GAP); + private static final List PATTERN_DASHED = Arrays.asList(DASH, GAP); + private static final List PATTERN_MIXED = Arrays.asList(DOT, GAP, DOT, DASH, GAP); + + private Polygon mutablePolygon; + private SeekBar fillHueBar; + private SeekBar fillAlphaBar; + private SeekBar strokeWidthBar; + private SeekBar strokeHueBar; + private SeekBar strokeAlphaBar; + private Spinner strokeJointTypeSpinner; + private Spinner strokePatternSpinner; + private CheckBox clickabilityCheckbox; + + // These are the options for polygon stroke joints and patterns. We use their + // string resource IDs as identifiers. + + private static final int[] JOINT_TYPE_NAME_RESOURCE_IDS = { + com.example.common_ui.R.string.joint_type_default, // Default + com.example.common_ui.R.string.joint_type_bevel, + com.example.common_ui.R.string.joint_type_round, + }; + + private static final int[] PATTERN_TYPE_NAME_RESOURCE_IDS = { + com.example.common_ui.R.string.pattern_solid, // Default + com.example.common_ui.R.string.pattern_dashed, + com.example.common_ui.R.string.pattern_dotted, + com.example.common_ui.R.string.pattern_mixed, + }; + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(com.example.common_ui.R.layout.polygon_demo); + + fillHueBar = findViewById(com.example.common_ui.R.id.fillHueSeekBar); + fillHueBar.setMax(MAX_HUE_DEGREES); + fillHueBar.setProgress(MAX_HUE_DEGREES / 2); + + fillAlphaBar = findViewById(com.example.common_ui.R.id.fillAlphaSeekBar); + fillAlphaBar.setMax(MAX_ALPHA); + fillAlphaBar.setProgress(MAX_ALPHA / 2); + + strokeWidthBar = findViewById(com.example.common_ui.R.id.strokeWidthSeekBar); + strokeWidthBar.setMax(MAX_WIDTH_PX); + strokeWidthBar.setProgress(MAX_WIDTH_PX / 3); + + strokeHueBar = findViewById(com.example.common_ui.R.id.strokeHueSeekBar); + strokeHueBar.setMax(MAX_HUE_DEGREES); + strokeHueBar.setProgress(0); + + strokeAlphaBar = findViewById(com.example.common_ui.R.id.strokeAlphaSeekBar); + strokeAlphaBar.setMax(MAX_ALPHA); + strokeAlphaBar.setProgress(MAX_ALPHA); + + strokeJointTypeSpinner = findViewById(com.example.common_ui.R.id.strokeJointTypeSpinner); + strokeJointTypeSpinner.setAdapter(new ArrayAdapter<>( + this, android.R.layout.simple_spinner_item, + getResourceStrings(JOINT_TYPE_NAME_RESOURCE_IDS))); + + strokePatternSpinner = findViewById(com.example.common_ui.R.id.strokePatternSpinner); + strokePatternSpinner.setAdapter(new ArrayAdapter<>( + this, android.R.layout.simple_spinner_item, + getResourceStrings(PATTERN_TYPE_NAME_RESOURCE_IDS))); + + clickabilityCheckbox = findViewById(com.example.common_ui.R.id.toggleClickability); + + SupportMapFragment mapFragment = + (SupportMapFragment) getSupportFragmentManager().findFragmentById(com.example.common_ui.R.id.map); + mapFragment.getMapAsync(this); + + applyInsets(findViewById(com.example.common_ui.R.id.map_container)); + } + + + @Override + public void onMapReady(GoogleMap map) { + // Override the default content description on the view, for accessibility mode. + map.setContentDescription(getString(com.example.common_ui.R.string.polygon_demo_description)); + + int fillColorArgb = Color.HSVToColor( + fillAlphaBar.getProgress(), new float[]{fillHueBar.getProgress(), 1, 1}); + int strokeColorArgb = Color.HSVToColor( + strokeAlphaBar.getProgress(), new float[]{strokeHueBar.getProgress(), 1, 1}); + + // Create a rectangle with two rectangular holes. + mutablePolygon = map.addPolygon(new PolygonOptions() + .addAll(createRectangle(CENTER, 5, 5)) + .addHole(createRectangle(new LatLng(-22, 128), 1, 1)) + .addHole(createRectangle(new LatLng(-18, 133), 0.5, 1.5)) + .fillColor(fillColorArgb) + .strokeColor(strokeColorArgb) + .strokeWidth(strokeWidthBar.getProgress()) + .clickable(clickabilityCheckbox.isChecked())); + + fillHueBar.setOnSeekBarChangeListener(this); + fillAlphaBar.setOnSeekBarChangeListener(this); + + strokeWidthBar.setOnSeekBarChangeListener(this); + strokeHueBar.setOnSeekBarChangeListener(this); + strokeAlphaBar.setOnSeekBarChangeListener(this); + + strokeJointTypeSpinner.setOnItemSelectedListener(this); + strokePatternSpinner.setOnItemSelectedListener(this); + + mutablePolygon.setStrokeJointType(getSelectedJointType(strokeJointTypeSpinner.getSelectedItemPosition())); + mutablePolygon.setStrokePattern(getSelectedPattern(strokePatternSpinner.getSelectedItemPosition())); + + // Move the map so that it is centered on the mutable polygon. + map.moveCamera(CameraUpdateFactory.newLatLngZoom(CENTER, 4)); + + // Add a listener for polygon clicks that changes the clicked polygon's stroke color. + map.setOnPolygonClickListener(new GoogleMap.OnPolygonClickListener() { + @Override + public void onPolygonClick(Polygon polygon) { + // Flip the red, green and blue components of the polygon's stroke color. + polygon.setStrokeColor(polygon.getStrokeColor() ^ 0x00ffffff); + } + }); + } + + +} +""".trimIndent() + ), + + "com.example.kotlindemos.AdvancedMarkersDemoActivity" to SnippetPair( + regionTag = "maps_android_sample_marker_advanced", + kotlinCode = """ +@Sample( + id = "advanced_markers", + title = "Advanced Markers & Pins", + description = "Modern PinConfig pins, custom glyphs, badge icon views, and collision behavior.", + category = "Markers & Overlays", + complexity = Complexity.ADVANCED, + tags = ["#markers", "#advancedmarkers", "#pinconfig", "#collision", "#badges", "#mapid"], + purpose = "Demonstrates Cloud-backed Advanced Markers with custom colors, pin glyphs, collision behaviors, and custom View icons.", + successCriteria = "Custom colored pins and badge icon views render sharply at correct anchor points with collision handling.", + failureIndicators = "Pins render as default red markers (missing Map ID), collision behavior ignored, or badge text blurry.", + framework = Framework.KOTLIN_VIEWS +) +class AdvancedMarkersDemoActivity : SamplesBaseActivity(), OnMapReadyCallback { + + /** + * This method is called when the activity is first created. + * + * It sets up the activity's layout and then initializes the map. + * + * The key logic here is to check if the developer has provided a Map ID in the + * `strings.xml` file. + * + * If the `R.string.map_id` value is not the default "DEMO_MAP_ID", it means a + * custom Map ID has been provided. In this case, we can rely on the simpler setup + * where the `SupportMapFragment` is inflated directly from the XML layout, and it + * will automatically use the Map ID from the string resource. + * + * However, if the `R.string.map_id` is still the default value, we fall back to a + * programmatic setup. This involves: + * 1. Retrieving the Map ID from the `secrets.properties` file, which is managed by the + * `ApiDemoApplication` class. + * 2. Creating a `GoogleMapOptions` object. + * 3. Explicitly setting the retrieved `mapId` on the `GoogleMapOptions`. This step is + * **critical** because Advanced Markers will not work without a valid Map ID. + * 4. Creating a new `SupportMapFragment` instance with these options and replacing the + * placeholder fragment in the layout. + * + * This dual approach ensures that the demo can run seamlessly while also providing a + * clear path for developers to use their own Map IDs, which is a requirement for using + * Advanced Markers. + */ + /** + * This method is called when the activity is first created. + * + * It sets up the activity's layout and then initializes the map. + * + * The key logic here is to check if the developer has provided a Map ID in the + * `strings.xml` file. + * + * If the `R.string.map_id` value is not the default "DEMO_MAP_ID", it means a + * custom Map ID has been provided. In this case, we can rely on the simpler setup + * where the `SupportMapFragment` is inflated directly from the XML layout, and it + * will automatically use the Map ID from the string resource. + * + * However, if the `R.string.map_id` is still the default value, we fall back to a + * programmatic setup. This involves: + * 1. Retrieving the Map ID from the `secrets.properties` file, via the + * `ApiDemoApplication.mapId` property. + * 2. Creating a `GoogleMapOptions` object. + * 3. Explicitly setting the retrieved `mapId` on the `GoogleMapOptions`. This step is + * **critical** because Advanced Markers will not work without a valid Map ID. + * 4. Creating a new `SupportMapFragment` instance with these options and replacing the + * placeholder fragment in the layout. + * + * This dual approach ensures that the demo can run seamlessly while also providing a + * clear path for developers to use their own Map IDs, which is a requirement for using + * Advanced Markers. + */ + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(com.example.common_ui.R.layout.advanced_markers_demo) + + if (getString(com.example.common_ui.R.string.map_id) != "DEMO_MAP_ID") { + val mapFragment = supportFragmentManager.findFragmentById(com.example.common_ui.R.id.map) as SupportMapFragment? + mapFragment?.getMapAsync(this) + } else { + val mapId = (application as ApiDemoApplication).mapId + + // --- Map ID Check --- + if (mapId == null) { + finish() + return // Exit early if no valid Map ID + } + + // --- Programmatically create and add the map fragment --- + val mapOptions = GoogleMapOptions().apply { + mapId(mapId) + } + val mapFragment = SupportMapFragment.newInstance(mapOptions) + supportFragmentManager.beginTransaction() + .replace(R.id.map, mapFragment) // Use the container ID + .commit() + mapFragment.getMapAsync(this) + } + + applyInsets(findViewById(com.example.common_ui.R.id.map_container)) + } + + override fun onMapReady(map: GoogleMap) { + + val bounds = LatLngBounds.builder() + .include(SINGAPORE) + .include(KUALA_LUMPUR) + .include(JAKARTA) + .include(BANGKOK) + .include(MANILA) + .include(HO_CHI_MINH_CITY) + .build() + map.moveCamera(CameraUpdateFactory.newLatLngBounds(bounds, 120)) + + val capabilities: MapCapabilities = map.mapCapabilities + Log.d(TAG, "are advanced marker enabled?" + capabilities.isAdvancedMarkersAvailable) + + // 1. Custom View as iconView (Framed circular badge with Android logo) + val iconImageView = android.widget.ImageView(this).apply { + setImageResource(R.drawable.ic_android) + setColorFilter("#3DDC84".toColorInt()) // Android Green + setBackgroundResource(R.drawable.bg_marker_badge) + val padding = (8 * resources.displayMetrics.density).toInt() + setPadding(padding, padding, padding, padding) + layoutParams = android.view.ViewGroup.LayoutParams( + (44 * resources.displayMetrics.density).toInt(), + (44 * resources.displayMetrics.density).toInt() + ) + } + map.addMarker( + AdvancedMarkerOptions() + .position(SINGAPORE) + .iconView(iconImageView) + .title("Singapore (Custom Framed Badge)") + .zIndex(1f) + ) + + // 2. PinConfig with custom background color + val pinConfigMagenta = PinConfig.builder() + .setBackgroundColor(Color.MAGENTA) + .build() + map.addMarker( + AdvancedMarkerOptions() + .icon(BitmapDescriptorFactory.fromPinConfig(pinConfigMagenta)) + .position(KUALA_LUMPUR) + .title("Kuala Lumpur (Magenta Pin)") + ) + + // 3. PinConfig with custom border color + val pinConfigBorder = PinConfig.builder() + .setBorderColor(Color.BLUE) + .build() + map.addMarker( + AdvancedMarkerOptions() + .icon(BitmapDescriptorFactory.fromPinConfig(pinConfigBorder)) + .position(JAKARTA) + .title("Jakarta (Blue Border)") + ) + + // 4. PinConfig with text glyph ("A") + val pinConfigTextGlyph = PinConfig.builder() + .setGlyph(PinConfig.Glyph("A")) + .build() + map.addMarker( + AdvancedMarkerOptions() + .icon(BitmapDescriptorFactory.fromPinConfig(pinConfigTextGlyph)) + .position(BANGKOK) + .title("Bangkok (Text Glyph 'A')") + ) + + // 5. PinConfig with transparent glyph (cutout / donut pin) + val pinConfigHole = PinConfig.builder() + .setBackgroundColor(Color.MAGENTA) + .setGlyph(PinConfig.Glyph(Color.TRANSPARENT)) + .build() + map.addMarker( + AdvancedMarkerOptions() + .icon(BitmapDescriptorFactory.fromPinConfig(pinConfigHole)) + .position(MANILA) + .title("Manila (Transparent Cutout Glyph)") + ) + + // 6. Collision behavior + val collisionBehavior = + AdvancedMarkerOptions.CollisionBehavior.REQUIRED_AND_HIDES_OPTIONAL + map.addMarker( + AdvancedMarkerOptions() + .position(HO_CHI_MINH_CITY) + .collisionBehavior(collisionBehavior) + .title("Ho Chi Minh City (Collision Behavior)") + ) + } +} +""".trimIndent(), + javaCode = """ +@Sample( + id = "advanced_markers", + title = "Advanced Markers & Pins", + description = "Modern PinConfig pins, custom glyphs, badge icon views, and collision behavior.", + category = "Markers & Overlays", + complexity = Complexity.ADVANCED, + tags = {"#markers", "#advancedmarkers", "#pinconfig", "#collision", "#badges", "#mapid"}, + purpose = "Demonstrates Cloud-backed Advanced Markers with custom colors, pin glyphs, collision behaviors, and custom View icons.", + successCriteria = "Custom colored pins and badge icon views render sharply at correct anchor points with collision handling.", + failureIndicators = "Pins render as default red markers (missing Map ID), collision behavior ignored, or badge text blurry.", + framework = Framework.JAVA_VIEWS +) +public class AdvancedMarkersDemoActivity extends SamplesBaseActivity implements OnMapReadyCallback { + + private static final LatLng SINGAPORE = new LatLng(1.3521, 103.8198); + private static final LatLng KUALA_LUMPUR = new LatLng(3.1390, 101.6869); + private static final LatLng JAKARTA = new LatLng(-6.2088, 106.8456); + private static final LatLng BANGKOK = new LatLng(13.7563, 100.5018); + private static final LatLng MANILA = new LatLng(14.5995, 120.9842); + private static final LatLng HO_CHI_MINH_CITY = new LatLng(10.7769, 106.7009); + + private static final float ZOOM_LEVEL = 3.5f; + + private static final String TAG = AdvancedMarkersDemoActivity.class.getName(); + + /** + * This method is called when the activity is first created. + * + * It sets up the activity's layout and then initializes the map. + * + * The key logic here is to check if the developer has provided a Map ID in the + * `strings.xml` file. + * + * If the `R.string.map_id` value is not the default "DEMO_MAP_ID", it means a + * custom Map ID has been provided. In this case, we can rely on the simpler setup + * where the `SupportMapFragment` is inflated directly from the XML layout, and it + * will automatically use the Map ID from the string resource. + * + * However, if the `R.string.map_id` is still the default value, we fall back to a + * programmatic setup. This involves: + * 1. Retrieving the Map ID from the `secrets.properties` file, which is managed by the + * `ApiDemoApplication` class. + * 2. Creating a `GoogleMapOptions` object. + * 3. Explicitly setting the retrieved `mapId` on the `GoogleMapOptions`. This step is + * **critical** because Advanced Markers will not work without a valid Map ID. + * 4. Creating a new `SupportMapFragment` instance with these options and replacing the + * placeholder fragment in the layout. + * + * This dual approach ensures that the demo can run seamlessly while also providing a + * clear path for developers to use their own Map IDs, which is a requirement for using + * Advanced Markers. + */ + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(com.example.common_ui.R.layout.advanced_markers_demo); + + if (!getString(com.example.common_ui.R.string.map_id).equals("DEMO_MAP_ID")) { + SupportMapFragment mapFragment = (SupportMapFragment) getSupportFragmentManager().findFragmentById(com.example.common_ui.R.id.map); + if (mapFragment != null) { + mapFragment.getMapAsync(this); + } + } else { + String mapId = ((ApiDemoApplication) getApplication()).getMapId(); + if (mapId == null) { + finish(); + return; + } + + GoogleMapOptions mapOptions = new GoogleMapOptions().mapId(mapId); + SupportMapFragment mapFragment = SupportMapFragment.newInstance(mapOptions); + getSupportFragmentManager().beginTransaction() + .replace(com.example.common_ui.R.id.map, mapFragment) + .commit(); + mapFragment.getMapAsync(this); + } + + applyInsets(findViewById(com.example.common_ui.R.id.map_container)); + } + + + + @Override + public void onMapReady(GoogleMap map) { + LatLngBounds bounds = new LatLngBounds.Builder() + .include(SINGAPORE) + .include(KUALA_LUMPUR) + .include(JAKARTA) + .include(BANGKOK) + .include(MANILA) + .include(HO_CHI_MINH_CITY) + .build(); + map.moveCamera(CameraUpdateFactory.newLatLngBounds(bounds, 120)); + + MapCapabilities capabilities = map.getMapCapabilities(); + Log.d(TAG, "Are advanced markers enabled? " + capabilities.isAdvancedMarkersAvailable()); + + // 1. Custom View as iconView (Framed circular badge with Android logo) + ImageView iconImageView = new ImageView(this); + iconImageView.setImageResource(R.drawable.ic_android); + iconImageView.setColorFilter(Color.parseColor("#3DDC84")); // Android Green + iconImageView.setBackgroundResource(R.drawable.bg_marker_badge); + int padding = (int) (8 * getResources().getDisplayMetrics().density); + iconImageView.setPadding(padding, padding, padding, padding); + int size = (int) (44 * getResources().getDisplayMetrics().density); + iconImageView.setLayoutParams(new ViewGroup.LayoutParams(size, size)); + + map.addMarker(new AdvancedMarkerOptions() + .position(SINGAPORE) + .iconView(iconImageView) + .title("Singapore (Custom Framed Badge)") + .zIndex(1f)); + + // This uses PinConfig.Builder to create an instance of PinConfig. + PinConfig.Builder pinConfigBuilder = PinConfig.builder(); + pinConfigBuilder.setBackgroundColor(Color.MAGENTA); + PinConfig pinConfig = pinConfigBuilder.build(); + + // Use the PinConfig instance to set the icon for AdvancedMarkerOptions. + AdvancedMarkerOptions advancedMarkerOptions = new AdvancedMarkerOptions() + .icon(BitmapDescriptorFactory.fromPinConfig(pinConfig)) + .position(KUALA_LUMPUR); + + // Pass the AdvancedMarkerOptions instance to addMarker(). + Marker marker = map.addMarker(advancedMarkerOptions); + + // This sample changes the border color of the advanced marker + PinConfig.Builder pinConfigBuilder2 = PinConfig.builder(); + pinConfigBuilder2.setBorderColor(Color.BLUE); + PinConfig pinConfig2 = pinConfigBuilder2.build(); + + AdvancedMarkerOptions advancedMarkerOptions2 = new AdvancedMarkerOptions() + .icon(BitmapDescriptorFactory.fromPinConfig(pinConfig2)) + .position(JAKARTA); + + Marker marker2 = map.addMarker(advancedMarkerOptions2); + + // Set the glyph text. + PinConfig.Builder pinConfigBuilder3 = PinConfig.builder(); + PinConfig.Glyph glyphText = new PinConfig.Glyph("A"); + + // Alternatively, you can set the text color: + // Glyph glyphText = new Glyph("A", Color.GREEN); + pinConfigBuilder3.setGlyph(glyphText); + PinConfig pinConfig3 = pinConfigBuilder3.build(); + + AdvancedMarkerOptions advancedMarkerOptions3 = new AdvancedMarkerOptions() + .icon(BitmapDescriptorFactory.fromPinConfig(pinConfig3)) + .position(BANGKOK); + + Marker marker3 = map.addMarker(advancedMarkerOptions3); + + // Create a transparent glyph. + PinConfig.Builder pinConfigBuilder4 = PinConfig.builder(); + pinConfigBuilder4.setBackgroundColor(Color.MAGENTA); + pinConfigBuilder4.setGlyph(new PinConfig.Glyph(Color.TRANSPARENT)); + PinConfig pinConfig4 = pinConfigBuilder4.build(); + + AdvancedMarkerOptions advancedMarkerOptions4 = new AdvancedMarkerOptions() + .icon(BitmapDescriptorFactory.fromPinConfig(pinConfig4)) + .position(MANILA); + + Marker marker4 = map.addMarker(advancedMarkerOptions4); + + // Collision behavior can only be changed in the AdvancedMarkerOptions object. + // Changes to collision behavior after a marker has been created are not possible + int collisionBehavior = AdvancedMarkerOptions.CollisionBehavior.REQUIRED_AND_HIDES_OPTIONAL; + AdvancedMarkerOptions advancedMarkerOptions5 = new AdvancedMarkerOptions() + .position(HO_CHI_MINH_CITY) + .collisionBehavior(collisionBehavior); + + Marker marker5 = map.addMarker(advancedMarkerOptions5); + } +} +""".trimIndent() + ), + + "com.example.kotlindemos.MarkerDemoActivity" to SnippetPair( + regionTag = "maps_android_sample_marker", + kotlinCode = """ +@Sample( + id = "marker_demo", + title = "Standard Markers & Info Windows", + description = "Placing markers, custom icons, draggable pins, and custom info window layouts.", + category = "Markers & Overlays", + complexity = Complexity.SIMPLE, + tags = ["#markers", "#infowindow", "#draggable", "#icons", "#anchor"], + purpose = "Demonstrates adding standard markers with alpha, rotation, draggable pins, and custom InfoWindowAdapter views.", + successCriteria = "Tapping markers displays custom info windows with formatted content; dragging pins updates position.", + failureIndicators = "Info window clicks not detected or custom snippet styling not applied.", + framework = Framework.KOTLIN_VIEWS +) +class MarkerDemoActivity : + SamplesBaseActivity(), + OnMarkerClickListener, + OnInfoWindowClickListener, + OnMarkerDragListener, + OnInfoWindowLongClickListener, + OnInfoWindowCloseListener, + OnMapAndViewReadyListener.OnGlobalLayoutAndMapReadyListener { + + private val TAG = MarkerDemoActivity::class.java.name + + /** This is ok to be lateinit as it is initialised in onMapReady */ + private lateinit var map: GoogleMap + + /** + * Keeps track of the last selected marker (though it may no longer be selected). This is + * useful for refreshing the info window. + * + * Must be nullable as it is null when no marker has been selected + */ + private var lastSelectedMarker: Marker? = null + + private val markerRainbow = ArrayList() + + /** map to store place names and locations */ + private val places = mapOf( + "BRISBANE" to LatLng(-27.47093, 153.0235), + "MELBOURNE" to LatLng(-37.81319, 144.96298), + "DARWIN" to LatLng(-12.4634, 130.8456), + "SYDNEY" to LatLng(-33.87365, 151.20689), + "ADELAIDE" to LatLng(-34.92873, 138.59995), + "PERTH" to LatLng(-31.952854, 115.857342), + "ALICE_SPRINGS" to LatLng(-24.6980, 133.8807) + ) + + private lateinit var binding: com.example.common_ui.databinding.MarkerDemoBinding + + private val random = Random() + + /** Demonstrates customizing the info window and/or its contents. */ + internal inner class CustomInfoWindowAdapter : InfoWindowAdapter { + + // These are both view groups containing an ImageView with id "badge" and two + // TextViews with id "title" and "snippet". + private val window: View = layoutInflater.inflate(R.layout.custom_info_window, null) + private val contents: View = layoutInflater.inflate(R.layout.custom_info_contents, null) + + override fun getInfoWindow(marker: Marker): View? { + if (binding.customInfoWindowOptions.checkedRadioButtonId != R.id.custom_info_window) { + // This means that getInfoContents will be called. + return null + } + render(marker, window) + return window + } + + override fun getInfoContents(marker: Marker): View? { + if (binding.customInfoWindowOptions.checkedRadioButtonId != R.id.custom_info_contents) { + // This means that the default info contents will be used. + return null + } + render(marker, contents) + return contents + } + + private fun render(marker: Marker, view: View) { + val badge = when (marker.title!!) { + "Brisbane" -> R.drawable.badge_qld + "Adelaide" -> R.drawable.badge_sa + "Sydney" -> R.drawable.badge_nsw + "Melbourne" -> R.drawable.badge_victoria + "Perth" -> R.drawable.badge_wa + in "Darwin Marker 1".."Darwin Marker 4" -> R.drawable.badge_nt + else -> 0 // Passing 0 to setImageResource will clear the image view. + } + + view.findViewById(R.id.badge).setImageResource(badge) + + // Set the title and snippet for the custom info window + val title: String? = marker.title + val titleUi = view.findViewById(R.id.title) + + if (title != null) { + // Spannable string allows us to edit the formatting of the text. + titleUi.text = SpannableString(title).apply { + setSpan(ForegroundColorSpan(Color.RED), 0, length, 0) + } + } else { + titleUi.text = "" + } + + val snippet: String? = marker.snippet + val snippetUi = view.findViewById(R.id.snippet) + if (snippet != null && snippet.length > 12) { + snippetUi.text = SpannableString(snippet).apply { + setSpan(ForegroundColorSpan(Color.MAGENTA), 0, 10, 0) + setSpan(ForegroundColorSpan(Color.BLUE), 12, snippet.length, 0) + } + } else { + snippetUi.text = "" + } + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = com.example.common_ui.databinding.MarkerDemoBinding.inflate(layoutInflater) + setContentView(binding.root) + + binding.rotationSeekBar.apply { + max = 360 + setOnSeekBarChangeListener(object: OnSeekBarChangeListener { + + /** Called when the Rotation progress bar is moved */ + override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) { + val rotation = seekBar?.progress?.toFloat() + checkReadyThen { markerRainbow.map { it.rotation = rotation ?: 0f } } + } + + override fun onStartTrackingTouch(p0: SeekBar?) { + // do nothing + } + + override fun onStopTrackingTouch(p0: SeekBar?) { + //do nothing + } + + } ) + } + + binding.customInfoWindowOptions.apply { + setOnCheckedChangeListener { _, _ -> + if (lastSelectedMarker?.isInfoWindowShown == true) { + // Refresh the info window when the info window's content has changed. + // must deal with the possibility that lastSelectedMarker has changed in + // another thread between the null check and this line, do this with !! + lastSelectedMarker?.showInfoWindow() + } + } + } + + binding.clearMap.setOnClickListener { onClearMap() } + binding.resetMap.setOnClickListener { onResetMap() } + binding.flat.setOnClickListener { onToggleFlat() } + + val mapFragment = supportFragmentManager.findFragmentById(R.id.map) as SupportMapFragment + OnMapAndViewReadyListener(mapFragment, this) + applyInsets(binding.mapContainer) + } + + /** + * This is the callback that is triggered when the GoogleMap has loaded and is ready for use + */ + override fun onMapReady(googleMap: GoogleMap?) { + + // return early if the map was not initialised properly + map = googleMap ?: return + + // create bounds that encompass every location we reference + val boundsBuilder = LatLngBounds.Builder() + // include all places we have markers for on the map + places.keys.map { place -> boundsBuilder.include(places.getValue(place)) } + val bounds = boundsBuilder.build() + + with(map) { + // Hide the zoom controls as the button panel will cover it. + uiSettings.isZoomControlsEnabled = false + + // Setting an info window adapter allows us to change the both the contents and + // look of the info window. + setInfoWindowAdapter(CustomInfoWindowAdapter()) + + // Set listeners for marker events. See the bottom of this class for their behavior. + setOnMarkerClickListener(this@MarkerDemoActivity) + setOnInfoWindowClickListener(this@MarkerDemoActivity) + setOnMarkerDragListener(this@MarkerDemoActivity) + setOnInfoWindowCloseListener(this@MarkerDemoActivity) + setOnInfoWindowLongClickListener(this@MarkerDemoActivity) + + // Override the default content description on the view, for accessibility mode. + // Ideally this string would be localised. + setContentDescription("Map with lots of markers.") + + moveCamera(CameraUpdateFactory.newLatLngBounds(bounds, 50)) + } + + // Add lots of markers to the googleMap. + addMarkersToMap() + + } + + /** + * Show all the specified markers on the map + */ + private fun addMarkersToMap() { + + val placeDetailsMap = mutableMapOf( + // Uses a coloured icon + "BRISBANE" to PlaceDetails( + position = places.getValue("BRISBANE"), + title = "Brisbane", + snippet = "Population: 2,074,200", + icon = BitmapDescriptorFactory + .defaultMarker(BitmapDescriptorFactory.HUE_AZURE) + ), + + // Uses a custom icon with the info window popping out of the center of the icon. + "SYDNEY" to PlaceDetails( + position = places.getValue("SYDNEY"), + title = "Sydney", + snippet = "Population: 4,627,300", + icon = BitmapDescriptorFactory.fromResource(R.drawable.arrow), + infoWindowAnchorX = 0.5f, + infoWindowAnchorY = 0.5f + ), + + // Will create a draggable marker. Long press to drag. + "MELBOURNE" to PlaceDetails( + position = places.getValue("MELBOURNE"), + title = "Melbourne", + snippet = "Population: 4,137,400", + draggable = true + ), + + // Use a vector drawable resource as a marker icon. + "ALICE_SPRINGS" to PlaceDetails( + position = places.getValue("ALICE_SPRINGS"), + title = "Alice Springs", + icon = vectorToBitmap( + R.drawable.ic_android, "#A4C639".toColorInt()) + ), + + // More markers for good measure + "PERTH" to PlaceDetails( + position = places.getValue("PERTH"), + title = "Perth", + snippet = "Population: 1,738,800" + ), + + "ADELAIDE" to PlaceDetails( + position = places.getValue("ADELAIDE"), + title = "Adelaide", + snippet = "Population: 1,213,000" + ) + + ) + + // add 4 markers on top of each other in Darwin with varying z-indexes + (0 until 4).map { + placeDetailsMap.put( + "DARWIN ${"$"}{it + 1}", PlaceDetails( + position = places.getValue("DARWIN"), + title = "Darwin Marker ${"$"}{it + 1}", + snippet = "z-index initially ${"$"}{it + 1}", + zIndex = it.toFloat() + ) + ) + } + + // place markers for each of the defined locations + placeDetailsMap.keys.map { + with(placeDetailsMap.getValue(it)) { + map.addMarker(MarkerOptions() + .position(position) + .title(title) + .snippet(snippet) + .icon(icon) + .infoWindowAnchor(infoWindowAnchorX, infoWindowAnchorY) + .draggable(draggable) + .zIndex(zIndex)) + + } + } + + // Creates a marker rainbow demonstrating how to create default marker icons of different + // hues (colors). + val numMarkersInRainbow = 12 + (0 until numMarkersInRainbow).mapTo(markerRainbow) { + map.addMarker(MarkerOptions().apply{ + position(LatLng( + -30 + 10 * sin(it * Math.PI / (numMarkersInRainbow - 1)), + 135 - 10 * cos(it * Math.PI / (numMarkersInRainbow - 1)) + )) + title("Marker ${"$"}it") + icon(BitmapDescriptorFactory.defaultMarker((it * 360 / numMarkersInRainbow) + .toFloat())) + flat(binding.flat.isChecked) + rotation(binding.rotationSeekBar.progress.toFloat()) + })!! + } + } + + /** + * Demonstrates converting a [Drawable] to a [BitmapDescriptor], + * for use as a marker icon. + */ + private fun vectorToBitmap(@DrawableRes id : Int, @ColorInt color : Int): BitmapDescriptor { + val vectorDrawable: Drawable? = ResourcesCompat.getDrawable(resources, id, null) + if (vectorDrawable == null) { + Log.e(TAG, "Resource not found") + return BitmapDescriptorFactory.defaultMarker() + } + val bitmap = createBitmap( + vectorDrawable.intrinsicWidth, + vectorDrawable.intrinsicHeight, + Bitmap.Config.ARGB_8888 + ) + val canvas = Canvas(bitmap) + vectorDrawable.setBounds(0, 0, canvas.width, canvas.height) + DrawableCompat.setTint(vectorDrawable, color) + vectorDrawable.draw(canvas) + return BitmapDescriptorFactory.fromBitmap(bitmap) + } + + private fun onClearMap() { + checkReadyThen { map.clear() } + } + + private fun onResetMap() { + checkReadyThen { + map.clear() + addMarkersToMap() + } + } + + private fun onToggleFlat() { + checkReadyThen { markerRainbow.map { marker -> marker.isFlat = binding.flat.isChecked } } + } + + // + // Marker related listeners. + // + override fun onMarkerClick(marker : Marker): Boolean { + + // Markers have a z-index that is settable and gettable. + marker.zIndex += 1.0f + Toast.makeText(this, "${"$"}{marker.title} z-index set to ${"$"}{marker.zIndex}", + Toast.LENGTH_SHORT).show() + + lastSelectedMarker = marker + + if (marker.position == places.getValue("PERTH")) { + // This causes the marker at Perth to bounce into position when it is clicked. + val handler = Handler(Looper.getMainLooper()) + val start = SystemClock.uptimeMillis() + val duration = 1500 + + val interpolator = BounceInterpolator() + + handler.post(object : Runnable { + override fun run() { + val elapsed = SystemClock.uptimeMillis() - start + val t = + (1 - interpolator.getInterpolation(elapsed.toFloat() / duration)).coerceAtLeast( + 0f + ) + marker.setAnchor(0.5f, 1.0f + 2 * t) + + // Post again 16ms later. + if (t > 0.0) { + handler.postDelayed(this, 16) + } + } + }) + } else if (marker.position == places.getValue("ADELAIDE")) { + // This causes the marker at Adelaide to change color and alpha. + marker.apply { + setIcon(BitmapDescriptorFactory.defaultMarker(random.nextFloat() * 360)) + alpha = random.nextFloat() + } + } + + // We return false to indicate that we have not consumed the event and that we wish + // for the default behavior to occur (which is for the camera to move such that the + // marker is centered and for the marker's info window to open, if it has one). + return false + } + + override fun onInfoWindowClick(marker : Marker) { + Toast.makeText(this, "Click Info Window", Toast.LENGTH_SHORT).show() + } + + override fun onInfoWindowClose(marker : Marker) { + Toast.makeText(this, "Close Info Window", Toast.LENGTH_SHORT).show() + } + + override fun onInfoWindowLongClick(marker : Marker) { + Toast.makeText(this, "Info Window long click", Toast.LENGTH_SHORT).show() + } + + override fun onMarkerDragStart(marker : Marker) { + binding.topText.text = getString(R.string.on_marker_drag_start) + } + + override fun onMarkerDragEnd(marker : Marker) { + binding.topText.text = getString(R.string.on_marker_drag_end) + } + + override fun onMarkerDrag(marker : Marker) { + binding.topText.text = getString(R.string.on_marker_drag, marker.position.latitude, marker.position.longitude) + } + + /** + * Checks if the map is ready, the executes the provided lambda function + * + * @param stuffToDo the code to be executed if the map is ready + */ + private fun checkReadyThen(stuffToDo : () -> Unit) { + if (!::map.isInitialized) { + Toast.makeText(this, R.string.map_not_ready, Toast.LENGTH_SHORT).show() + } else { + stuffToDo() + } + } +} +""".trimIndent(), + javaCode = """ +@Sample( + id = "marker_demo", + title = "Standard Markers & Info Windows", + description = "Placing markers, custom icons, draggable pins, and custom info window layouts.", + category = "Markers & Overlays", + complexity = Complexity.SIMPLE, + tags = {"#markers", "#infowindow", "#draggable", "#icons", "#anchor"}, + purpose = "Demonstrates adding standard markers with alpha, rotation, draggable pins, and custom InfoWindowAdapter views.", + successCriteria = "Tapping markers displays custom info windows with formatted content; dragging pins updates position.", + failureIndicators = "Info window clicks not detected or custom snippet styling not applied.", + framework = Framework.JAVA_VIEWS +) +public class MarkerDemoActivity extends SamplesBaseActivity implements + OnMarkerClickListener, + OnInfoWindowClickListener, + OnMarkerDragListener, + OnSeekBarChangeListener, + OnInfoWindowLongClickListener, + OnInfoWindowCloseListener, + OnMapAndViewReadyListener.OnGlobalLayoutAndMapReadyListener { + + private static final LatLng BRISBANE = new LatLng(-27.47093, 153.0235); + + private static final LatLng MELBOURNE = new LatLng(-37.81319, 144.96298); + + private static final LatLng DARWIN = new LatLng(-12.4634, 130.8456); + + private static final LatLng SYDNEY = new LatLng(-33.87365, 151.20689); + + private static final LatLng ADELAIDE = new LatLng(-34.92873, 138.59995); + + private static final LatLng PERTH = new LatLng(-31.952854, 115.857342); + + private static final LatLng ALICE_SPRINGS = new LatLng(-24.6980, 133.8807); + + private com.example.common_ui.databinding.MarkerDemoBinding binding; + + /** Demonstrates customizing the info window and/or its contents. */ + class CustomInfoWindowAdapter implements InfoWindowAdapter { + + // These are both viewgroups containing an ImageView with id "badge" and two TextViews with id + // "title" and "snippet". + private final View mWindow; + + private final View mContents; + + CustomInfoWindowAdapter() { + mWindow = getLayoutInflater().inflate(R.layout.custom_info_window, null); + mContents = getLayoutInflater().inflate(R.layout.custom_info_contents, null); + } + + @Override + public View getInfoWindow(Marker marker) { + if (binding.customInfoWindowOptions.getCheckedRadioButtonId() != R.id.custom_info_window) { + // This means that getInfoContents will be called. + return null; + } + render(marker, mWindow); + return mWindow; + } + + @Override + public View getInfoContents(Marker marker) { + if (binding.customInfoWindowOptions.getCheckedRadioButtonId() != R.id.custom_info_contents) { + // This means that the default info contents will be used. + return null; + } + render(marker, mContents); + return mContents; + } + + private void render(Marker marker, View view) { + int badge; + // Use the equals() method on a Marker to check for equals. Do not use ==. + if (marker.equals(mBrisbane)) { + badge = R.drawable.badge_qld; + } else if (marker.equals(mAdelaide)) { + badge = R.drawable.badge_sa; + } else if (marker.equals(mSydney)) { + badge = R.drawable.badge_nsw; + } else if (marker.equals(mMelbourne)) { + badge = R.drawable.badge_victoria; + } else if (marker.equals(mPerth)) { + badge = R.drawable.badge_wa; + } else if (marker.equals(mDarwin1)) { + badge = R.drawable.badge_nt; + } else if (marker.equals(mDarwin2)) { + badge = R.drawable.badge_nt; + } else if (marker.equals(mDarwin3)) { + badge = R.drawable.badge_nt; + } else if (marker.equals(mDarwin4)) { + badge = R.drawable.badge_nt; + } else { + // Passing 0 to setImageResource will clear the image view. + badge = 0; + } + ((ImageView) view.findViewById(R.id.badge)).setImageResource(badge); + + String title = marker.getTitle(); + TextView titleUi = view.findViewById(R.id.title); + if (title != null) { + // Spannable string allows us to edit the formatting of the text. + SpannableString titleText = new SpannableString(title); + titleText.setSpan(new ForegroundColorSpan(Color.RED), 0, titleText.length(), 0); + titleUi.setText(titleText); + } else { + titleUi.setText(""); + } + + String snippet = marker.getSnippet(); + TextView snippetUi = view.findViewById(R.id.snippet); + if (snippet != null && snippet.length() > 12) { + SpannableString snippetText = new SpannableString(snippet); + snippetText.setSpan(new ForegroundColorSpan(Color.MAGENTA), 0, 10, 0); + snippetText.setSpan(new ForegroundColorSpan(Color.BLUE), 12, snippet.length(), 0); + snippetUi.setText(snippetText); + } else { + snippetUi.setText(""); + } + } + } + + private GoogleMap mMap; + + private Marker mPerth; + + private Marker mSydney; + + private Marker mBrisbane; + + private Marker mAdelaide; + + private Marker mMelbourne; + + private Marker mDarwin1; + private Marker mDarwin2; + private Marker mDarwin3; + private Marker mDarwin4; + + + /** + * Keeps track of the last selected marker (though it may no longer be selected). This is + * useful for refreshing the info window. + */ + private Marker mLastSelectedMarker; + + private final List mMarkerRainbow = new ArrayList<>(); + + private final Random mRandom = new Random(); + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + binding = com.example.common_ui.databinding.MarkerDemoBinding.inflate(getLayoutInflater()); + setContentView(binding.getRoot()); + + binding.rotationSeekBar.setMax(360); + binding.rotationSeekBar.setOnSeekBarChangeListener(this); + + binding.customInfoWindowOptions.setOnCheckedChangeListener(new OnCheckedChangeListener() { + @Override + public void onCheckedChanged(RadioGroup group, int checkedId) { + if (mLastSelectedMarker != null && mLastSelectedMarker.isInfoWindowShown()) { + // Refresh the info window when the info window's content has changed. + mLastSelectedMarker.showInfoWindow(); + } + } + }); + + binding.clearMap.setOnClickListener(v -> onClearMap()); + binding.resetMap.setOnClickListener(v -> onResetMap()); + binding.flat.setOnClickListener(v -> onToggleFlat()); + + SupportMapFragment mapFragment = + (SupportMapFragment) getSupportFragmentManager().findFragmentById(R.id.map); + new OnMapAndViewReadyListener(mapFragment, this); + + applyInsets(binding.mapContainer); + } + + @Override + public void onMapReady(GoogleMap map) { + mMap = map; + + // Hide the zoom controls as the button panel will cover it. + mMap.getUiSettings().setZoomControlsEnabled(false); + + // Add lots of markers to the map. + addMarkersToMap(); + + // Setting an info window adapter allows us to change the both the contents and look of the + // info window. + mMap.setInfoWindowAdapter(new CustomInfoWindowAdapter()); + + // Set listeners for marker events. See the bottom of this class for their behavior. + mMap.setOnMarkerClickListener(this); + mMap.setOnInfoWindowClickListener(this); + mMap.setOnMarkerDragListener(this); + mMap.setOnInfoWindowCloseListener(this); + mMap.setOnInfoWindowLongClickListener(this); + + // Override the default content description on the view, for accessibility mode. + // Ideally this string would be localised. + mMap.setContentDescription("Map with lots of markers."); + + LatLngBounds bounds = new LatLngBounds.Builder() + .include(PERTH) + .include(SYDNEY) + .include(ADELAIDE) + .include(BRISBANE) + .include(MELBOURNE) + .include(DARWIN) + .build(); + mMap.moveCamera(CameraUpdateFactory.newLatLngBounds(bounds, 50)); + } + + private void addMarkersToMap() { + // Uses a colored icon. + mBrisbane = mMap.addMarker(new MarkerOptions() + .position(BRISBANE) + .title("Brisbane") + .snippet("Population: 2,074,200") + .icon(BitmapDescriptorFactory.defaultMarker(BitmapDescriptorFactory.HUE_AZURE))); + + // Uses a custom icon with the info window popping out of the center of the icon. + mSydney = mMap.addMarker(new MarkerOptions() + .position(SYDNEY) + .title("Sydney") + .snippet("Population: 4,627,300") + .icon(BitmapDescriptorFactory.fromResource(R.drawable.arrow)) + .infoWindowAnchor(0.5f, 0.5f)); + + // Creates a draggable marker. Long press to drag. + mMelbourne = mMap.addMarker(new MarkerOptions() + .position(MELBOURNE) + .title("Melbourne") + .snippet("Population: 4,137,400") + .draggable(true)); + + // Place four markers on top of each other with differing z-indexes. + mDarwin1 = mMap.addMarker(new MarkerOptions() + .position(DARWIN) + .title("Darwin Marker 1") + .snippet("z-index 1") + .zIndex(1)); + mDarwin2 = mMap.addMarker(new MarkerOptions() + .position(DARWIN) + .title("Darwin Marker 2") + .snippet("z-index 2") + .zIndex(2)); + mDarwin3 = mMap.addMarker(new MarkerOptions() + .position(DARWIN) + .title("Darwin Marker 3") + .snippet("z-index 3") + .zIndex(3)); + mDarwin4 = mMap.addMarker(new MarkerOptions() + .position(DARWIN) + .title("Darwin Marker 4") + .snippet("z-index 4") + .zIndex(4)); + + + // A few more markers for good measure. + mPerth = mMap.addMarker(new MarkerOptions() + .position(PERTH) + .title("Perth") + .snippet("Population: 1,738,800")); + mAdelaide = mMap.addMarker(new MarkerOptions() + .position(ADELAIDE) + .title("Adelaide") + .snippet("Population: 1,213,000")); + + // Vector drawable resource as a marker icon. + mMap.addMarker(new MarkerOptions() + .position(ALICE_SPRINGS) + .icon(vectorToBitmap(R.drawable.ic_android, Color.parseColor("#A4C639"))) + .title("Alice Springs")); + + // Creates a marker rainbow demonstrating how to create default marker icons of different + // hues (colors). + float rotation = binding.rotationSeekBar.getProgress(); + boolean flat = binding.flat.isChecked(); + + int numMarkersInRainbow = 12; + for (int i = 0; i < numMarkersInRainbow; i++) { + Marker marker = mMap.addMarker(new MarkerOptions() + .position(new LatLng( + -30 + 10 * Math.sin(i * Math.PI / (numMarkersInRainbow - 1)), + 135 - 10 * Math.cos(i * Math.PI / (numMarkersInRainbow - 1)))) + .title("Marker " + i) + .icon(BitmapDescriptorFactory.defaultMarker(i * 360 / numMarkersInRainbow)) + .flat(flat) + .rotation(rotation)); + mMarkerRainbow.add(marker); + } + } + + /** + * Demonstrates converting a {@link Drawable} to a {@link BitmapDescriptor}, + * for use as a marker icon. + */ + private BitmapDescriptor vectorToBitmap(@DrawableRes int id, @ColorInt int color) { + Drawable vectorDrawable = ResourcesCompat.getDrawable(getResources(), id, null); + Bitmap bitmap = Bitmap.createBitmap(vectorDrawable.getIntrinsicWidth(), + vectorDrawable.getIntrinsicHeight(), Bitmap.Config.ARGB_8888); + Canvas canvas = new Canvas(bitmap); + vectorDrawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight()); + DrawableCompat.setTint(vectorDrawable, color); + vectorDrawable.draw(canvas); + return BitmapDescriptorFactory.fromBitmap(bitmap); + } + + private boolean checkReady() { + if (mMap == null) { + Toast.makeText(this, R.string.map_not_ready, Toast.LENGTH_SHORT).show(); + return false; + } + return true; + } + + private void onClearMap() { + if (!checkReady()) { + return; + } + mMap.clear(); + } + + private void onResetMap() { + if (!checkReady()) { + return; + } + // Clear the map because we don't want duplicates of the markers. + mMap.clear(); + addMarkersToMap(); + } + + private void onToggleFlat() { + if (!checkReady()) { + return; + } + boolean flat = binding.flat.isChecked(); + for (Marker marker : mMarkerRainbow) { + marker.setFlat(flat); + } + } + + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + if (!checkReady()) { + return; + } + float rotation = seekBar.getProgress(); + for (Marker marker : mMarkerRainbow) { + marker.setRotation(rotation); + } + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + // Do nothing. + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + // Do nothing. + } + + // + // Marker related listeners. + // + + @Override + public boolean onMarkerClick(final Marker marker) { + if (marker.equals(mPerth)) { + // This causes the marker at Perth to bounce into position when it is clicked. + final Handler handler = new Handler(Looper.getMainLooper()); + final long start = SystemClock.uptimeMillis(); + final long duration = 1500; + + final Interpolator interpolator = new BounceInterpolator(); + + handler.post(new Runnable() { + @Override + public void run() { + long elapsed = SystemClock.uptimeMillis() - start; + float t = Math.max( + 1 - interpolator.getInterpolation((float) elapsed / duration), 0); + marker.setAnchor(0.5f, 1.0f + 2 * t); + + if (t > 0.0) { + // Post again 16ms later. + handler.postDelayed(this, 16); + } + } + }); + } else if (marker.equals(mAdelaide)) { + // This causes the marker at Adelaide to change color and alpha. + marker.setIcon(BitmapDescriptorFactory.defaultMarker(mRandom.nextFloat() * 360)); + marker.setAlpha(mRandom.nextFloat()); + } + + // Markers have a z-index that is settable and gettable. + float zIndex = marker.getZIndex() + 1.0f; + marker.setZIndex(zIndex); + Toast.makeText(this, marker.getTitle() + " z-index set to " + zIndex, + Toast.LENGTH_SHORT).show(); + + mLastSelectedMarker = marker; + // We return false to indicate that we have not consumed the event and that we wish + // for the default behavior to occur (which is for the camera to move such that the + // marker is centered and for the marker's info window to open, if it has one). + return false; + } + + @Override + public void onInfoWindowClick(Marker marker) { + Toast.makeText(this, "Click Info Window", Toast.LENGTH_SHORT).show(); + } + + @Override + public void onInfoWindowClose(Marker marker) { + //Toast.makeText(this, "Close Info Window", Toast.LENGTH_SHORT).show(); + } + + @Override + public void onInfoWindowLongClick(Marker marker) { + Toast.makeText(this, "Info Window long click", Toast.LENGTH_SHORT).show(); + } + + @Override + public void onMarkerDragStart(Marker marker) { + binding.topText.setText(R.string.on_marker_drag_start); + } + + @Override + public void onMarkerDragEnd(Marker marker) { + binding.topText.setText(R.string.on_marker_drag_end); + } + + @Override + public void onMarkerDrag(Marker marker) { + binding.topText.setText(getString(R.string.on_marker_drag, marker.getPosition().latitude, marker.getPosition().longitude)); + } + +} +""".trimIndent() + ), + + "com.example.kotlindemos.EventsDemoActivity" to SnippetPair( + regionTag = "maps_android_sample_events", + kotlinCode = """ +@Sample( + id = "events_demo", + title = "Events & Gestures", + description = "Handling map taps, long clicks, camera change events, and POI selections.", + category = "Events & Gestures", + complexity = Complexity.SNIPPET, + tags = ["#events", "#gestures", "#clicks", "#poi", "#listeners"], + purpose = "Demonstrates registering listeners for map clicks, long presses, camera moves, and POI selections.", + successCriteria = "Event log text updates with coordinates and POI names upon user interaction.", + failureIndicators = "Click events swallowed or POI name unresolved.", + framework = Framework.KOTLIN_VIEWS +) +class EventsDemoActivity : SamplesBaseActivity(), OnMapClickListener, + OnMapLongClickListener, OnCameraIdleListener, OnCameraMoveListener, OnMapReadyCallback { + + private lateinit var tapTextView: TextView + private lateinit var cameraTextView: TextView + private lateinit var map: GoogleMap + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.events_demo) + tapTextView = findViewById(R.id.tap_text) + cameraTextView = findViewById(R.id.camera_text) + val mapFragment = supportFragmentManager.findFragmentById(R.id.map) as SupportMapFragment? + mapFragment?.getMapAsync(this) + applyInsets(findViewById(R.id.map_container)) + } + + override fun onMapReady(googleMap: GoogleMap) { + map = googleMap + map.setOnMapClickListener(this) + map.setOnMapLongClickListener(this) + map.setOnCameraMoveListener(this) + map.setOnCameraIdleListener(this) + updateCameraPosition() + } + + override fun onMapClick(point: LatLng) { + val lat = String.format(Locale.US, "%.6f", point.latitude) + val lng = String.format(Locale.US, "%.6f", point.longitude) + tapTextView.text = getString(R.string.events_tapped_format, lat, lng) + } + + override fun onMapLongClick(point: LatLng) { + val lat = String.format(Locale.US, "%.6f", point.latitude) + val lng = String.format(Locale.US, "%.6f", point.longitude) + tapTextView.text = getString(R.string.events_long_pressed_format, lat, lng) + } + + override fun onCameraMove() { + updateCameraPosition() + } + + override fun onCameraIdle() { + updateCameraPosition() + } + + private fun updateCameraPosition() { + if (!::map.isInitialized) return + val pos = map.cameraPosition + val lat = String.format(Locale.US, "%.6f", pos.target.latitude) + val lng = String.format(Locale.US, "%.6f", pos.target.longitude) + val zoom = String.format(Locale.US, "%.1f", pos.zoom) + val tilt = String.format(Locale.US, "%.1f", pos.tilt) + val bearing = String.format(Locale.US, "%.1f", pos.bearing) + cameraTextView.text = getString(R.string.events_camera_position_format, lat, lng, zoom, tilt, bearing) + } +} +""".trimIndent(), + javaCode = """ +@Sample( + id = "events_demo", + title = "Events & Gestures", + description = "Handling map taps, long clicks, camera change events, and POI selections.", + category = "Events & Gestures", + complexity = Complexity.SNIPPET, + tags = {"#events", "#gestures", "#clicks", "#poi", "#listeners"}, + purpose = "Demonstrates registering listeners for map clicks, long presses, camera moves, and POI selections.", + successCriteria = "Event log text updates with coordinates and POI names upon user interaction.", + failureIndicators = "Click events swallowed or POI name unresolved.", + framework = Framework.JAVA_VIEWS +) +public class EventsDemoActivity extends SamplesBaseActivity + implements OnMapClickListener, OnMapLongClickListener, OnCameraIdleListener, + GoogleMap.OnCameraMoveListener, OnMapReadyCallback { + + private TextView tapTextView; + private TextView cameraTextView; + private GoogleMap map; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(com.example.common_ui.R.layout.events_demo); + + tapTextView = findViewById(com.example.common_ui.R.id.tap_text); + cameraTextView = findViewById(com.example.common_ui.R.id.camera_text); + + SupportMapFragment mapFragment = + (SupportMapFragment) getSupportFragmentManager().findFragmentById(com.example.common_ui.R.id.map); + mapFragment.getMapAsync(this); + applyInsets(findViewById(com.example.common_ui.R.id.map_container)); + } + + @Override + public void onMapReady(GoogleMap map) { + this.map = map; + this.map.setOnMapClickListener(this); + this.map.setOnMapLongClickListener(this); + this.map.setOnCameraMoveListener(this); + this.map.setOnCameraIdleListener(this); + updateCameraPosition(); + } + + @Override + public void onMapClick(LatLng point) { + String lat = String.format(Locale.US, "%.6f", point.latitude); + String lng = String.format(Locale.US, "%.6f", point.longitude); + tapTextView.setText(getString(com.example.common_ui.R.string.events_tapped_format, lat, lng)); + } + + @Override + public void onMapLongClick(LatLng point) { + String lat = String.format(Locale.US, "%.6f", point.latitude); + String lng = String.format(Locale.US, "%.6f", point.longitude); + tapTextView.setText(getString(com.example.common_ui.R.string.events_long_pressed_format, lat, lng)); + } + + @Override + public void onCameraMove() { + updateCameraPosition(); + } + + @Override + public void onCameraIdle() { + updateCameraPosition(); + } + + private void updateCameraPosition() { + if (map == null) return; + com.google.android.gms.maps.model.CameraPosition pos = map.getCameraPosition(); + String lat = String.format(Locale.US, "%.6f", pos.target.latitude); + String lng = String.format(Locale.US, "%.6f", pos.target.longitude); + String zoom = String.format(Locale.US, "%.1f", pos.zoom); + String tilt = String.format(Locale.US, "%.1f", pos.tilt); + String bearing = String.format(Locale.US, "%.1f", pos.bearing); + cameraTextView.setText(getString( + com.example.common_ui.R.string.events_camera_position_format, + lat, + lng, + zoom, + tilt, + bearing + )); + } +} +""".trimIndent() + ), + + "com.example.kotlindemos.CameraDemoActivity" to SnippetPair( + regionTag = "maps_camera_events", + kotlinCode = """ +@Sample( + id = "camera_demo", + title = "Camera Controls & Animation", + description = "Programmatic camera panning, zooming, tilt, bearing, and smooth animations.", + category = "Camera Controls", + complexity = Complexity.SIMPLE, + tags = ["#camera", "#animation", "#bearing", "#tilt", "#zoom", "#pan"], + purpose = "Demonstrates programmatic camera movements, animated transitions, tilt angles, and bearing rotations.", + successCriteria = "Buttons animate camera smoothly with custom durations, stops, and rotation angles.", + failureIndicators = "Jerky animations, unexpected camera jumps, or tilt angle exceeding platform constraints.", + framework = Framework.KOTLIN_VIEWS +) +class CameraDemoActivity : + SamplesBaseActivity(), + OnCameraMoveStartedListener, + OnCameraMoveListener, + OnCameraMoveCanceledListener, + OnCameraIdleListener, + OnMapReadyCallback { + + + private lateinit var map: GoogleMap + + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = CameraDemoBinding.inflate(layoutInflater) + setContentView(binding.root) + + + val mapFragment = supportFragmentManager.findFragmentById(R.id.map) as SupportMapFragment + mapFragment.getMapAsync(this) + applyInsets(binding.mapContainer) + + binding.bondi.setOnClickListener(this::onGoToBondi) + binding.sydney.setOnClickListener(this::onGoToSydney) + binding.stopAnimation.setOnClickListener(this::onStopAnimation) + binding.animate.setOnClickListener(this::onToggleAnimate) + binding.scrollLeft.setOnClickListener(this::onScrollLeft) + binding.scrollUp.setOnClickListener(this::onScrollUp) + binding.scrollDown.setOnClickListener(this::onScrollDown) + binding.scrollRight.setOnClickListener(this::onScrollRight) + binding.zoomIn.setOnClickListener(this::onZoomIn) + binding.zoomOut.setOnClickListener(this::onZoomOut) + binding.tiltMore.setOnClickListener(this::onTiltMore) + binding.tiltLess.setOnClickListener(this::onTiltLess) + binding.durationToggle.setOnClickListener(this::onToggleCustomDuration) + } + + + + override fun onMapReady(googleMap: GoogleMap) { + map = googleMap + // return early if the map was not initialised properly + with(googleMap) { + setOnCameraIdleListener(this@CameraDemoActivity) + setOnCameraMoveStartedListener(this@CameraDemoActivity) + setOnCameraMoveListener(this@CameraDemoActivity) + setOnCameraMoveCanceledListener(this@CameraDemoActivity) + + + // Show Sydney + moveCamera(CameraUpdateFactory.newLatLngZoom(sydneyLatLng, 10f)) + } + } + + + + override fun onCameraMoveStarted(reason: Int) { + + + var reasonText = "UNKNOWN_REASON" + + when (reason) { + OnCameraMoveStartedListener.REASON_GESTURE -> { + + reasonText = "GESTURE" + } + OnCameraMoveStartedListener.REASON_API_ANIMATION -> { + + reasonText = "API_ANIMATION" + } + OnCameraMoveStartedListener.REASON_DEVELOPER_ANIMATION -> { + + reasonText = "DEVELOPER_ANIMATION" + } + } + Log.d(TAG, "onCameraMoveStarted(${"$"}reasonText)") + + } + + + + override fun onCameraMove() { + Log.d(TAG, "onCameraMove") + + } + + override fun onCameraMoveCanceled() { + + Log.d(TAG, "onCameraMoveCancelled") + } + + override fun onCameraIdle() { + + Log.d(TAG, "onCameraIdle") + } + +} +""".trimIndent(), + javaCode = """ +@Sample( + id = "camera_demo", + title = "Camera Controls & Animation", + description = "Programmatic camera panning, zooming, tilt, bearing, and smooth animations.", + category = "Camera Controls", + complexity = Complexity.SIMPLE, + tags = {"#camera", "#animation", "#bearing", "#tilt", "#zoom", "#pan"}, + purpose = "Demonstrates programmatic camera movements, animated transitions, tilt angles, and bearing rotations.", + successCriteria = "Buttons animate camera smoothly with custom durations, stops, and rotation angles.", + failureIndicators = "Jerky animations, unexpected camera jumps, or tilt angle exceeding platform constraints.", + framework = Framework.JAVA_VIEWS +) +public class CameraDemoActivity extends SamplesBaseActivity implements + OnCameraMoveStartedListener, + OnCameraMoveListener, + OnCameraMoveCanceledListener, + OnCameraIdleListener, + OnMapReadyCallback { + + + private GoogleMap map; + + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + binding = CameraDemoBinding.inflate(getLayoutInflater()); + setContentView(binding.getRoot()); + + + SupportMapFragment mapFragment = + (SupportMapFragment) getSupportFragmentManager().findFragmentById(com.example.common_ui.R.id.map); + mapFragment.getMapAsync(this); + applyInsets(binding.mapContainer); + + binding.bondi.setOnClickListener(this::onGoToBondi); + binding.sydney.setOnClickListener(this::onGoToSydney); + binding.stopAnimation.setOnClickListener(this::onStopAnimation); + binding.animate.setOnClickListener(this::onToggleAnimate); + binding.scrollLeft.setOnClickListener(this::onScrollLeft); + binding.scrollUp.setOnClickListener(this::onScrollUp); + binding.scrollDown.setOnClickListener(this::onScrollDown); + binding.scrollRight.setOnClickListener(this::onScrollRight); + binding.zoomIn.setOnClickListener(this::onZoomIn); + binding.zoomOut.setOnClickListener(this::onZoomOut); + binding.tiltMore.setOnClickListener(this::onTiltMore); + binding.tiltLess.setOnClickListener(this::onTiltLess); + binding.durationToggle.setOnClickListener(this::onToggleCustomDuration); + } + + + + @Override + public void onMapReady(GoogleMap googleMap) { + map = googleMap; + + map.setOnCameraIdleListener(this); + map.setOnCameraMoveStartedListener(this); + map.setOnCameraMoveListener(this); + map.setOnCameraMoveCanceledListener(this); + + + // Show Sydney + map.moveCamera(CameraUpdateFactory.newLatLngZoom(new LatLng(-33.87365, 151.20689), 10)); + } + + public GoogleMap getMap() { + return map; + } + + + + @Override + public void onCameraMoveStarted(int reason) { + + + String reasonText = "UNKNOWN_REASON"; + + switch (reason) { + case OnCameraMoveStartedListener.REASON_GESTURE: + + reasonText = "GESTURE"; + break; + case OnCameraMoveStartedListener.REASON_API_ANIMATION: + + reasonText = "API_ANIMATION"; + break; + case OnCameraMoveStartedListener.REASON_DEVELOPER_ANIMATION: + + reasonText = "DEVELOPER_ANIMATION"; + break; + } + Log.d(TAG, "onCameraMoveStarted(" + reasonText + ")"); + + } + + @Override + public void onCameraMove() { + + Log.d(TAG, "onCameraMove"); + } + + @Override + public void onCameraMoveCanceled() { + + Log.d(TAG, "onCameraMoveCancelled"); + } + + @Override + public void onCameraIdle() { + + Log.d(TAG, "onCameraIdle"); + } + + +} +""".trimIndent() + ), + + "com.example.kotlindemos.MyLocationDemoActivity" to SnippetPair( + regionTag = "maps_android_sample_my_location", + kotlinCode = """ +@Sample( + id = "my_location", + title = "My Location Layer", + description = "Enabling blue dot location indicator and My Location button with runtime permissions.", + category = "Location & Sensors", + complexity = Complexity.SIMPLE, + tags = ["#location", "#mylocation", "#permissions", "#bluedot"], + purpose = "Demonstrates requesting ACCESS_FINE_LOCATION permissions and enabling the blue dot location layer.", + successCriteria = "Tapping My Location button centers camera on user's current GPS position.", + failureIndicators = "Permission denial causes unhandled crash or location button missing.", + framework = Framework.KOTLIN_VIEWS +) +class MyLocationDemoActivity : SamplesBaseActivity(), + OnMyLocationButtonClickListener, + OnMyLocationClickListener, OnMapReadyCallback, + OnRequestPermissionsResultCallback { + /** + * Flag indicating whether a requested permission has been denied after returning in + * [.onRequestPermissionsResult]. + */ + private var permissionDenied = false + private lateinit var map: GoogleMap + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.my_location_demo) + val mapFragment = + supportFragmentManager.findFragmentById(R.id.map) as SupportMapFragment? + mapFragment?.getMapAsync(this) + applyInsets(findViewById(R.id.map_container)) + } + + override fun onMapReady(googleMap: GoogleMap) { + map = googleMap + googleMap.setOnMyLocationButtonClickListener(this) + googleMap.setOnMyLocationClickListener(this) + enableMyLocation() + } + + /** + * Enables the My Location layer if the fine location permission has been granted. + */ + @SuppressLint("MissingPermission") + private fun enableMyLocation() { + + // [START maps_check_location_permission] + // 1. Check if permissions are granted, if so, enable the my location layer + if (ContextCompat.checkSelfPermission( + this, + Manifest.permission.ACCESS_FINE_LOCATION + ) == PackageManager.PERMISSION_GRANTED || ContextCompat.checkSelfPermission( + this, + Manifest.permission.ACCESS_COARSE_LOCATION + ) == PackageManager.PERMISSION_GRANTED + ) { + map.isMyLocationEnabled = true + return + } + + // 2. If if a permission rationale dialog should be shown + if (ActivityCompat.shouldShowRequestPermissionRationale( + this, + Manifest.permission.ACCESS_FINE_LOCATION + ) || ActivityCompat.shouldShowRequestPermissionRationale( + this, + Manifest.permission.ACCESS_COARSE_LOCATION + ) + ) { + PermissionUtils.RationaleDialog.newInstance( + LOCATION_PERMISSION_REQUEST_CODE, true + ).show(supportFragmentManager, "dialog") + return + } + + // 3. Otherwise, request permission + ActivityCompat.requestPermissions( + this, + arrayOf( + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_COARSE_LOCATION + ), + LOCATION_PERMISSION_REQUEST_CODE + ) + // [END maps_check_location_permission] + } + + override fun onMyLocationButtonClick(): Boolean { + Toast.makeText(this, "MyLocation button clicked", Toast.LENGTH_SHORT) + .show() + // Return false so that we don't consume the event and the default behavior still occurs + // (the camera animates to the user's current position). + return false + } + + override fun onMyLocationClick(location: Location) { + Toast.makeText(this, "Current location:\n${"$"}location", Toast.LENGTH_LONG) + .show() + } + + // [START maps_check_location_permission_result] + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray + ) { + if (requestCode != LOCATION_PERMISSION_REQUEST_CODE) { + super.onRequestPermissionsResult( + requestCode, + permissions, + grantResults + ) + return + } + + if (isPermissionGranted( + permissions, + grantResults, + Manifest.permission.ACCESS_FINE_LOCATION + ) || isPermissionGranted( + permissions, + grantResults, + Manifest.permission.ACCESS_COARSE_LOCATION + ) + ) { + // Enable the my location layer if the permission has been granted. + enableMyLocation() + } else { + // Permission was denied. Display an error message + + } + } + + // [END maps_check_location_permission_result] + override fun onResumeFragments() { + super.onResumeFragments() + if (permissionDenied) { + // Permission was not granted, display error dialog. + showMissingPermissionError() + permissionDenied = false + } + } + + /** + * Displays a dialog with error message explaining that the location permission is missing. + */ + private fun showMissingPermissionError() { + newInstance(true).show(supportFragmentManager, "dialog") + } + + companion object { + /** + * Request code for location permission request. + * + * @see .onRequestPermissionsResult + */ + private const val LOCATION_PERMISSION_REQUEST_CODE = 1 + } +} +""".trimIndent(), + javaCode = """ +@Sample( + id = "my_location", + title = "My Location Layer", + description = "Enabling blue dot location indicator and My Location button with runtime permissions.", + category = "Location & Sensors", + complexity = Complexity.SIMPLE, + tags = {"#location", "#mylocation", "#permissions", "#bluedot"}, + purpose = "Demonstrates requesting ACCESS_FINE_LOCATION permissions and enabling the blue dot location layer.", + successCriteria = "Tapping My Location button centers camera on user's current GPS position.", + failureIndicators = "Permission denial causes unhandled crash or location button missing.", + framework = Framework.JAVA_VIEWS +) +public class MyLocationDemoActivity extends SamplesBaseActivity + implements + OnMyLocationButtonClickListener, + OnMyLocationClickListener, + OnMapReadyCallback, + ActivityCompat.OnRequestPermissionsResultCallback { + + /** + * Request code for location permission request. + * + * @see #onRequestPermissionsResult(int, String[], int[]) + */ + private static final int LOCATION_PERMISSION_REQUEST_CODE = 1; + + /** + * Flag indicating whether a requested permission has been denied after returning in {@link + * #onRequestPermissionsResult(int, String[], int[])}. + */ + private boolean permissionDenied = false; + + private GoogleMap map; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(com.example.common_ui.R.layout.my_location_demo); + + SupportMapFragment mapFragment = + (SupportMapFragment) getSupportFragmentManager().findFragmentById(com.example.common_ui.R.id.map); + mapFragment.getMapAsync(this); + applyInsets(findViewById(com.example.common_ui.R.id.map_container)); + } + + @Override + public void onMapReady(@NonNull GoogleMap googleMap) { + map = googleMap; + map.setOnMyLocationButtonClickListener(this); + map.setOnMyLocationClickListener(this); + enableMyLocation(); + } + + /** + * Enables the My Location layer if the fine location permission has been granted. + */ + @SuppressLint("MissingPermission") + private void enableMyLocation() { + // [START maps_check_location_permission] + // 1. Check if permissions are granted, if so, enable the my location layer + if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) + == PackageManager.PERMISSION_GRANTED + || ContextCompat.checkSelfPermission(this, permission.ACCESS_COARSE_LOCATION) + == PackageManager.PERMISSION_GRANTED) { + map.setMyLocationEnabled(true); + return; + } + + // 2. Otherwise, request location permissions from the user. + PermissionUtils.requestLocationPermissions(this, LOCATION_PERMISSION_REQUEST_CODE, true); + // [END maps_check_location_permission] + } + + @Override + public boolean onMyLocationButtonClick() { + Toast.makeText(this, "MyLocation button clicked", Toast.LENGTH_SHORT).show(); + // Return false so that we don't consume the event and the default behavior still occurs + // (the camera animates to the user's current position). + return false; + } + + @Override + public void onMyLocationClick(@NonNull Location location) { + Toast.makeText(this, "Current location:\n" + location, Toast.LENGTH_LONG).show(); + } + + // [START maps_check_location_permission_result] + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, + @NonNull int[] grantResults) { + if (requestCode != LOCATION_PERMISSION_REQUEST_CODE) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + return; + } + + if (PermissionUtils.isPermissionGranted(permissions, grantResults, + Manifest.permission.ACCESS_FINE_LOCATION) || PermissionUtils + .isPermissionGranted(permissions, grantResults, + Manifest.permission.ACCESS_COARSE_LOCATION)) { + // Enable the my location layer if the permission has been granted. + enableMyLocation(); + } else { + // Permission was denied. Display an error message + + } + } + // [END maps_check_location_permission_result] + + @Override + protected void onResumeFragments() { + super.onResumeFragments(); + if (permissionDenied) { + // Permission was not granted, display error dialog. + showMissingPermissionError(); + permissionDenied = false; + } + } + + /** + * Displays a dialog with error message explaining that the location permission is missing. + */ + private void showMissingPermissionError() { + PermissionUtils.PermissionDeniedDialog + .newInstance(true).show(getSupportFragmentManager(), "dialog"); + } + +} +""".trimIndent() + ), + + "com.example.kotlindemos.DataDrivenBoundariesActivity" to SnippetPair( + regionTag = "maps_android_data_driven_styling_boundaries", + kotlinCode = """ +// Add PopupMenu.OnMenuItemClickListener interface +@Sample( + id = "data_driven_boundaries", + title = "Data-Driven Boundaries", + description = "Dynamic styling and click handlers for administrative boundaries (Localities, States, Countries).", + category = "Data-Driven Styling", + complexity = Complexity.ADVANCED, + tags = ["#boundaries", "#datadriven", "#featurelayer", "#locality", "#choropleth"], + purpose = "Demonstrates styling administrative boundaries dynamically via FeatureLayer and capturing boundary clicks.", + successCriteria = "Boundaries render with custom stroke and fill colors; tapping a region highlights its polygon.", + failureIndicators = "Boundary layer is null (requires vector map / Map ID) or click listener not firing.", + framework = Framework.KOTLIN_VIEWS +) +class DataDrivenBoundariesActivity : SamplesBaseActivity(), OnMapReadyCallback, + FeatureLayer.OnFeatureClickListener, PopupMenu.OnMenuItemClickListener { + + private lateinit var map: GoogleMap + + private var localityLayer: FeatureLayer? = null + private var areaLevel1Layer: FeatureLayer? = null + private var countryLayer: FeatureLayer? = null + + private val HANA_HAWAII = LatLng(20.7522, -155.9877) // Hana, Hawaii + private val CENTER_US = LatLng(39.8283, -98.5795) // Approx center US + + // --- State Variables --- + private var localityEnabled = true // Default enabled + private var adminAreaEnabled = false + private var countryEnabled = false + private val selectedPlaceIds = mutableSetOf() // For selected countries + + // --- Style Factories (defined once) --- + private val localityStyleFactory: FeatureLayer.StyleFactory = createLocalityStyleFactory() + private val areaLevel1StyleFactory: FeatureLayer.StyleFactory = createAreaLevel1StyleFactory() + // Country factory references selectedPlaceIds, needs to be instance property or re-created if needed + private val countryStyleFactory: FeatureLayer.StyleFactory = createCountryStyleFactory() + + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + // Assumes layout is in common_ui module + setContentView(R.layout.data_driven_boundaries_demo) + + val mapId = (application as ApiDemoApplication).mapId + + // --- Map ID Check --- + if (mapId == null) { + finish() + return // Exit early if no valid Map ID + } + + // --- Programmatically create and add the map fragment --- + val mapOptions = GoogleMapOptions().apply { + mapId(mapId) + } + val mapFragment = SupportMapFragment.newInstance(mapOptions) + supportFragmentManager.beginTransaction() + .replace(R.id.map_fragment_container, mapFragment) // Use the container ID + .commit() + mapFragment.getMapAsync(this) + + // --- Setup Buttons --- + findViewById(R.id.button_hawaii).setOnClickListener { + centerMapOnLocation(HANA_HAWAII, 11f) // Adjusted zoom from Java + } + findViewById(R.id.button_us).setOnClickListener { + centerMapOnLocation(CENTER_US, 1f) // Adjusted zoom from Java + } + setupBoundarySelectorButton() // Setup the new selector button + + // --- Insets --- + applyInsets(findViewById(R.id.map_container)) // Apply insets if needed + } + + private fun setupBoundarySelectorButton() { + val stylingTypeButton: MaterialButton = findViewById(R.id.button_feature_type) // Find the button + stylingTypeButton.setOnClickListener { view -> + val popupMenu = PopupMenu(this, view) + val inflater: MenuInflater = popupMenu.menuInflater + inflater.inflate(R.menu.boundary_types_menu, popupMenu.menu) // Inflate your menu + + popupMenu.setOnMenuItemClickListener(this) // Set listener to this Activity + + // Set initial check states based on current flags + popupMenu.menu.findItem(R.id.boundary_type_locality)?.isChecked = localityEnabled + popupMenu.menu.findItem(R.id.boundary_type_administrative_area_level_1)?.isChecked = adminAreaEnabled + popupMenu.menu.findItem(R.id.boundary_type_country)?.isChecked = countryEnabled + + popupMenu.show() + } + } + + private fun centerMapOnLocation(location: LatLng, zoomLevel: Float) { + if (::map.isInitialized) { // Check if map is ready + map.moveCamera(CameraUpdateFactory.newLatLngZoom(location, zoomLevel)) + } else { + Log.w(TAG, "Map not initialized, cannot center map.") + } + } + + override fun onMapReady(googleMap: GoogleMap) { + map = googleMap + val capabilities: MapCapabilities = map.mapCapabilities + Log.d(TAG, "Data-driven Styling is available: ${"$"}{capabilities.isDataDrivenStylingAvailable}") + + if (!capabilities.isDataDrivenStylingAvailable) { + Toast.makeText( + this, + "Data-driven Styling is not available. See README.md for instructions.", + Toast.LENGTH_LONG + ).show() + } + + // Get feature layers + localityLayer = googleMap.getFeatureLayer( + FeatureLayerOptions.Builder() + .featureType(FeatureType.LOCALITY) + .build() + ) + areaLevel1Layer = googleMap.getFeatureLayer( + FeatureLayerOptions.Builder() + .featureType(FeatureType.ADMINISTRATIVE_AREA_LEVEL_1) + .build() + ) + countryLayer = googleMap.getFeatureLayer( + FeatureLayerOptions.Builder() + .featureType(FeatureType.COUNTRY) + .build() + ).also { + it.addOnFeatureClickListener(this) + } + + // Apply initial styles based on default flags + updateStyles() + + // Center map initially + centerMapOnLocation(HANA_HAWAII, 11f) + } + + /** + * Updates the styles based on the enabled flags. + */ + private fun updateStyles() { + Log.d(TAG, "Updating Styles: Locality=${"$"}localityEnabled, Admin1=${"$"}adminAreaEnabled, Country=${"$"}countryEnabled") + localityLayer?.featureStyle = if (localityEnabled) localityStyleFactory else null + areaLevel1Layer?.featureStyle = if (adminAreaEnabled) areaLevel1StyleFactory else null + countryLayer?.featureStyle = if (countryEnabled) countryStyleFactory else null + } + + // --- Style Factory Creation Methods --- + + private fun createLocalityStyleFactory(): FeatureLayer.StyleFactory { + val purple = 0x810FCB + // Define a style with purple fill at 50% opacity and solid purple border. + val fillColor = ColorUtils.setAlphaComponent(purple, (0.5f * 255).roundToInt()) + val strokeColor = ColorUtils.setAlphaComponent(purple, 255) // Fully opaque + + return FeatureLayer.StyleFactory { feature -> + if (feature is PlaceFeature && feature.placeId == "ChIJ0zQtYiWsVHkRk8lRoB1RNPo") { // Hana, HI + FeatureStyle.Builder() + .fillColor(fillColor) + .strokeColor(strokeColor) + .build() + } else { + null // No style for other localities + } + } + } + + private fun createAreaLevel1StyleFactory(): FeatureLayer.StyleFactory { + val alpha = (255 * 0.25).roundToInt() // 25% opacity + + return FeatureLayer.StyleFactory { feature -> + if (feature is PlaceFeature) { + // Generate a hue based on placeId hash + var hueColor = feature.placeId.hashCode() % 300 + if (hueColor < 0) hueColor += 300 + FeatureStyle.Builder() + .fillColor(Color.HSVToColor(alpha, floatArrayOf(hueColor.toFloat(), 1f, 1f))) + .build() + } else { + null + } + } + } + + private fun createCountryStyleFactory(): FeatureLayer.StyleFactory { + val defaultFillColor = ColorUtils.setAlphaComponent(Color.BLACK, (0.1f * 255).roundToInt()) // 10% Black + val selectedFillColor = ColorUtils.setAlphaComponent(Color.RED, (0.33f * 255).roundToInt()) // 33% Red + + return FeatureLayer.StyleFactory { feature -> + if (feature is PlaceFeature) { + // Check if this country's place ID is in our selected set + val fillColor = if (selectedPlaceIds.contains(feature.placeId)) { + selectedFillColor + } else { + defaultFillColor + } + FeatureStyle.Builder() + .fillColor(fillColor) + .strokeColor(Color.BLACK) // Solid black border + .build() + } else { + null + } + } + } + + // --- Listener Implementations --- + + /** + * Handles clicks on the Country Layer features. + */ + override fun onFeatureClick(event: FeatureClickEvent) { + val clickedPlaceIds = event.features + .filterIsInstance() // Get only PlaceFeatures + .map { it.placeId } // Extract their place IDs + + var changed = false + clickedPlaceIds.forEach { placeId -> + if (selectedPlaceIds.contains(placeId)) { + selectedPlaceIds.remove(placeId) + changed = true + } else { + selectedPlaceIds.add(placeId) + changed = true + } + } + + // If the selection changed and the country layer is enabled, re-apply its style + if (changed && countryEnabled) { + Log.d(TAG, "Country selection changed. Selected IDs: ${"$"}selectedPlaceIds") + countryLayer?.featureStyle = countryStyleFactory // Re-apply the factory + } else if (!countryEnabled) { + Log.d(TAG, "Country clicked but layer not enabled.") + // Optional: Show a toast? "Enable country layer to select" + } + } + + + /** + * Handles clicks on the PopupMenu items. + */ + override fun onMenuItemClick(item: MenuItem): Boolean { + val id = item.itemId + item.isChecked = !item.isChecked // Toggle the checkmark + + when (id) { + R.id.boundary_type_locality -> { + localityEnabled = item.isChecked + } + R.id.boundary_type_administrative_area_level_1 -> { + adminAreaEnabled = item.isChecked + } + R.id.boundary_type_country -> { + countryEnabled = item.isChecked + // If disabling country layer, clear selection visually (optional) + // if (!countryEnabled) selectedPlaceIds.clear() + } + else -> return false // Unknown item + } + + updateStyles() // Apply changes to map layers + return true + } +} +""".trimIndent(), + javaCode = """ +@Sample( + id = "data_driven_boundaries", + title = "Data-Driven Boundaries", + description = "Dynamic styling and click handlers for administrative boundaries (Localities, States, Countries).", + category = "Data-Driven Styling", + complexity = Complexity.ADVANCED, + tags = {"#boundaries", "#datadriven", "#featurelayer", "#locality", "#choropleth"}, + purpose = "Demonstrates styling administrative boundaries dynamically via FeatureLayer and capturing boundary clicks.", + successCriteria = "Boundaries render with custom stroke and fill colors; tapping a region highlights its polygon.", + failureIndicators = "Boundary layer is null (requires vector map / Map ID) or click listener not firing.", + framework = Framework.JAVA_VIEWS +) +public class DataDrivenBoundariesActivity extends SamplesBaseActivity implements OnMapReadyCallback, + FeatureLayer.OnFeatureClickListener, PopupMenu.OnMenuItemClickListener { + private static final String TAG = DataDrivenBoundariesActivity.class.getName(); + + private static final LatLng HANA_HAWAII = new LatLng(20.7522, -155.9877); // Hana, Hawaii + private static final LatLng CENTER_US = new LatLng(39.8283, -98.5795); // Approximate geographical center of the contiguous US + + private GoogleMap map; + + private FeatureLayer localityLayer = null; + private FeatureLayer areaLevel1Layer = null; + private FeatureLayer countryLayer = null; + + private final FeatureLayer.StyleFactory localityStyleFactory = getLocalityStyleFactory(); + private final FeatureLayer.StyleFactory countryStyleFactory = getCountryStyleFactory(); + private final FeatureLayer.StyleFactory areaLevel1StyleFactory = getAreaLevel1StyleFactory(); + + // Which layers are currently enabled + private boolean localityEnabled = true; + private boolean adminAreaEnabled = false; + private boolean countryEnabled = false; + + private final Set selectedPlaceIds = new HashSet<>(); + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + EdgeToEdge.enable(this); + setContentView(R.layout.data_driven_boundaries_demo); + + + + // --- Programmatically Create and Add Map Fragment --- + // 1. Create GoogleMapOptions + GoogleMapOptions mapOptions = new GoogleMapOptions(); + + // 2. Set the mapId from the secrets.properties file + mapOptions.mapId(mapId); + // 3. Create SupportMapFragment instance with options + SupportMapFragment mapFragment = SupportMapFragment.newInstance(mapOptions); + + // 4. Add the fragment to your FrameLayout container using FragmentManager + FragmentManager fragmentManager = getSupportFragmentManager(); + FragmentTransaction fragmentTransaction = fragmentManager.beginTransaction(); + fragmentTransaction.replace(R.id.map_fragment_container, mapFragment); // Use the container ID from XML + fragmentTransaction.commit(); + // --- End Programmatic Creation --- + + mapFragment.getMapAsync(this); + + findViewById(R.id.button_hawaii).setOnClickListener(view -> centerMapOnLocation(HANA_HAWAII, 11f)); + findViewById(R.id.button_us).setOnClickListener(view -> centerMapOnLocation(CENTER_US, 1f)); + + applyInsets(findViewById(R.id.map_container)); + + setupBoundarySelectorButton(); + + + } + + private void setupBoundarySelectorButton() { + MaterialButton stylingTypeButton = findViewById(R.id.button_feature_type); + stylingTypeButton.setOnClickListener(v -> { + PopupMenu popupMenu = new PopupMenu(this, v); + MenuInflater inflater = popupMenu.getMenuInflater(); + inflater.inflate(R.menu.boundary_types_menu, popupMenu.getMenu()); + + popupMenu.setOnMenuItemClickListener(this); + + popupMenu.getMenu().findItem(R.id.boundary_type_locality).setChecked(localityEnabled); + popupMenu.getMenu().findItem(R.id.boundary_type_administrative_area_level_1).setChecked(adminAreaEnabled); + popupMenu.getMenu().findItem(R.id.boundary_type_country).setChecked(countryEnabled); + popupMenu.show(); + }); + } + // [END_EXCLUDE] + + private void centerMapOnLocation(LatLng location, float zoomLevel) { + map.moveCamera(CameraUpdateFactory.newLatLngZoom(location, zoomLevel)); + } + + @Override + public void onMapReady(@NonNull GoogleMap googleMap) { + this.map = googleMap; + MapCapabilities capabilities = map.getMapCapabilities(); + Log.d(TAG, "Data-driven Styling is available: " + capabilities.isDataDrivenStylingAvailable()); + + if (!capabilities.isDataDrivenStylingAvailable()) { + Toast.makeText( + this, + "Data-driven Styling is not available. See README.md for instructions.", + Toast.LENGTH_LONG + ).show(); + } + + // Gets the LOCALITY feature layer. + localityLayer = googleMap.getFeatureLayer( + new FeatureLayerOptions.Builder() + .featureType(FeatureType.LOCALITY) + .build() + ); + + // Gets the ADMINISTRATIVE_AREA_LEVEL_1 feature layer. + areaLevel1Layer = googleMap.getFeatureLayer( + new FeatureLayerOptions.Builder() + .featureType(FeatureType.ADMINISTRATIVE_AREA_LEVEL_1) + .build() + ); + + // Gets the COUNTRY feature layer. + countryLayer = googleMap.getFeatureLayer( + new FeatureLayerOptions.Builder() + .featureType(FeatureType.COUNTRY) + .build() + ); + countryLayer.addOnFeatureClickListener(this); + + centerMapOnLocation(HANA_HAWAII, 11f); + + // Apply the current set of styles. + updateStyles(); + } + + /** + * Updates the styles of the locality, area level 1, and country layers based on the current + * state of the `localityEnabled`, `adminAreaEnabled`, and `countryEnabled` flags. + *

+ * For each layer, if the corresponding flag is true, the layer's features will be styled using + * the layer specific style factory function. + */ + private void updateStyles() { + if (localityLayer != null && areaLevel1Layer != null && countryLayer != null) { + localityLayer.setFeatureStyle(localityEnabled ? localityStyleFactory : null); + areaLevel1Layer.setFeatureStyle(adminAreaEnabled ? areaLevel1StyleFactory : null); + if (countryEnabled) { + countryLayer.setFeatureStyle(countryStyleFactory); + } else { + countryLayer.setFeatureStyle(null); + } + } + } + + /** + * Creates a StyleFactory for a FeatureLayer that styles Hana, HI on its Place ID. + *

+ * This method defines a style factory that checks if a given feature is a {@link PlaceFeature}. + * and if that feature matches "ChIJ0zQtYiWsVHkRk8lRoB1RNPo" (Hana, HI) applies a specific style. + * Otherwise, it returns null, indicating no specific styling is applied. + * + * @return A {@link FeatureLayer.StyleFactory} instance that can be used to style features in a FeatureLayer. + * The factory returns a {@link FeatureStyle} for Hana, HI, and null for other features. + */ + private static FeatureLayer.StyleFactory getLocalityStyleFactory() { + int purple = 0x810FCB; + // Define a style with purple fill at 50% opacity and + // solid purple border. + int fillColor = setAlphaValueOnColor(purple, 0.5f); + int strokeColor = setAlphaValueOnColor(purple, 1f); + + return feature -> { + // Check if the feature is an instance of PlaceFeature, + // which contains a place ID. + if (feature instanceof PlaceFeature placeFeature) { + + // Determine if the place ID is for Hana, HI. + if ("ChIJ0zQtYiWsVHkRk8lRoB1RNPo".equals(placeFeature.getPlaceId())) { + // Use FeatureStyle.Builder to configure the FeatureStyle object + // returned by the style factory function. + return new FeatureStyle.Builder() + .fillColor(fillColor) + .strokeColor(strokeColor) + .build(); + } + } + return null; + }; + } + + /** + * Creates a StyleFactory for area level 1 features (e.g., states, provinces). + *

+ * This factory provides a semi-transparent fill color for each area level 1 feature. + *

+ * @return A StyleFactory that can be used to style area level 1 features on a map. + */ + private static FeatureLayer.StyleFactory getAreaLevel1StyleFactory() { + int alpha = (int) (255 * 0.25); + + return feature -> { + if (feature instanceof PlaceFeature placeFeature) { + + // Return a hueColor in the range [-299,299]. If the value is + // negative, add 300 to make the value positive. + int hueColor = placeFeature.getPlaceId().hashCode() % 300; + if (hueColor < 0) { + hueColor += 300; + } + return new FeatureStyle.Builder() + // Set the fill color for the state based on the hashed hue color. + .fillColor(Color.HSVToColor(alpha, new float[]{hueColor, 1f, 1f})) + .build(); + } + return null; + }; + } + + /** + * Creates a StyleFactory for styling country features on a FeatureLayer highlighting selected + * countries. Selection is determined via the selectedPlaceIds set. + *

+ * *Note:* If the set of selected countries changes, this function must be called to update the + * styling. + *

+ * @return A FeatureLayer.StyleFactory that can be used to style country features. + */ + private FeatureLayer.StyleFactory getCountryStyleFactory() { + int defaultFillColor = setAlphaValueOnColor(Color.BLACK, 0.1f); + int selectedFillColor = setAlphaValueOnColor(Color.RED, 0.33f); + return feature -> { + if (feature instanceof PlaceFeature) { + int fillColor = selectedPlaceIds.contains(((PlaceFeature) feature).getPlaceId()) ? selectedFillColor : defaultFillColor; + FeatureStyle.Builder build = new FeatureStyle.Builder(); + return build.fillColor(fillColor).strokeColor(Color.BLACK).build(); + } + return null; + }; + } + + /** + * Called when a feature is clicked on the map. It is only applied to the country layer. + *

+ * Each time a country is clicked, its place ID is added to the selectedPlaceIds set or removed + * if it was already present. Each time the set is + *

+ */ + @Override + public void onFeatureClick(@NonNull FeatureClickEvent event) { + // Get the list of features affected by the click using + // getPlaceIds() defined below. + List newSelectedPlaceIds = getPlaceIds(event.getFeatures()); + + for (String placeId : newSelectedPlaceIds) { + if (selectedPlaceIds.contains(placeId)) { + selectedPlaceIds.remove(placeId); + } else { + selectedPlaceIds.add(placeId); + } + } + + // Reset the feature styling + countryLayer.setFeatureStyle(countryStyleFactory); + } + + // Gets a List of place IDs from the FeatureClickEvent object. + private List getPlaceIds(List features) { + List placeIds = new ArrayList<>(); + for (Feature feature : features) { + if (feature instanceof PlaceFeature) { + placeIds.add(((PlaceFeature) feature).getPlaceId()); + } + } + return placeIds; + } + + private static int setAlphaValueOnColor(int color, float alpha) { + return (color & 0x00ffffff) | (round(alpha * 255) << 24); + } + + /** + * Handles the click events for menu items in the boundary type selection menu. + * This method is called when a user selects a boundary type (locality, administrative area, or country) from the menu. + * It toggles the checked state of the selected menu item and updates the corresponding boolean flags (localityEnabled, adminAreaEnabled, countryEnabled). + * Finally, it calls the {@link #updateStyles()} method to reflect the changes in the map's display. + * + * @param item The MenuItem that was clicked. + * @return True if the event was handled, false otherwise. In this case it always return true if one of the correct items was selected. + */ +} +""".trimIndent() + ), + + "com.example.kotlindemos.DataDrivenDatasetStylingActivity" to SnippetPair( + regionTag = "maps_android_data_driven_styling_datasets", + kotlinCode = """ +@Sample( + id = "data_driven_datasets", + title = "Data-Driven Dataset Styling", + description = "Styling custom geospatial datasets uploaded to Google Cloud Platform based on attributes.", + category = "Data-Driven Styling", + complexity = Complexity.ADVANCED, + tags = ["#datasets", "#datadriven", "#clouddata", "#attributes", "#filtering"], + purpose = "Demonstrates loading a Cloud Dataset FeatureLayer and applying dynamic style rules based on feature properties.", + successCriteria = "Dataset points and polygons display distinct styling according to attribute values.", + failureIndicators = "Dataset ID invalid or attributes fail to filter correctly.", + framework = Framework.KOTLIN_VIEWS +) +class DataDrivenDatasetStylingActivity : SamplesBaseActivity(), OnMapReadyCallback, FeatureLayer.OnFeatureClickListener { + private lateinit var mapContainer: ViewGroup + + private lateinit var map: GoogleMap + + private var datasetLayer: FeatureLayer? = null + + // The global id of the clicked dataset feature. + private var lastGlobalId: String? = null + + private data class DataSet( + val datasetId: String, + val bounds: LatLngBounds, + val callback: DataDrivenDatasetStylingActivity.() -> Unit + ) + + private val dataSets = mutableMapOf() + + private lateinit var buttonLayout: LinearLayout + + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() + super.onCreate(savedInstanceState) + + val mapId = (application as ApiDemoApplication).mapId + + // --- Map ID Check --- + if (mapId == null) { + finish() + return // Exit early if no valid Map ID + } + + if (dataSets.isEmpty()) { + with(dataSets) { + put( + getString(com.example.common_ui.R.string.boulder), + DataSet( + BuildConfig.BOULDER_DATASET_ID, + LatLngBounds(LatLng(39.920, -105.340), LatLng(40.090, -105.210)) + ) { styleBoulderDataset() } + ) + put( + getString(com.example.common_ui.R.string.new_york), + DataSet( + BuildConfig.NEW_YORK_DATASET_ID, + LatLngBounds(LatLng(40.7640, -73.9820), LatLng(40.8000, -73.9490)) + ) { styleNYCDataset() } + ) + put( + getString(com.example.common_ui.R.string.kyoto), + DataSet( + BuildConfig.KYOTO_DATASET_ID, + LatLngBounds(LatLng(34.9700, 135.7200), LatLng(35.0400, 135.8000)) + ) { styleKyotoDataset() } + ) + } + } + + setContentView(com.example.common_ui.R.layout.data_driven_styling_demo) + + mapContainer = findViewById(com.example.common_ui.R.id.map_container) + + // --- Programmatically create and add the map fragment --- + // 1. Create GoogleMapOptions + val mapOptions = GoogleMapOptions().apply { + // 2. Set the mapId using your BuildConfig field + mapId(mapId) + } + + // 3. Create SupportMapFragment instance with options + val mapFragment = SupportMapFragment.newInstance(mapOptions) + + // 4. Add the fragment to your FrameLayout container + supportFragmentManager.beginTransaction() + .replace(com.example.common_ui.R.id.map_fragment_container, mapFragment) // Use the container ID from XML + .commit() + // --- End of programmatic creation --- + + mapFragment.getMapAsync(this) + + // Set the click listener for each of the buttons + listOf(com.example.common_ui.R.id.button_kyoto, com.example.common_ui.R.id.button_ny, com.example.common_ui.R.id.button_boulder).forEach { viewId -> + findViewById