diff --git a/ApiDemos/project/common-ui/build.gradle.kts b/ApiDemos/project/common-ui/build.gradle.kts index 595680348..36f0235ef 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,26 @@ 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.core) + implementation(libs.activity.compose) + debugImplementation(libs.ui.tooling) + + // Lifecycle & Coroutines + implementation(libs.lifecycle.runtime.ktx) + testImplementation(libs.junit) + testImplementation(libs.truth) 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..6b82ae6c4 --- /dev/null +++ b/ApiDemos/project/common-ui/src/main/java/com/example/common_ui/catalog/compose/CatalogActivity.kt @@ -0,0 +1,93 @@ +/* + * 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.ActivityNotFoundException +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 = { + try { + val intent = Intent().setClassName(packageName, "com.example.reviewer.compose.ReviewerActivity") + startActivity(intent) + finish() + } catch (e: ActivityNotFoundException) { + Toast.makeText(this@CatalogActivity, "Reviewer mode is not available", Toast.LENGTH_SHORT).show() + } catch (e: Exception) { + Toast.makeText(this@CatalogActivity, "Could not switch to Reviewer mode: ${e.message}", Toast.LENGTH_SHORT).show() + } + } + ) + } + } + } + + 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 + } + + val targetPackage = when { + className.startsWith("com.example.mapdemo.") -> "com.example.mapdemo" + className.startsWith("com.example.kotlindemos.") -> "com.example.kotlindemos" + else -> packageName + } + + try { + val intent = Intent().apply { + setClassName(targetPackage, className) + putExtra("extra_sample_id", sample.id) + putExtra("extra_is_reviewer_mode", false) + } + startActivity(intent) + } catch (e: ActivityNotFoundException) { + Toast.makeText( + this, + "${framework.displayName} app ($targetPackage) is not installed", + Toast.LENGTH_LONG + ).show() + } 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/CatalogDialogs.kt b/ApiDemos/project/common-ui/src/main/java/com/example/common_ui/catalog/compose/CatalogDialogs.kt new file mode 100644 index 000000000..3208fa0e2 --- /dev/null +++ b/ApiDemos/project/common-ui/src/main/java/com/example/common_ui/catalog/compose/CatalogDialogs.kt @@ -0,0 +1,226 @@ +/* + * 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.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.example.common_ui.R +import com.example.common_ui.catalog.Framework +import com.example.common_ui.catalog.ReviewStatus +import com.example.common_ui.catalog.SampleItem + +/** + * Host component managing the display of catalog dialogs (confirmation, detail viewer, quick grading). + */ +@Composable +fun CatalogDialogHost( + state: CatalogState, + onSaveEvaluation: ((targetFqcn: String, status: ReviewStatus, notes: String, sample: SampleItem) -> Unit)?, + onClearEvaluations: (() -> Unit)?, + onLaunchSample: (SampleItem, Framework) -> Unit +) { + if (state.showClearConfirmDialog) { + ClearEvaluationsDialog( + onConfirm = { + onClearEvaluations?.invoke() + state.showClearConfirmDialog = false + }, + onDismiss = { state.showClearConfirmDialog = false } + ) + } + + state.activeSampleForDetail?.let { sample -> + val targetFqcn = sample.getTargetFqcn(state.selectedFramework) + val existingEval = state.evaluations[targetFqcn] ?: state.evaluations[sample.id] + SampleDetailFullScreenDialog( + sample = sample, + targetFqcn = targetFqcn, + framework = state.selectedFramework, + isReviewerMode = state.isReviewerMode, + existingEvaluation = existingEval, + onDismiss = { state.activeSampleDetailId = null }, + onSaveEvaluation = { status, notes -> + onSaveEvaluation?.invoke(targetFqcn, status, notes, sample) + state.activeSampleDetailId = null + }, + onLaunch = { fw -> + state.activeSampleDetailId = null + onLaunchSample(sample, fw) + } + ) + } + + state.activeQuickGrading?.let { (sample, gradeStatus) -> + val targetFqcn = sample.getTargetFqcn(state.selectedFramework) + val existingEval = state.evaluations[targetFqcn] ?: state.evaluations[sample.id] + + QuickGradingDialog( + sample = sample, + targetFqcn = targetFqcn, + gradeStatus = gradeStatus, + initialNotes = existingEval?.notes.orEmpty(), + onSave = { notes -> + onSaveEvaluation?.invoke(targetFqcn, gradeStatus, notes, sample) + state.activeQuickGradingSampleId = null + state.activeQuickGradingStatus = null + }, + onSaveAndNext = { notes -> + onSaveEvaluation?.invoke(targetFqcn, gradeStatus, notes, sample) + state.activeQuickGradingSampleId = null + state.activeQuickGradingStatus = null + val nextUnchecked = state.findNextUncheckedSample(currentSampleId = sample.id) + if (nextUnchecked != null) { + onLaunchSample(nextUnchecked, state.selectedFramework) + } + }, + onDismiss = { + state.activeQuickGradingSampleId = null + state.activeQuickGradingStatus = null + } + ) + } +} + +/** + * Confirmation dialog for clearing / resetting all evaluations back to unchecked. + */ +@Composable +fun ClearEvaluationsDialog( + onConfirm: () -> Unit, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + icon = { + Icon( + painter = painterResource(R.drawable.ic_undo), + 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 = onConfirm, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError + ) + ) { + Text("Reset All") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + } + ) +} + +/** + * Quick grading dialog for entering optional notes and saving or saving & advancing to the next sample. + */ +@Composable +fun QuickGradingDialog( + sample: SampleItem, + targetFqcn: String, + gradeStatus: ReviewStatus, + initialNotes: String, + onSave: (notes: String) -> Unit, + onSaveAndNext: (notes: String) -> Unit, + onDismiss: () -> Unit +) { + var notes by rememberSaveable { mutableStateOf(initialNotes) } + + AlertDialog( + onDismissRequest = onDismiss, + 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 = { onSave(notes) }) { + Text(if (gradeStatus == ReviewStatus.PASSING) "Save Pass πŸ‘" else "Save Issue ⚠️") + } + Button( + onClick = { onSaveAndNext(notes) }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary + ) + ) { + Text("Save & Next ⏭️", fontWeight = FontWeight.Bold) + } + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + } + ) +} diff --git a/ApiDemos/project/common-ui/src/main/java/com/example/common_ui/catalog/compose/CatalogFilterBar.kt b/ApiDemos/project/common-ui/src/main/java/com/example/common_ui/catalog/compose/CatalogFilterBar.kt new file mode 100644 index 000000000..cf30641aa --- /dev/null +++ b/ApiDemos/project/common-ui/src/main/java/com/example/common_ui/catalog/compose/CatalogFilterBar.kt @@ -0,0 +1,216 @@ +/* + * 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.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.PrimaryTabRow +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +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.Complexity +import com.example.common_ui.catalog.Framework +import com.example.common_ui.catalog.ReviewStatus +import com.example.common_ui.catalog.SampleCatalogRegistry + +/** + * Filter header composable housing framework tabs, search field, review status chips, + * complexity filters, and dynamic tag chips. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CatalogFilterBar( + selectedFramework: Framework, + onFrameworkSelected: (Framework) -> Unit, + searchQuery: String, + onSearchQueryChange: (String) -> Unit, + isReviewerMode: Boolean, + selectedStatusFilter: ReviewStatus?, + onStatusFilterSelected: (ReviewStatus?) -> Unit, + uncheckedCount: Int, + needsWorkCount: Int, + passingCount: Int, + selectedComplexity: Complexity?, + onComplexitySelected: (Complexity?) -> Unit, + selectedTags: Set, + onTagToggled: (String) -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface) + .padding(bottom = 6.dp) + ) { + // Framework Tabs + PrimaryTabRow( + selectedTabIndex = when (selectedFramework) { + Framework.KOTLIN_VIEWS -> 0 + Framework.JAVA_VIEWS -> 1 + } + ) { + Tab( + selected = selectedFramework == Framework.KOTLIN_VIEWS, + onClick = { onFrameworkSelected(Framework.KOTLIN_VIEWS) }, + text = { Text("πŸ’œ Kotlin Views", fontWeight = FontWeight.Bold) } + ) + Tab( + selected = selectedFramework == Framework.JAVA_VIEWS, + onClick = { onFrameworkSelected(Framework.JAVA_VIEWS) }, + text = { Text("β˜• Java Views", fontWeight = FontWeight.Bold) } + ) + } + + // Search Bar + OutlinedTextField( + value = searchQuery, + onValueChange = onSearchQueryChange, + 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 = { onSearchQueryChange("") }) { + 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 = { onStatusFilterSelected(null) }, + label = { Text("All Status") } + ) + FilterChip( + selected = selectedStatusFilter == ReviewStatus.UNCHECKED, + onClick = { + onStatusFilterSelected(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 = { + onStatusFilterSelected(if (selectedStatusFilter == ReviewStatus.NEEDS_WORK) null else ReviewStatus.NEEDS_WORK) + }, + label = { Text("πŸ”΄ Needs Work ($needsWorkCount)") } + ) + FilterChip( + selected = selectedStatusFilter == ReviewStatus.PASSING, + onClick = { + onStatusFilterSelected(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 = { onComplexitySelected(null) }, + label = { Text("All Complexity") } + ) + FilterChip( + selected = selectedComplexity == Complexity.SNIPPET, + onClick = { onComplexitySelected(if (selectedComplexity == Complexity.SNIPPET) null else Complexity.SNIPPET) }, + label = { Text("πŸ”Ή Snippet") } + ) + FilterChip( + selected = selectedComplexity == Complexity.SIMPLE, + onClick = { onComplexitySelected(if (selectedComplexity == Complexity.SIMPLE) null else Complexity.SIMPLE) }, + label = { Text("🟒 Simple") } + ) + FilterChip( + selected = selectedComplexity == Complexity.ADVANCED, + onClick = { onComplexitySelected(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 = { onTagToggled(tag) }, + label = { Text(tag, fontSize = 12.sp) } + ) + } + } + } +} diff --git a/ApiDemos/project/common-ui/src/main/java/com/example/common_ui/catalog/compose/CatalogFloatingActionButtons.kt b/ApiDemos/project/common-ui/src/main/java/com/example/common_ui/catalog/compose/CatalogFloatingActionButtons.kt new file mode 100644 index 000000000..a9fe0b755 --- /dev/null +++ b/ApiDemos/project/common-ui/src/main/java/com/example/common_ui/catalog/compose/CatalogFloatingActionButtons.kt @@ -0,0 +1,84 @@ +/* + * 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.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.Badge +import androidx.compose.material3.BadgedBox +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import com.example.common_ui.R + +/** + * Floating action buttons for Reviewer Mode: "Review Next" and "Generate Report". + */ +@Composable +fun CatalogFloatingActionButtons( + isReviewerMode: Boolean, + uncheckedCount: Int, + grievancesCount: Int, + onLaunchNextUnchecked: () -> Unit, + onExportGrievances: (() -> Unit)? +) { + if (!isReviewerMode) return + + Row( + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (uncheckedCount > 0) { + ExtendedFloatingActionButton( + onClick = onLaunchNextUnchecked, + 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( + painter = painterResource(R.drawable.ic_grievances), + contentDescription = "Generate Report" + ) + } + } + } + } +} diff --git a/ApiDemos/project/common-ui/src/main/java/com/example/common_ui/catalog/compose/CatalogSampleList.kt b/ApiDemos/project/common-ui/src/main/java/com/example/common_ui/catalog/compose/CatalogSampleList.kt new file mode 100644 index 000000000..f5a241748 --- /dev/null +++ b/ApiDemos/project/common-ui/src/main/java/com/example/common_ui/catalog/compose/CatalogSampleList.kt @@ -0,0 +1,147 @@ +/* + * 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.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.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.example.common_ui.catalog.Framework +import com.example.common_ui.catalog.ReviewStatus +import com.example.common_ui.catalog.SampleItem + +/** + * Main scrollable list for the catalog containing the filter bar, empty state, and sample cards. + */ +@Composable +fun CatalogSampleList( + state: CatalogState, + paddingValues: PaddingValues, + onLaunchSample: (SampleItem, Framework) -> Unit, + modifier: Modifier = Modifier +) { + LazyColumn( + state = state.lazyListState, + modifier = modifier + .fillMaxSize() + .padding(paddingValues), + contentPadding = PaddingValues(bottom = 80.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + item(key = "catalog_filter_bar") { + CatalogFilterBar( + selectedFramework = state.selectedFramework, + onFrameworkSelected = { state.selectedFramework = it }, + searchQuery = state.searchQuery, + onSearchQueryChange = { state.searchQuery = it }, + isReviewerMode = state.isReviewerMode, + selectedStatusFilter = state.selectedStatusFilter, + onStatusFilterSelected = { state.selectedStatusFilter = it }, + uncheckedCount = state.uncheckedCount, + needsWorkCount = state.needsWorkCount, + passingCount = state.passingCount, + selectedComplexity = state.selectedComplexity, + onComplexitySelected = { state.selectedComplexity = it }, + selectedTags = state.selectedTags, + onTagToggled = { tag -> + state.selectedTags = if (state.selectedTags.contains(tag)) state.selectedTags - tag else state.selectedTags + tag + } + ) + } + + val filteredSamples = state.filteredSamples + if (filteredSamples.isEmpty()) { + item(key = "empty_samples_state") { + CatalogEmptyState( + selectedStatusFilter = state.selectedStatusFilter, + onClearStatusFilter = { state.selectedStatusFilter = null } + ) + } + } else { + items(filteredSamples, key = { it.id }) { sample -> + val targetFqcn = sample.getTargetFqcn(state.selectedFramework) + val eval = state.evaluations[targetFqcn] ?: state.evaluations[sample.id] + val status = ReviewStatus.fromString(eval?.status) + Box(modifier = Modifier.padding(horizontal = 12.dp)) { + SampleComposeCard( + sample = sample, + targetFqcn = targetFqcn, + framework = state.selectedFramework, + isReviewerMode = state.isReviewerMode, + evaluation = eval, + status = status, + onSampleClick = { onLaunchSample(sample, state.selectedFramework) }, + onInfoClick = { state.activeSampleDetailId = sample.id }, + onQuickGrade = { gradeStatus -> + state.activeQuickGradingSampleId = sample.id + state.activeQuickGradingStatus = gradeStatus + } + ) + } + } + } + } +} + +/** + * Empty state view shown when no samples match the active filters or search query. + */ +@Composable +fun CatalogEmptyState( + selectedStatusFilter: ReviewStatus?, + onClearStatusFilter: () -> Unit, + modifier: Modifier = Modifier +) { + 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 = onClearStatusFilter) { + Text("Clear Status Filter") + } + } + } + } +} 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..f5ee4bd84 --- /dev/null +++ b/ApiDemos/project/common-ui/src/main/java/com/example/common_ui/catalog/compose/CatalogScreen.kt @@ -0,0 +1,87 @@ +/* + * 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.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.runtime.Composable +import com.example.common_ui.catalog.Framework +import com.example.common_ui.catalog.ReviewStatus +import com.example.common_ui.catalog.SampleEvaluation +import com.example.common_ui.catalog.SampleItem + +/** + * Clean, declarative multi-framework sample catalog and reviewer screen. + */ +@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 +) { + val state = rememberCatalogState( + isReviewerMode = isReviewerMode, + evaluations = evaluations + ) + + Scaffold( + topBar = { + CatalogTopBar( + isReviewerMode = isReviewerMode, + selectedFramework = state.selectedFramework, + sampleCount = state.filteredSamples.size, + uncheckedCount = state.uncheckedCount, + selectedStatusFilter = state.selectedStatusFilter, + onToggleUncheckedFilter = { + state.selectedStatusFilter = if (state.selectedStatusFilter == ReviewStatus.UNCHECKED) null else ReviewStatus.UNCHECKED + }, + onLaunchNextUnchecked = { state.launchNextUnchecked(onLaunchSample) }, + onClearEvaluationsClick = if (onClearEvaluations != null) { { state.showClearConfirmDialog = true } } else null, + onScrollToTop = state::scrollToTop, + onExportGrievances = onExportGrievances, + onSwitchMode = onSwitchMode + ) + }, + snackbarHost = { SnackbarHost(state.snackbarHostState) }, + floatingActionButton = { + CatalogFloatingActionButtons( + isReviewerMode = isReviewerMode, + uncheckedCount = state.uncheckedCount, + grievancesCount = state.grievancesCount, + onLaunchNextUnchecked = { state.launchNextUnchecked(onLaunchSample) }, + onExportGrievances = onExportGrievances + ) + } + ) { paddingValues -> + CatalogSampleList( + state = state, + paddingValues = paddingValues, + onLaunchSample = onLaunchSample + ) + } + + CatalogDialogHost( + state = state, + onSaveEvaluation = onSaveEvaluation, + onClearEvaluations = onClearEvaluations, + onLaunchSample = onLaunchSample + ) +} diff --git a/ApiDemos/project/common-ui/src/main/java/com/example/common_ui/catalog/compose/CatalogState.kt b/ApiDemos/project/common-ui/src/main/java/com/example/common_ui/catalog/compose/CatalogState.kt new file mode 100644 index 000000000..9fb712e4f --- /dev/null +++ b/ApiDemos/project/common-ui/src/main/java/com/example/common_ui/catalog/compose/CatalogState.kt @@ -0,0 +1,167 @@ +/* + * 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.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +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 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.CoroutineScope +import kotlinx.coroutines.launch + +/** + * Holds all state, filter selections, and query results for the sample catalog screen. + */ +@Stable +class CatalogState( + val isReviewerMode: Boolean, + val evaluations: Map, + val lazyListState: LazyListState, + val snackbarHostState: SnackbarHostState, + val coroutineScope: CoroutineScope, + initialFramework: Framework = Framework.KOTLIN_VIEWS +) { + var selectedFramework by mutableStateOf(initialFramework) + var selectedComplexity by mutableStateOf(null) + var selectedStatusFilter by mutableStateOf(null) + var selectedTags by mutableStateOf(emptySet()) + var searchQuery by mutableStateOf("") + + var activeSampleDetailId by mutableStateOf(null) + var activeQuickGradingSampleId by mutableStateOf(null) + var activeQuickGradingStatus by mutableStateOf(null) + var showClearConfirmDialog by mutableStateOf(false) + + val activeSampleForDetail: SampleItem? + get() = SampleCatalogRegistry.findById(activeSampleDetailId) + + val activeQuickGrading: Pair? + get() { + val sample = SampleCatalogRegistry.findById(activeQuickGradingSampleId) + val status = activeQuickGradingStatus + return if (sample != null && status != null) Pair(sample, status) else null + } + + val frameworkSamples: List + get() = SampleCatalogRegistry.filter(framework = selectedFramework) + + val statusCounts: Triple + get() { + 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++ + } + } + return Triple(unchecked, passing, needsWork) + } + + val uncheckedCount: Int get() = statusCounts.first + val passingCount: Int get() = statusCounts.second + val needsWorkCount: Int get() = statusCounts.third + + val filteredSamples: List + get() = 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] + ReviewStatus.fromString(eval?.status) == selectedStatusFilter + } + } + + val grievancesCount: Int + get() = evaluations.values.count { it.status == "NEEDS_WORK" || it.notes.isNotBlank() } + + fun findNextUncheckedSample(currentSampleId: String? = null): SampleItem? { + val searchList = if (currentSampleId != null) { + val allFw = frameworkSamples + val currIdx = allFw.indexOfFirst { it.id == currentSampleId } + if (currIdx >= 0) allFw.drop(currIdx + 1) + allFw.take(currIdx) else allFw + } else { + val inFiltered = filteredSamples.firstOrNull { isSampleUnchecked(it) } + if (inFiltered != null) return inFiltered + frameworkSamples + } + + return searchList.firstOrNull { isSampleUnchecked(it) } + } + + private fun isSampleUnchecked(sample: SampleItem): Boolean { + val targetFqcn = sample.getTargetFqcn(selectedFramework) + val eval = evaluations[targetFqcn] ?: evaluations[sample.id] + return eval == null || ReviewStatus.fromString(eval.status) == ReviewStatus.UNCHECKED + } + + fun launchNextUnchecked(onLaunchSample: (SampleItem, Framework) -> Unit) { + val next = findNextUncheckedSample() + if (next != null) { + onLaunchSample(next, selectedFramework) + } + } + + fun scrollToTop() { + coroutineScope.launch { + lazyListState.animateScrollToItem(0) + } + } +} + +/** + * Remembers and creates a [CatalogState] instance. + */ +@Composable +fun rememberCatalogState( + isReviewerMode: Boolean, + evaluations: Map, + lazyListState: LazyListState = rememberLazyListState(), + snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, + coroutineScope: CoroutineScope = rememberCoroutineScope() +): CatalogState { + return remember(isReviewerMode, evaluations, lazyListState, snackbarHostState, coroutineScope) { + CatalogState( + isReviewerMode = isReviewerMode, + evaluations = evaluations, + lazyListState = lazyListState, + snackbarHostState = snackbarHostState, + coroutineScope = coroutineScope + ) + } +} 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/CatalogTopBar.kt b/ApiDemos/project/common-ui/src/main/java/com/example/common_ui/catalog/compose/CatalogTopBar.kt new file mode 100644 index 000000000..bb451908d --- /dev/null +++ b/ApiDemos/project/common-ui/src/main/java/com/example/common_ui/catalog/compose/CatalogTopBar.kt @@ -0,0 +1,215 @@ +/* + * 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.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.Badge +import androidx.compose.material3.BadgedBox +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.example.common_ui.R +import com.example.common_ui.catalog.Framework +import com.example.common_ui.catalog.ReviewStatus + +/** + * Top app bar composable for the GMP Sample Catalog, providing title stats, quick filters, + * navigation shortcuts, and reviewer overflow actions. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CatalogTopBar( + isReviewerMode: Boolean, + selectedFramework: Framework, + sampleCount: Int, + uncheckedCount: Int, + selectedStatusFilter: ReviewStatus?, + onToggleUncheckedFilter: () -> Unit, + onLaunchNextUnchecked: () -> Unit, + onClearEvaluationsClick: (() -> Unit)?, + onScrollToTop: () -> Unit, + onExportGrievances: (() -> Unit)?, + onSwitchMode: (() -> Unit)? +) { + var showMoreMenu by remember { mutableStateOf(false) } + + 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} β€’ $sampleCount samples$filterSummary" + } else { + "Unified Multi-Framework Catalog β€’ $sampleCount 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 = onToggleUncheckedFilter) { + BadgedBox( + badge = { + if (uncheckedCount > 0) { + Badge { Text("$uncheckedCount") } + } + } + ) { + Icon( + painter = painterResource( + if (selectedStatusFilter == ReviewStatus.UNCHECKED) R.drawable.ic_status_passing + else R.drawable.ic_status_unchecked + ), + contentDescription = "Filter Unchecked Only", + tint = if (selectedStatusFilter == ReviewStatus.UNCHECKED) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(24.dp) + ) + } + } + + // Next Unchecked Action Button + if (uncheckedCount > 0) { + IconButton(onClick = onLaunchNextUnchecked) { + Icon( + painter = painterResource(R.drawable.ic_skip_next), + contentDescription = "Launch Next Unchecked Sample", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp) + ) + } + } + + // Direct Reset / Clear Reviews Button (Always Available) + if (onClearEvaluationsClick != null) { + IconButton(onClick = onClearEvaluationsClick) { + Icon( + painter = painterResource(R.drawable.ic_undo), + contentDescription = "Reset All Evaluations", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(24.dp) + ) + } + } + } + + // Jump to Search & Filters Button + IconButton(onClick = onScrollToTop) { + 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) { + if (onClearEvaluationsClick != null) { + DropdownMenuItem( + text = { Text("πŸ”„ Reset All Evaluations", color = MaterialTheme.colorScheme.error) }, + onClick = { + showMoreMenu = false + onClearEvaluationsClick() + } + ) + } + if (onExportGrievances != null) { + DropdownMenuItem( + text = { Text("πŸ“Š Generate Evaluation Report") }, + onClick = { + showMoreMenu = false + onExportGrievances() + } + ) + } + DropdownMenuItem( + text = { + Text(if (selectedStatusFilter == ReviewStatus.UNCHECKED) "Show All Samples" else "βšͺ Show Unchecked Only") + }, + onClick = { + showMoreMenu = false + onToggleUncheckedFilter() + } + ) + 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 + onScrollToTop() + } + ) + } + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) +} diff --git a/ApiDemos/project/common-ui/src/main/java/com/example/common_ui/catalog/compose/SampleCard.kt b/ApiDemos/project/common-ui/src/main/java/com/example/common_ui/catalog/compose/SampleCard.kt new file mode 100644 index 000000000..89f717ce5 --- /dev/null +++ b/ApiDemos/project/common-ui/src/main/java/com/example/common_ui/catalog/compose/SampleCard.kt @@ -0,0 +1,393 @@ +/* + * 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.clickable +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.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.SuggestionChip +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.graphics.Color +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.ReviewStatus +import com.example.common_ui.catalog.SampleEvaluation +import com.example.common_ui.catalog.SampleItem + +/** + * Returns label, background, and foreground color styling for review statuses. + */ +fun statusVisuals(status: ReviewStatus): Triple = 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)) +} + +/** + * Reusable visual badge representing sample review status. + */ +@Composable +fun ReviewStatusBadge( + status: ReviewStatus, + modifier: Modifier = Modifier +) { + val (statusText, statusBg, statusFg) = statusVisuals(status) + Surface( + shape = RoundedCornerShape(8.dp), + color = statusBg, + modifier = modifier + ) { + Text( + text = statusText, + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + color = statusFg, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp) + ) + } +} + +/** + * Interactive card displaying sample metadata, review evaluation status, and launch actions. + */ +@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) + + 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) { + SampleCardCollapsedRow( + sample = sample, + status = status, + evaluation = evaluation, + hasActivity = hasActivity, + onSampleClick = onSampleClick + ) + } else { + SampleCardExpandedContent( + sample = sample, + targetFqcn = targetFqcn, + framework = framework, + isReviewerMode = isReviewerMode, + status = status, + evaluation = evaluation, + hasActivity = hasActivity, + onCollapse = { isExpandedManually = false }, + onQuickGrade = onQuickGrade, + onInfoClick = onInfoClick, + onSampleClick = onSampleClick + ) + } + } +} + +@Composable +private fun SampleCardCollapsedRow( + sample: SampleItem, + status: ReviewStatus, + evaluation: SampleEvaluation?, + hasActivity: Boolean, + onSampleClick: () -> Unit +) { + 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 + ) + + ReviewStatusBadge(status = status) + + 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.KeyboardArrowDown, + contentDescription = "Expand", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(24.dp) + ) + } + } +} + +@Composable +private fun SampleCardExpandedContent( + sample: SampleItem, + targetFqcn: String, + framework: Framework, + isReviewerMode: Boolean, + status: ReviewStatus, + evaluation: SampleEvaluation?, + hasActivity: Boolean, + onCollapse: () -> Unit, + onQuickGrade: (ReviewStatus) -> Unit, + onInfoClick: () -> Unit, + onSampleClick: () -> Unit +) { + 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 = onCollapse, + modifier = Modifier.size(28.dp) + ) { + Icon( + Icons.Default.KeyboardArrowUp, + 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) + ) { + ReviewStatusBadge(status = status) + + 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.Default.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/SampleDetailContent.kt b/ApiDemos/project/common-ui/src/main/java/com/example/common_ui/catalog/compose/SampleDetailContent.kt new file mode 100644 index 000000000..55cb5fd30 --- /dev/null +++ b/ApiDemos/project/common-ui/src/main/java/com/example/common_ui/catalog/compose/SampleDetailContent.kt @@ -0,0 +1,505 @@ +/* + * 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.graphics.BitmapFactory +import android.text.Html +import android.widget.TextView +import androidx.compose.foundation.Image +import androidx.compose.foundation.border +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +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.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.ui.res.painterResource +import com.example.common_ui.R +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.ExperimentalMaterial3Api +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.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +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 androidx.compose.ui.viewinterop.AndroidView +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.example.common_ui.catalog.Framework +import com.example.common_ui.catalog.ReviewStatus +import com.example.common_ui.catalog.SampleItem +import com.example.common_ui.catalog.SampleEvaluation +import java.io.File + +/** + * Full-screen detail dialog wrapper for [SampleDetailContent]. + */ +@Composable +fun SampleDetailFullScreenDialog( + sample: SampleItem, + targetFqcn: String, + framework: Framework, + isReviewerMode: Boolean, + existingEvaluation: SampleEvaluation?, + onDismiss: () -> Unit, + onSaveEvaluation: (ReviewStatus, String) -> Unit, + onLaunch: (Framework) -> Unit, + onSaveAndNext: ((ReviewStatus, String) -> Unit)? = null +) { + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties( + usePlatformDefaultWidth = false, + decorFitsSystemWindows = false + ) + ) { + SampleDetailContent( + sample = sample, + targetFqcn = targetFqcn, + framework = framework, + isReviewerMode = isReviewerMode, + existingEvaluation = existingEvaluation, + onDismiss = onDismiss, + onSaveEvaluation = onSaveEvaluation, + onLaunch = onLaunch, + onSaveAndNext = onSaveAndNext + ) + } +} + +/** + * Comprehensive "Info & Code" view containing: + * 1. Title, Category, Complexity & Launch header + * 2. Target Class (FQCN) banner + * 3. Card 1: Acceptance Criteria, Purpose, What to Verify, Edge Cases + * 4. Card 2: Full-width syntax-highlighted Source Code Snippet with Kotlin/Java tabs + * 5. Card 3: Reviewer Evaluation controls (Pass/Needs Work/Unchecked, notes, attached screenshot) + * 6. Pinned bottom action bar with Framework Switch, Save, and Save & Next + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SampleDetailContent( + sample: SampleItem, + targetFqcn: String, + framework: Framework, + isReviewerMode: Boolean, + existingEvaluation: SampleEvaluation?, + onDismiss: () -> Unit, + onSaveEvaluation: (ReviewStatus, String) -> Unit, + onLaunch: (Framework) -> Unit, + onSaveAndNext: ((ReviewStatus, String) -> Unit)? = null +) { + var currentStatus by rememberSaveable { mutableStateOf(ReviewStatus.fromString(existingEvaluation?.status)) } + var notesText by rememberSaveable { mutableStateOf(existingEvaluation?.notes.orEmpty()) } + + Scaffold( + topBar = { + TopAppBar( + title = { + Column { + Text( + text = sample.title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + maxLines = 1 + ) + Text( + text = "${sample.category} β€’ ${sample.complexity.badge} ${sample.complexity.displayName}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + }, + navigationIcon = { + IconButton(onClick = onDismiss) { + Icon(Icons.Default.Close, contentDescription = "Close") + } + }, + actions = { + Button( + onClick = { onLaunch(framework) }, + shape = RoundedCornerShape(8.dp), + modifier = Modifier.padding(end = 8.dp) + ) { + Icon(Icons.Default.PlayArrow, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.width(4.dp)) + Text("Launch", fontSize = 12.sp) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) + }, + bottomBar = { + Surface( + modifier = Modifier.fillMaxWidth(), + tonalElevation = 6.dp, + shadowElevation = 8.dp + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + val altFramework = when (framework) { + Framework.KOTLIN_VIEWS -> if (sample.javaActivity != null) Framework.JAVA_VIEWS else null + Framework.JAVA_VIEWS -> if (sample.kotlinActivity != null) Framework.KOTLIN_VIEWS else null + } + + if (altFramework != null) { + OutlinedButton( + onClick = { onLaunch(altFramework) }, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(10.dp) + ) { + Icon(painterResource(R.drawable.ic_swap_framework), contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(4.dp)) + Text("Switch to ${altFramework.displayName}", fontSize = 12.sp, maxLines = 1) + } + } + + Button( + onClick = { onLaunch(framework) }, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(10.dp) + ) { + Icon(Icons.Default.PlayArrow, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(4.dp)) + Text("Launch ${framework.badge}", fontSize = 12.sp) + } + } + } + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // FQCN Target Info Banner + Surface( + shape = RoundedCornerShape(10.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + modifier = Modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.padding(12.dp)) { + Text( + text = "Target Class (FQCN)", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = targetFqcn, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + // Card 1: Purpose & Criteria HTML Card + ElevatedCard( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Text( + text = if (isReviewerMode) "🎯 Purpose & Verification Criteria" else "πŸ’‘ About This Sample", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.height(8.dp)) + AndroidView( + factory = { context -> + TextView(context).apply { + textSize = 14f + setLineSpacing(4f, 1.15f) + } + }, + update = { textView -> + textView.text = Html.fromHtml(sample.getFormattedHelpHtml(isReviewerMode), Html.FROM_HTML_MODE_COMPACT) + }, + modifier = Modifier.fillMaxWidth() + ) + } + } + + // Card 2: Key API Calls (Focused API indicators instead of clunky code blocks) + if (sample.apiCalls.isNotEmpty()) { + ElevatedCard( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + painter = painterResource(R.drawable.ic_code), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + Text( + text = "Key API Calls", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + } + Spacer(modifier = Modifier.height(10.dp)) + sample.apiCalls.forEach { apiCall -> + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 3.dp) + ) { + Text( + text = apiCall, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 7.dp) + ) + } + } + } + } + } + + // Card 3: Reviewer Evaluation Controls (Reviewer Mode Only) + if (isReviewerMode) { + ElevatedCard( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Text( + text = "πŸ” Reviewer Evaluation & Grievances", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.height(12.dp)) + + // Status Buttons (Clean, Non-wrapping layout) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Button( + onClick = { currentStatus = ReviewStatus.PASSING }, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(10.dp), + colors = ButtonDefaults.buttonColors( + containerColor = if (currentStatus == ReviewStatus.PASSING) Color(0xFF2E7D32) else Color(0xFFE8F5E9), + contentColor = if (currentStatus == ReviewStatus.PASSING) Color.White else Color(0xFF2E7D32) + ), + contentPadding = PaddingValues(vertical = 8.dp) + ) { + Text("🟒 Pass", fontWeight = FontWeight.Bold, fontSize = 12.sp) + } + + Button( + onClick = { currentStatus = ReviewStatus.NEEDS_WORK }, + modifier = Modifier.weight(1.2f), + shape = RoundedCornerShape(10.dp), + colors = ButtonDefaults.buttonColors( + containerColor = if (currentStatus == ReviewStatus.NEEDS_WORK) Color(0xFFC62828) else Color(0xFFFFEBEE), + contentColor = if (currentStatus == ReviewStatus.NEEDS_WORK) Color.White else Color(0xFFC62828) + ), + contentPadding = PaddingValues(vertical = 8.dp) + ) { + Text("πŸ”΄ Needs Work", fontWeight = FontWeight.Bold, fontSize = 12.sp) + } + + Button( + onClick = { currentStatus = ReviewStatus.UNCHECKED }, + modifier = Modifier.weight(1.1f), + shape = RoundedCornerShape(10.dp), + colors = ButtonDefaults.buttonColors( + containerColor = if (currentStatus == ReviewStatus.UNCHECKED) Color(0xFF616161) else Color(0xFFEEEEEE), + contentColor = if (currentStatus == ReviewStatus.UNCHECKED) Color.White else Color(0xFF616161) + ), + contentPadding = PaddingValues(vertical = 8.dp) + ) { + Text("βšͺ Unchecked", fontWeight = FontWeight.Bold, fontSize = 12.sp) + } + } + + Spacer(modifier = Modifier.height(12.dp)) + OutlinedTextField( + value = notesText, + onValueChange = { notesText = it }, + label = { Text("Reviewer Notes & Grievances (Bugs, reproduction steps, UI flaws)") }, + modifier = Modifier.fillMaxWidth(), + minLines = 3, + maxLines = 6, + shape = RoundedCornerShape(10.dp) + ) + + if (!existingEvaluation?.screenshotPath.isNullOrBlank()) { + val sFile = File(existingEvaluation.screenshotPath) + if (sFile.exists()) { + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = "πŸ“Έ Attached Screenshot & Markup:", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.height(6.dp)) + val bitmap = remember(sFile.absolutePath) { + BitmapFactory.decodeFile(sFile.absolutePath) + } + if (bitmap != null) { + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = "Annotated Issue Screenshot", + modifier = Modifier + .fillMaxWidth() + .height(240.dp) + .clip(RoundedCornerShape(10.dp)) + .border(1.dp, MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(10.dp)), + contentScale = ContentScale.Fit + ) + } + } + } + + Spacer(modifier = Modifier.height(14.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (existingEvaluation != null) { + OutlinedButton( + onClick = { + currentStatus = ReviewStatus.UNCHECKED + notesText = "" + onSaveEvaluation(ReviewStatus.UNCHECKED, "") + }, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(10.dp) + ) { + Text("Reset", color = MaterialTheme.colorScheme.error) + } + } + + OutlinedButton( + onClick = { onSaveEvaluation(currentStatus, notesText) }, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(10.dp) + ) { + Text("Save", maxLines = 1) + } + Button( + onClick = { + if (onSaveAndNext != null) { + onSaveAndNext(currentStatus, notesText) + } else { + onSaveEvaluation(currentStatus, notesText) + } + }, + modifier = Modifier.weight(1.5f), + shape = RoundedCornerShape(10.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary + ) + ) { + Text("Save & Next ⏭️", fontWeight = FontWeight.Bold, maxLines = 1) + } + } + } + } + } + + // Extra bottom spacing + Spacer(modifier = Modifier.height(16.dp)) + } + } +} diff --git a/ApiDemos/project/common-ui/src/main/java/com/example/common_ui/catalog/ui/SampleCardAdapter.kt b/ApiDemos/project/common-ui/src/main/java/com/example/common_ui/catalog/ui/SampleCardAdapter.kt new file mode 100644 index 000000000..72a29addb --- /dev/null +++ b/ApiDemos/project/common-ui/src/main/java/com/example/common_ui/catalog/ui/SampleCardAdapter.kt @@ -0,0 +1,173 @@ +/* + * 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.ui + +import android.content.res.ColorStateList +import android.graphics.Color +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import com.example.common_ui.R +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.SampleEvaluation +import com.example.common_ui.catalog.SampleItem +import com.google.android.material.button.MaterialButton +import com.google.android.material.card.MaterialCardView +import com.google.android.material.chip.Chip + +class SampleCardAdapter( + private val onSampleClick: (SampleItem) -> Unit, + private val onInfoClick: (SampleItem) -> Unit, + private val onStatusClick: (SampleItem, SampleEvaluation?) -> Unit +) : ListAdapter(SampleDiffCallback()) { + + var currentFramework: Framework = Framework.KOTLIN_VIEWS + set(value) { + field = value + notifyDataSetChanged() + } + + var isReviewerMode: Boolean = true + set(value) { + field = value + notifyDataSetChanged() + } + + private var evaluationsMap: Map = emptyMap() + + fun updateEvaluations(evaluations: List) { + evaluationsMap = evaluations.associateBy { it.sampleId } + notifyDataSetChanged() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SampleViewHolder { + val view = LayoutInflater.from(parent.context).inflate(R.layout.item_sample_card, parent, false) + return SampleViewHolder(view) + } + + override fun onBindViewHolder(holder: SampleViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + inner class SampleViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + private val cardView: MaterialCardView = itemView.findViewById(R.id.card_view) + private val textCategory: TextView = itemView.findViewById(R.id.text_category) + private val chipComplexity: Chip = itemView.findViewById(R.id.chip_complexity) + private val btnStatusBadge: MaterialButton = itemView.findViewById(R.id.btn_status_badge) + private val textTitle: TextView = itemView.findViewById(R.id.text_title) + private val textDescription: TextView = itemView.findViewById(R.id.text_description) + private val textNotesPreview: TextView = itemView.findViewById(R.id.text_notes_preview) + private val textTags: TextView = itemView.findViewById(R.id.text_tags) + private val btnInfo: MaterialButton = itemView.findViewById(R.id.btn_info) + private val btnLaunch: MaterialButton = itemView.findViewById(R.id.btn_launch) + + fun bind(sample: SampleItem) { + textCategory.text = sample.category + textTitle.text = sample.title + textDescription.text = sample.description + + // Complexity chip + chipComplexity.text = "${sample.complexity.badge} ${sample.complexity.displayName}" + + // Hashtags + if (sample.tags.isNotEmpty()) { + textTags.visibility = View.VISIBLE + textTags.text = sample.tags.joinToString(" ") + } else { + textTags.visibility = View.GONE + } + + // Reviewer evaluation + val evaluation = evaluationsMap[sample.id] + val status = ReviewStatus.fromString(evaluation?.status) + + if (isReviewerMode) { + btnStatusBadge.visibility = View.VISIBLE + btnStatusBadge.text = status.displayName + when (status) { + ReviewStatus.PASSING -> { + btnStatusBadge.setIconResource(R.drawable.ic_status_passing) + btnStatusBadge.iconTint = ColorStateList.valueOf(Color.parseColor("#4CAF50")) + btnStatusBadge.setTextColor(Color.parseColor("#2E7D32")) + } + ReviewStatus.NEEDS_WORK -> { + btnStatusBadge.setIconResource(R.drawable.ic_status_needs_work) + btnStatusBadge.iconTint = ColorStateList.valueOf(Color.parseColor("#F44336")) + btnStatusBadge.setTextColor(Color.parseColor("#C62828")) + } + ReviewStatus.UNCHECKED -> { + btnStatusBadge.setIconResource(R.drawable.ic_status_unchecked) + btnStatusBadge.iconTint = ColorStateList.valueOf(Color.parseColor("#9E9E9E")) + btnStatusBadge.setTextColor(Color.parseColor("#616161")) + } + } + + // Show notes preview if present + if (!evaluation?.notes.isNullOrBlank()) { + textNotesPreview.visibility = View.VISIBLE + textNotesPreview.text = "πŸ“ Note: ${evaluation?.notes}" + } else { + textNotesPreview.visibility = View.GONE + } + } else { + btnStatusBadge.visibility = View.GONE + textNotesPreview.visibility = View.GONE + } + + btnStatusBadge.setOnClickListener { + onStatusClick(sample, evaluation) + } + + btnInfo.setOnClickListener { + onInfoClick(sample) + } + + val hasActivity = sample.getActivityForFramework(currentFramework) != null + btnLaunch.isEnabled = hasActivity + btnLaunch.alpha = if (hasActivity) 1.0f else 0.4f + btnLaunch.text = if (hasActivity) "Launch Sample" else "No ${currentFramework.badge} Impl" + + btnLaunch.setOnClickListener { + if (hasActivity) { + onSampleClick(sample) + } + } + + cardView.setOnClickListener { + if (hasActivity) { + onSampleClick(sample) + } else { + onInfoClick(sample) + } + } + } + } + + class SampleDiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: SampleItem, newItem: SampleItem): Boolean = + oldItem.id == newItem.id + + override fun areContentsTheSame(oldItem: SampleItem, newItem: SampleItem): Boolean = + oldItem == newItem + } +} diff --git a/ApiDemos/project/common-ui/src/main/java/com/example/common_ui/catalog/ui/UnifiedCatalogActivity.kt b/ApiDemos/project/common-ui/src/main/java/com/example/common_ui/catalog/ui/UnifiedCatalogActivity.kt new file mode 100644 index 000000000..2e50db852 --- /dev/null +++ b/ApiDemos/project/common-ui/src/main/java/com/example/common_ui/catalog/ui/UnifiedCatalogActivity.kt @@ -0,0 +1,28 @@ +/* + * 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.ui + +import com.example.common_ui.catalog.compose.CatalogActivity + +/** + * Unified Catalog Activity front door providing Compose-based sample navigation. + */ +open class UnifiedCatalogActivity : CatalogActivity() { + companion object { + const val EXTRA_SAMPLE_ID = "extra_sample_id" + } +} diff --git a/ApiDemos/project/common-ui/src/main/res/drawable/ic_code.xml b/ApiDemos/project/common-ui/src/main/res/drawable/ic_code.xml new file mode 100644 index 000000000..e24681438 --- /dev/null +++ b/ApiDemos/project/common-ui/src/main/res/drawable/ic_code.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/ApiDemos/project/common-ui/src/main/res/drawable/ic_drag_pan.xml b/ApiDemos/project/common-ui/src/main/res/drawable/ic_drag_pan.xml new file mode 100644 index 000000000..a1834ec97 --- /dev/null +++ b/ApiDemos/project/common-ui/src/main/res/drawable/ic_drag_pan.xml @@ -0,0 +1,26 @@ + + + + + diff --git a/ApiDemos/project/common-ui/src/main/res/drawable/ic_grievances.xml b/ApiDemos/project/common-ui/src/main/res/drawable/ic_grievances.xml new file mode 100644 index 000000000..12ebb6a07 --- /dev/null +++ b/ApiDemos/project/common-ui/src/main/res/drawable/ic_grievances.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/ApiDemos/project/common-ui/src/main/res/drawable/ic_info_outline.xml b/ApiDemos/project/common-ui/src/main/res/drawable/ic_info_outline.xml new file mode 100644 index 000000000..0eb6a90aa --- /dev/null +++ b/ApiDemos/project/common-ui/src/main/res/drawable/ic_info_outline.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/ApiDemos/project/common-ui/src/main/res/drawable/ic_skip_next.xml b/ApiDemos/project/common-ui/src/main/res/drawable/ic_skip_next.xml new file mode 100644 index 000000000..c75cc753d --- /dev/null +++ b/ApiDemos/project/common-ui/src/main/res/drawable/ic_skip_next.xml @@ -0,0 +1,25 @@ + + + + + \ No newline at end of file diff --git a/ApiDemos/project/common-ui/src/main/res/drawable/ic_skip_previous.xml b/ApiDemos/project/common-ui/src/main/res/drawable/ic_skip_previous.xml new file mode 100644 index 000000000..015a2ba95 --- /dev/null +++ b/ApiDemos/project/common-ui/src/main/res/drawable/ic_skip_previous.xml @@ -0,0 +1,25 @@ + + + + + \ No newline at end of file diff --git a/ApiDemos/project/common-ui/src/main/res/drawable/ic_status_needs_work.xml b/ApiDemos/project/common-ui/src/main/res/drawable/ic_status_needs_work.xml new file mode 100644 index 000000000..352666e68 --- /dev/null +++ b/ApiDemos/project/common-ui/src/main/res/drawable/ic_status_needs_work.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/ApiDemos/project/common-ui/src/main/res/drawable/ic_status_passing.xml b/ApiDemos/project/common-ui/src/main/res/drawable/ic_status_passing.xml new file mode 100644 index 000000000..e3beac17f --- /dev/null +++ b/ApiDemos/project/common-ui/src/main/res/drawable/ic_status_passing.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/ApiDemos/project/common-ui/src/main/res/drawable/ic_status_unchecked.xml b/ApiDemos/project/common-ui/src/main/res/drawable/ic_status_unchecked.xml new file mode 100644 index 000000000..dae9245d9 --- /dev/null +++ b/ApiDemos/project/common-ui/src/main/res/drawable/ic_status_unchecked.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/ApiDemos/project/common-ui/src/main/res/drawable/ic_swap_framework.xml b/ApiDemos/project/common-ui/src/main/res/drawable/ic_swap_framework.xml new file mode 100644 index 000000000..99e6efb22 --- /dev/null +++ b/ApiDemos/project/common-ui/src/main/res/drawable/ic_swap_framework.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/ApiDemos/project/common-ui/src/main/res/drawable/ic_thumb_up.xml b/ApiDemos/project/common-ui/src/main/res/drawable/ic_thumb_up.xml new file mode 100644 index 000000000..fb787e3a2 --- /dev/null +++ b/ApiDemos/project/common-ui/src/main/res/drawable/ic_thumb_up.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/ApiDemos/project/common-ui/src/main/res/drawable/ic_undo.xml b/ApiDemos/project/common-ui/src/main/res/drawable/ic_undo.xml new file mode 100644 index 000000000..1732cbc3c --- /dev/null +++ b/ApiDemos/project/common-ui/src/main/res/drawable/ic_undo.xml @@ -0,0 +1,25 @@ + + + + + \ No newline at end of file diff --git a/ApiDemos/project/common-ui/src/main/res/drawable/ic_warning_bug.xml b/ApiDemos/project/common-ui/src/main/res/drawable/ic_warning_bug.xml new file mode 100644 index 000000000..2bf97b6c7 --- /dev/null +++ b/ApiDemos/project/common-ui/src/main/res/drawable/ic_warning_bug.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/ApiDemos/project/common-ui/src/main/res/layout/activity_sample_base.xml b/ApiDemos/project/common-ui/src/main/res/layout/activity_sample_base.xml new file mode 100644 index 000000000..4fbaf9324 --- /dev/null +++ b/ApiDemos/project/common-ui/src/main/res/layout/activity_sample_base.xml @@ -0,0 +1,40 @@ + + + + + + + + + diff --git a/ApiDemos/project/common-ui/src/main/res/layout/activity_unified_catalog.xml b/ApiDemos/project/common-ui/src/main/res/layout/activity_unified_catalog.xml new file mode 100644 index 000000000..3c5093dc6 --- /dev/null +++ b/ApiDemos/project/common-ui/src/main/res/layout/activity_unified_catalog.xml @@ -0,0 +1,172 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ApiDemos/project/common-ui/src/main/res/layout/bottom_sheet_sample_expectations.xml b/ApiDemos/project/common-ui/src/main/res/layout/bottom_sheet_sample_expectations.xml new file mode 100644 index 000000000..998a03947 --- /dev/null +++ b/ApiDemos/project/common-ui/src/main/res/layout/bottom_sheet_sample_expectations.xml @@ -0,0 +1,199 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ApiDemos/project/common-ui/src/main/res/layout/cloud_styling_basic_demo.xml b/ApiDemos/project/common-ui/src/main/res/layout/cloud_styling_basic_demo.xml index f95663411..08b172934 100644 --- a/ApiDemos/project/common-ui/src/main/res/layout/cloud_styling_basic_demo.xml +++ b/ApiDemos/project/common-ui/src/main/res/layout/cloud_styling_basic_demo.xml @@ -37,16 +37,16 @@ android:name="com.google.android.gms.maps.SupportMapFragment" android:layout_width="match_parent" android:layout_height="match_parent" - map:cameraTargetLat="47.6089945" - map:cameraTargetLng="-122.3410462" - map:cameraZoom="14" + map:cameraTargetLat="45.8326" + map:cameraTargetLng="6.8652" + map:cameraZoom="11.5" map:mapId="@string/cloud_styling_basic_map_id" /> - - + \ No newline at end of file diff --git a/ApiDemos/project/common-ui/src/main/res/layout/ground_overlay_demo.xml b/ApiDemos/project/common-ui/src/main/res/layout/ground_overlay_demo.xml index 14aa1027e..60110f51c 100644 --- a/ApiDemos/project/common-ui/src/main/res/layout/ground_overlay_demo.xml +++ b/ApiDemos/project/common-ui/src/main/res/layout/ground_overlay_demo.xml @@ -36,44 +36,69 @@ android:layout_height="match_parent" app:layout_constraintTop_toBottomOf="@+id/container" /> - - + android:orientation="vertical" + android:paddingHorizontal="16dp" + android:paddingVertical="12dp"> - + - + - - + + + + + + + + + + + diff --git a/ApiDemos/project/common-ui/src/main/res/layout/item_sample_card.xml b/ApiDemos/project/common-ui/src/main/res/layout/item_sample_card.xml new file mode 100644 index 000000000..a4ebbd1cf --- /dev/null +++ b/ApiDemos/project/common-ui/src/main/res/layout/item_sample_card.xml @@ -0,0 +1,157 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ApiDemos/project/common-ui/src/main/res/layout/location_source_demo.xml b/ApiDemos/project/common-ui/src/main/res/layout/location_source_demo.xml new file mode 100644 index 000000000..2fc62c666 --- /dev/null +++ b/ApiDemos/project/common-ui/src/main/res/layout/location_source_demo.xml @@ -0,0 +1,105 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ApiDemos/project/common-ui/src/main/res/layout/marker_demo.xml b/ApiDemos/project/common-ui/src/main/res/layout/marker_demo.xml index b44b7f2e0..27de5223c 100644 --- a/ApiDemos/project/common-ui/src/main/res/layout/marker_demo.xml +++ b/ApiDemos/project/common-ui/src/main/res/layout/marker_demo.xml @@ -30,47 +30,63 @@ app:title="@string/marker_demo_label" app:titleTextColor="?attr/colorOnPrimary" /> - - - + android:orientation="vertical" + android:paddingHorizontal="14dp" + android:paddingVertical="10dp"> - + android:text="@string/flat_to_map" /> - + android:gravity="center_vertical" + android:layout_marginTop="4dp" + android:orientation="horizontal"> - + + + + + + - + + app:cameraTargetLat="29.9792" + app:cameraTargetLng="31.1342" + app:cameraZoom="4" /> + app:cameraTargetLat="-13.1631" + app:cameraTargetLng="-72.5450" + app:cameraZoom="4" /> + app:cameraTargetLat="27.1751" + app:cameraTargetLng="78.0421" + app:cameraZoom="4" /> + app:cameraTargetLat="41.8902" + app:cameraTargetLng="12.4922" + app:cameraZoom="4" /> diff --git a/ApiDemos/project/common-ui/src/main/res/layout/polyline_demo.xml b/ApiDemos/project/common-ui/src/main/res/layout/polyline_demo.xml index e34d9b24d..09ef2f709 100644 --- a/ApiDemos/project/common-ui/src/main/res/layout/polyline_demo.xml +++ b/ApiDemos/project/common-ui/src/main/res/layout/polyline_demo.xml @@ -66,57 +66,66 @@ - - - - - - - - - - - - - - - - - + android:stretchColumns="1,3" + android:paddingStart="8dp" + android:paddingEnd="8dp" + android:paddingTop="4dp" + android:paddingBottom="4dp"> + + + + + + + + + + + + + + + @@ -25,20 +26,27 @@ android:layout_height="match_parent" android:name="com.google.android.gms.maps.SupportMapFragment" /> - + android:layout_alignParentStart="true" + android:layout_marginStart="16dp" + android:layout_marginBottom="24dp" + app:cardCornerRadius="16dp" + app:cardElevation="6dp"> - + android:layout_height="match_parent" + android:padding="8dp"> + + + diff --git a/ApiDemos/project/common-ui/src/main/res/layout/visible_region_demo.xml b/ApiDemos/project/common-ui/src/main/res/layout/visible_region_demo.xml index debcd0b37..49cb9f8a3 100755 --- a/ApiDemos/project/common-ui/src/main/res/layout/visible_region_demo.xml +++ b/ApiDemos/project/common-ui/src/main/res/layout/visible_region_demo.xml @@ -13,69 +13,120 @@ See the License for the specific language governing permissions and limitations under the License. --> - + android:layout_height="match_parent"> - + - - - + + android:paddingHorizontal="14dp" + android:paddingVertical="10dp"> - + android:gravity="center_horizontal" + android:orientation="vertical"> - + - + - + + + + android:layout_gravity="center_horizontal" + android:layout_marginTop="8dp" + android:text="Actions β–Ύ" + app:cornerRadius="10dp" /> + - - + + + + + + + + + + diff --git a/ApiDemos/project/common-ui/src/main/res/menu/visible_region_menu.xml b/ApiDemos/project/common-ui/src/main/res/menu/visible_region_menu.xml new file mode 100644 index 000000000..a69522c0d --- /dev/null +++ b/ApiDemos/project/common-ui/src/main/res/menu/visible_region_menu.xml @@ -0,0 +1,34 @@ + + + + + + + + + diff --git a/ApiDemos/project/common-ui/src/main/res/values/strings.xml b/ApiDemos/project/common-ui/src/main/res/values/strings.xml index 641941e8b..049f5162b 100755 --- a/ApiDemos/project/common-ui/src/main/res/values/strings.xml +++ b/ApiDemos/project/common-ui/src/main/res/values/strings.xml @@ -64,6 +64,9 @@ Fill Alpha Fill Hue Flat + Flat to map surface + Long press and drag the Melbourne marker across the map to reposition. + Go ahead. Drag me. I love it. Go to Bondi Go to Santorini Go to Sydney @@ -85,6 +88,11 @@ \u2190 Location Source Demo Demonstrates how to use a custom location source. + Fowler / Rattlesnake Trail + 10.1 km β€’ GPX LocationSource simulation + Pause + Play + Fit Trail Google Map with circles. Map is not ready yet Map In Pager diff --git a/ApiDemos/project/common-ui/src/main/res/xml/file_paths.xml b/ApiDemos/project/common-ui/src/main/res/xml/file_paths.xml new file mode 100644 index 000000000..76522c1ac --- /dev/null +++ b/ApiDemos/project/common-ui/src/main/res/xml/file_paths.xml @@ -0,0 +1,23 @@ + + + + + + + + + diff --git a/ApiDemos/project/common-ui/src/test/java/com/example/common_ui/catalog/SampleCatalogRegistryTest.kt b/ApiDemos/project/common-ui/src/test/java/com/example/common_ui/catalog/SampleCatalogRegistryTest.kt new file mode 100644 index 000000000..6d44af2ce --- /dev/null +++ b/ApiDemos/project/common-ui/src/test/java/com/example/common_ui/catalog/SampleCatalogRegistryTest.kt @@ -0,0 +1,168 @@ +/* + * 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 com.google.common.truth.Truth.assertThat +import org.junit.Test + +class SampleCatalogRegistryTest { + + @Test + fun registry_containsNonEmptySampleList() { + val samples = SampleCatalogRegistry.SAMPLES + assertThat(samples).isNotEmpty() + assertThat(samples.size).isAtLeast(30) + } + + @Test + fun allSamples_haveValidMetadata() { + for (sample in SampleCatalogRegistry.SAMPLES) { + assertThat(sample.id).isNotEmpty() + assertThat(sample.title).isNotEmpty() + assertThat(sample.description).isNotEmpty() + assertThat(sample.category).isNotEmpty() + assertThat(sample.tags).isNotEmpty() + assertThat(sample.complexity).isNotNull() + // Every sample must support at least one framework + val hasImplementation = sample.kotlinActivity != null || sample.javaActivity != null + assertThat(hasImplementation).isTrue() + } + } + + @Test + fun getAllTags_returnsSortedDistinctTags() { + val tags = SampleCatalogRegistry.getAllTags() + assertThat(tags).isNotEmpty() + assertThat(tags).contains("#map") + assertThat(tags).isInOrder() + assertThat(tags.toSet().size).isEqualTo(tags.size) + } + + @Test + fun getCategories_returnsSortedDistinctCategories() { + val categories = SampleCatalogRegistry.getCategories() + assertThat(categories).isNotEmpty() + assertThat(categories).contains("Map Initialization") + assertThat(categories).isInOrder() + assertThat(categories.toSet().size).isEqualTo(categories.size) + } + + @Test + fun filter_byFramework() { + val kotlinSamples = SampleCatalogRegistry.filter(framework = Framework.KOTLIN_VIEWS) + assertThat(kotlinSamples).isNotEmpty() + for (sample in kotlinSamples) { + assertThat(sample.getActivityForFramework(Framework.KOTLIN_VIEWS)).isNotNull() + } + + val javaSamples = SampleCatalogRegistry.filter(framework = Framework.JAVA_VIEWS) + assertThat(javaSamples).isNotEmpty() + for (sample in javaSamples) { + assertThat(sample.getActivityForFramework(Framework.JAVA_VIEWS)).isNotNull() + } + } + + @Test + fun filter_byComplexity() { + val snippets = SampleCatalogRegistry.filter(complexity = Complexity.SNIPPET) + assertThat(snippets).isNotEmpty() + for (sample in snippets) { + assertThat(sample.complexity).isEqualTo(Complexity.SNIPPET) + } + + val advanced = SampleCatalogRegistry.filter(complexity = Complexity.ADVANCED) + assertThat(advanced).isNotEmpty() + for (sample in advanced) { + assertThat(sample.complexity).isEqualTo(Complexity.ADVANCED) + } + } + + @Test + fun filter_byTags() { + val tagged = SampleCatalogRegistry.filter(selectedTags = setOf("#init")) + assertThat(tagged).isNotEmpty() + for (sample in tagged) { + assertThat(sample.tags).contains("#init") + } + } + + @Test + fun filter_bySearchQuery() { + val byTitle = SampleCatalogRegistry.filter(searchQuery = "Basic Map") + assertThat(byTitle).isNotEmpty() + assertThat(byTitle.first().title).contains("Basic Map") + + val byCategory = SampleCatalogRegistry.filter(searchQuery = "Camera Controls") + assertThat(byCategory).isNotEmpty() + for (sample in byCategory) { + val matches = sample.category.contains("Camera Controls", ignoreCase = true) || + sample.title.contains("Camera Controls", ignoreCase = true) || + sample.description.contains("Camera Controls", ignoreCase = true) + assertThat(matches).isTrue() + } + } + + @Test + fun findById_matchesCorrectSample() { + val basic = SampleCatalogRegistry.findById("com.example.kotlindemos.BasicMapDemoActivity") + assertThat(basic).isNotNull() + assertThat(basic?.title).isEqualTo("Basic Map") + + val byJava = SampleCatalogRegistry.findById("com.example.mapdemo.BasicMapDemoActivity") + assertThat(byJava).isNotNull() + assertThat(byJava?.title).isEqualTo("Basic Map") + + val missing = SampleCatalogRegistry.findById("non.existent.Activity") + assertThat(missing).isNull() + + val nullLookup = SampleCatalogRegistry.findById(null) + assertThat(nullLookup).isNull() + } + + @Test + fun sampleItem_targetFqcn_resolvesPerFramework() { + val sample = SampleItem( + id = "com.example.kotlindemos.TestActivity", + title = "Test", + description = "Test desc", + category = "Testing", + complexity = Complexity.SIMPLE, + tags = listOf("#test"), + apiCalls = emptyList(), + purpose = "Testing", + successCriteria = "Pass", + failureIndicators = "Fail", + kotlinActivity = "com.example.kotlindemos.TestKotlinActivity", + javaActivity = "com.example.mapdemo.TestJavaActivity" + ) + + assertThat(sample.getTargetFqcn(Framework.KOTLIN_VIEWS)).isEqualTo("com.example.kotlindemos.TestKotlinActivity") + assertThat(sample.getTargetFqcn(Framework.JAVA_VIEWS)).isEqualTo("com.example.mapdemo.TestJavaActivity") + } + + @Test + fun reviewStatus_fromString_parsesCorrectly() { + assertThat(ReviewStatus.fromString("PASSING")).isEqualTo(ReviewStatus.PASSING) + assertThat(ReviewStatus.fromString("passing")).isEqualTo(ReviewStatus.PASSING) + assertThat(ReviewStatus.fromString("NEEDS_WORK")).isEqualTo(ReviewStatus.NEEDS_WORK) + assertThat(ReviewStatus.fromString("needs_work")).isEqualTo(ReviewStatus.NEEDS_WORK) + assertThat(ReviewStatus.fromString("UNCHECKED")).isEqualTo(ReviewStatus.UNCHECKED) + assertThat(ReviewStatus.fromString("unchecked")).isEqualTo(ReviewStatus.UNCHECKED) + assertThat(ReviewStatus.fromString(null)).isEqualTo(ReviewStatus.UNCHECKED) + assertThat(ReviewStatus.fromString("UNKNOWN_VALUE")).isEqualTo(ReviewStatus.UNCHECKED) + } +} diff --git a/ApiDemos/project/common-ui/src/test/java/com/example/common_ui/catalog/compose/CatalogStateTest.kt b/ApiDemos/project/common-ui/src/test/java/com/example/common_ui/catalog/compose/CatalogStateTest.kt new file mode 100644 index 000000000..455b2fb72 --- /dev/null +++ b/ApiDemos/project/common-ui/src/test/java/com/example/common_ui/catalog/compose/CatalogStateTest.kt @@ -0,0 +1,172 @@ +/* + * 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.lazy.LazyListState +import androidx.compose.material3.SnackbarHostState +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.google.common.truth.Truth.assertThat +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import org.junit.Test + +class CatalogStateTest { + + private fun createTestCatalogState( + isReviewerMode: Boolean = true, + evaluations: Map = emptyMap(), + initialFramework: Framework = Framework.KOTLIN_VIEWS + ): CatalogState { + return CatalogState( + isReviewerMode = isReviewerMode, + evaluations = evaluations, + lazyListState = LazyListState(), + snackbarHostState = SnackbarHostState(), + coroutineScope = CoroutineScope(Dispatchers.Unconfined), + initialFramework = initialFramework + ) + } + + @Test + fun initialState_defaultsCorrectly() { + val state = createTestCatalogState() + assertThat(state.selectedFramework).isEqualTo(Framework.KOTLIN_VIEWS) + assertThat(state.selectedComplexity).isNull() + assertThat(state.selectedStatusFilter).isNull() + assertThat(state.selectedTags).isEmpty() + assertThat(state.searchQuery).isEmpty() + assertThat(state.activeSampleDetailId).isNull() + assertThat(state.activeQuickGrading).isNull() + } + + @Test + fun statusCounts_calculatesCorrectly() { + val sample1 = SampleCatalogRegistry.SAMPLES[0] + val sample2 = SampleCatalogRegistry.SAMPLES[1] + + val fqcn1 = sample1.getTargetFqcn(Framework.KOTLIN_VIEWS) + val fqcn2 = sample2.getTargetFqcn(Framework.KOTLIN_VIEWS) + + val evals = mapOf( + fqcn1 to SampleEvaluation(sampleId = sample1.id, status = "PASSING", notes = "All good"), + fqcn2 to SampleEvaluation(sampleId = sample2.id, status = "NEEDS_WORK", notes = "Bug found") + ) + + val state = createTestCatalogState(evaluations = evals) + assertThat(state.passingCount).isEqualTo(1) + assertThat(state.needsWorkCount).isEqualTo(1) + val expectedUnchecked = state.frameworkSamples.size - 2 + assertThat(state.uncheckedCount).isEqualTo(expectedUnchecked) + } + + @Test + fun grievancesCount_countsIssuesAndNotes() { + val evals = mapOf( + "sample1" to SampleEvaluation(sampleId = "sample1", status = "NEEDS_WORK", notes = "Broken"), + "sample2" to SampleEvaluation(sampleId = "sample2", status = "PASSING", notes = "Good with note"), + "sample3" to SampleEvaluation(sampleId = "sample3", status = "PASSING", notes = "") + ) + + val state = createTestCatalogState(evaluations = evals) + // sample1 is NEEDS_WORK, sample2 has non-blank note -> 2 grievances + assertThat(state.grievancesCount).isEqualTo(2) + } + + @Test + fun filteredSamples_appliesStatusFilter() { + val sample1 = SampleCatalogRegistry.SAMPLES[0] + val fqcn1 = sample1.getTargetFqcn(Framework.KOTLIN_VIEWS) + + val evals = mapOf( + fqcn1 to SampleEvaluation(sampleId = sample1.id, status = "PASSING", notes = "Pass") + ) + + val state = createTestCatalogState(evaluations = evals) + state.selectedStatusFilter = ReviewStatus.PASSING + + assertThat(state.filteredSamples).hasSize(1) + assertThat(state.filteredSamples.first().id).isEqualTo(sample1.id) + } + + @Test + fun filteredSamples_appliesComplexityFilter() { + val state = createTestCatalogState() + state.selectedComplexity = Complexity.SNIPPET + + assertThat(state.filteredSamples).isNotEmpty() + for (sample in state.filteredSamples) { + assertThat(sample.complexity).isEqualTo(Complexity.SNIPPET) + } + } + + @Test + fun findNextUncheckedSample_returnsNextInOrder() { + val samples = SampleCatalogRegistry.SAMPLES + val s0 = samples[0] + val s1 = samples[1] + val fqcn0 = s0.getTargetFqcn(Framework.KOTLIN_VIEWS) + + // Mark s0 as passing + val evals = mapOf( + fqcn0 to SampleEvaluation(sampleId = s0.id, status = "PASSING", notes = "Pass") + ) + + val state = createTestCatalogState(evaluations = evals) + val next = state.findNextUncheckedSample() + assertThat(next).isNotNull() + assertThat(next?.id).isEqualTo(s1.id) + } + + @Test + fun findNextUncheckedSample_cyclicalLookupAfterCurrent() { + val samples = SampleCatalogRegistry.SAMPLES + val s0 = samples[0] + val s1 = samples[1] + val s2 = samples[2] + + val fqcn0 = s0.getTargetFqcn(Framework.KOTLIN_VIEWS) + val fqcn1 = s1.getTargetFqcn(Framework.KOTLIN_VIEWS) + + // Mark s0 and s1 as passing + val evals = mapOf( + fqcn0 to SampleEvaluation(sampleId = s0.id, status = "PASSING", notes = "Pass"), + fqcn1 to SampleEvaluation(sampleId = s1.id, status = "PASSING", notes = "Pass") + ) + + val state = createTestCatalogState(evaluations = evals) + // Starting after s1, next unchecked should be s2 + val next = state.findNextUncheckedSample(currentSampleId = s1.id) + assertThat(next).isNotNull() + assertThat(next?.id).isEqualTo(s2.id) + } + + @Test + fun statusVisuals_returnsExpectedStyles() { + val (passText, passBg, passFg) = statusVisuals(ReviewStatus.PASSING) + assertThat(passText).contains("Pass") + + val (needsText, needsBg, needsFg) = statusVisuals(ReviewStatus.NEEDS_WORK) + assertThat(needsText).contains("Needs Work") + + val (uncheckedText, uncheckedBg, uncheckedFg) = statusVisuals(ReviewStatus.UNCHECKED) + assertThat(uncheckedText).contains("Unchecked") + } +} diff --git a/ApiDemos/project/java-app/src/main/AndroidManifest.xml b/ApiDemos/project/java-app/src/main/AndroidManifest.xml index 1d361585f..a06918125 100644 --- a/ApiDemos/project/java-app/src/main/AndroidManifest.xml +++ b/ApiDemos/project/java-app/src/main/AndroidManifest.xml @@ -58,6 +58,11 @@ limitations under the License. + + The main layout lists the demonstrated features, with buttons to launch them. + * Provides the developer/learner catalog with multi-framework browsing, + * instant search, sample details, and syntax-highlighted source code snippets. */ -public final class MainActivity extends SamplesBaseActivity { - - private static final String TAG = MainActivity.class.getSimpleName(); - - /** A custom array adapter that shows a {@link FeatureView} containing details about the demo. */ - private static class CustomArrayAdapter extends ArrayAdapter { - - /** @param demos An array containing the details of the demos to be displayed. */ - public CustomArrayAdapter(Context context, DemoDetails[] demos) { - super(context, com.example.common_ui.R.layout.feature, com.example.common_ui.R.id.title, demos); - } - - @NonNull - @Override - public View getView(int position, View convertView, @NonNull ViewGroup parent) { - FeatureView featureView; - if (convertView instanceof FeatureView) { - featureView = (FeatureView) convertView; - } else { - featureView = new FeatureView(getContext()); - } - - DemoDetails demo = getItem(position); - - featureView.setTitleId(demo.titleId); - featureView.setDescriptionId(demo.descriptionId); - - Resources resources = getContext().getResources(); - String title = resources.getString(demo.titleId); - String description = resources.getString(demo.descriptionId); - featureView.setContentDescription(title + ". " + description); - - return featureView; - } - } +public final class MainActivity extends CatalogActivity { @Override - protected void onCreate(Bundle savedInstanceState) { + protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); - setContentView(com.example.common_ui.R.layout.main); - - ListAdapter adapter = new CustomArrayAdapter(this, DemoDetailsList.DEMOS); - - ListView demoListView = findViewById(com.example.common_ui.R.id.list); - if (demoListView != null) { - demoListView.setAdapter(adapter); - demoListView.setOnItemClickListener( - (parent, view, position, id) -> { - DemoDetails demo = (DemoDetails) parent.getItemAtPosition(position); - startActivity(new Intent(view.getContext(), demo.activityClass)); - }); + if (BuildConfig.MAPS_API_KEY.isEmpty()) { + Toast.makeText( + this, + "Add your own API key in secrets.properties as MAPS_API_KEY=YOUR_API_KEY", + Toast.LENGTH_LONG + ).show(); } - applyInsets(findViewById(com.example.common_ui.R.id.map_container)); } } diff --git a/ApiDemos/project/kotlin-app/src/main/AndroidManifest.xml b/ApiDemos/project/kotlin-app/src/main/AndroidManifest.xml index af57f3f7a..4dfa02c44 100644 --- a/ApiDemos/project/kotlin-app/src/main/AndroidManifest.xml +++ b/ApiDemos/project/kotlin-app/src/main/AndroidManifest.xml @@ -51,6 +51,11 @@ + + ?, view: View?, position: Int, id: Long) { - val demo: DemoDetails = parent?.adapter?.getItem(position) as DemoDetails - startActivity(Intent(this, demo.activityClass)) - } +class MainActivity : CatalogActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContentView(R.layout.main) - val listAdapter: ListAdapter = CustomArrayAdapter(this, DemoDetailsList.DEMOS) - - with(findViewById(R.id.top_bar)) { - title = getString(R.string.demo_title_kotlin) - } - - // Find the view that will show empty message if there is no demo in DemoDetailsList.DEMOS - val emptyMessage = findViewById(R.id.empty) - with(findViewById(R.id.list)) { - adapter = listAdapter - onItemClickListener = this@MainActivity - emptyView = emptyMessage - } - if (BuildConfig.MAPS_API_KEY.isEmpty()) { - Toast.makeText(this, "Add your own API key in secrets.properties as MAPS_API_KEY=YOUR_API_KEY", Toast.LENGTH_LONG).show() - } - applyInsets(findViewById(R.id.map_container)) - } - - /** - * A custom array adapter that shows a {@link FeatureView} containing details about the demo. - * - * @param context current activity - * @param demos An array containing the details of the demos to be displayed. - */ - @SuppressLint("ResourceType") - class CustomArrayAdapter(context: Context, demos: List) : - ArrayAdapter(context, R.layout.feature, demos) { - - override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { - val demo: DemoDetails? = getItem(position) - return (convertView as? FeatureView ?: FeatureView(context)).apply { - if (demo != null) { - setTitleId(demo.titleId) - setDescriptionId(demo.descriptionId) - contentDescription = resources.getString(demo.titleId) - } - } + Toast.makeText( + this, + "Add your own API key in secrets.properties as MAPS_API_KEY=YOUR_API_KEY", + Toast.LENGTH_LONG + ).show() } } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f2c4ccacd..a9781cf67 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -97,6 +97,7 @@ compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeB compose-foundation = { module = "androidx.compose.foundation:foundation", version.ref = "compose" } compose-material = { module = "androidx.compose.material:material", version.ref = "compose" } material = { group = "com.google.android.material", name = "material", version.ref = "material" } +material-icons-core = { module = "androidx.compose.material:material-icons-core" } material-icons-extended = { module = "androidx.compose.material:material-icons-extended", version.ref = "materialIconsExtended" } material3 = { module = "androidx.compose.material3:material3", version.ref = "material3" } ui = { module = "androidx.compose.ui:ui", version.ref = "compose" }